diff --git a/.gitignore b/.gitignore
index a56a938..33ba5fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@
.godot/
/android/
/builds/
+
+# Blender backup files
+*.blend1
diff --git a/ASSET-LICENSE.md b/ASSET-LICENSE.md
new file mode 100644
index 0000000..50933b5
--- /dev/null
+++ b/ASSET-LICENSE.md
@@ -0,0 +1,135 @@
+# NETfishing Asset License 1.0
+
+Copyright © 2026 Woofmeow. All rights reserved except for the permissions
+expressly granted below.
+
+## 1. Scope
+
+This license applies to original NETfishing artwork, graphics, textures,
+models, animations, music, sound recordings, narrative content, visual
+designs, characters, and other creative assets owned by the NETfishing owners,
+publishing as Woofmeow (the **“NETfishing Assets”**).
+
+It does not apply to:
+
+- software source code identified as GPL-3.0-or-later;
+- third-party material identified in the source repository's
+ `docs/ATTRIBUTION.md` or the packaged `ATTRIBUTION.md` notice;
+- material accompanied by a different license; or
+- the NETfishing and Woofmeow names and logos, which are addressed in
+ `TRADEMARKS.md`.
+
+Repository location alone does not change third-party ownership or licensing.
+
+## 2. Definitions
+
+**Official NETfishing Software** means:
+
+- an unmodified binary release published by the NETfishing owners through an
+ official NETfishing distribution channel; or
+- a build made from an unmodified official NETfishing source release or tagged
+ revision.
+
+**Mod** means a non-standalone add-on created to operate with Official
+NETfishing Software. A Mod may add, replace, or alter content loaded by the
+Official NETfishing Software, but it may not redistribute a replacement copy
+of the game or represent a fork, clone, sequel, or separate game.
+
+## 3. Permission to use the official game
+
+You may reproduce NETfishing Assets as technically necessary to install, run,
+back up, and privately modify a lawfully obtained copy of Official NETfishing
+Software.
+
+You may capture, publish, and monetize screenshots, gameplay videos,
+livestreams, reviews, tutorials, and similar commentary showing a lawfully
+obtained copy of NETfishing. This permission does not authorize redistribution
+of the source asset files or imply endorsement.
+
+## 4. Community Mod license
+
+Subject to this license, you may create, modify, and distribute NETfishing
+Assets solely as part of a Mod for Official NETfishing Software.
+
+This permission is worldwide, nonexclusive, royalty-free, and available for
+as long as you comply with this license. It permits only the copies of
+NETfishing Assets reasonably necessary for the Mod to function. It does not
+permit distributing the original game, a substantially complete collection of
+its assets, or a general-purpose asset pack.
+
+A distributed Mod must:
+
+1. require a separately obtained copy of Official NETfishing Software;
+2. be presented as unofficial community content;
+3. include the following notice in its documentation or distribution page:
+
+ > Contains or adapts NETfishing assets © 2026 Woofmeow, used under the
+ > NETfishing Asset License 1.0. This Mod is unofficial and is not endorsed
+ > by Woofmeow.
+
+4. identify material created by the Mod author separately from NETfishing
+ Assets; and
+5. comply with `TRADEMARKS.md`.
+
+Mods may be shared without charge and may accept voluntary donations that do
+not purchase access, features, or preferential treatment. Selling a Mod,
+charging for access to it, licensing it commercially, or using NETfishing
+Assets in paid products requires separate written permission from both
+NETfishing owners.
+
+## 5. Uses not permitted
+
+Except with separate written permission from both NETfishing owners, you may
+not use or distribute NETfishing Assets:
+
+- in a fork, clone, remake, sequel, or standalone game;
+- with a modified distribution of the GPL-covered NETfishing code that is not
+ Official NETfishing Software;
+- in another game, application, asset library, template, or merchandise;
+- as a substantially complete asset extraction or archive;
+- in a way that implies sponsorship, endorsement, or official status;
+- to register a confusingly similar trademark, domain, account, or product
+ identity; or
+- under terms that claim ownership of the NETfishing Assets themselves.
+
+The GPL permits forks and redistribution of the GPL-covered code. This asset
+license does not permit those forks to carry NETfishing Assets. A code fork
+must remove or replace the NETfishing Assets and reserved branding unless both
+owners grant separate written permission.
+
+## 6. Ownership of Mods
+
+You retain ownership of your original Mod material. The NETfishing owners
+retain ownership of the NETfishing Assets and adaptations of those assets to
+the extent provided by applicable law.
+
+This license does not require you to assign your original Mod material to the
+NETfishing owners. It also does not grant you ownership of the underlying
+NETfishing Assets.
+
+## 7. Termination and cure
+
+Your permissions under this license terminate automatically if you violate
+its terms. They are reinstated provisionally when you stop the violation and
+permanently if both NETfishing owners do not notify you of the violation
+within sixty days after it has been cured.
+
+Termination does not limit remedies available for activity outside this
+license.
+
+## 8. No warranty
+
+THE NETFISHING ASSETS ARE PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE, TITLE, OR NON-INFRINGEMENT. TO THE MAXIMUM EXTENT
+PERMITTED BY LAW, THE OWNERS WILL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR
+OTHER LIABILITY ARISING FROM THIS LICENSE OR USE OF THE NETFISHING ASSETS.
+
+## 9. Additional permission
+
+Uses not granted here require written permission from both NETfishing owners.
+A permission granted to one person or project does not amend this license for
+others.
+
+This is a custom asset license, not an open-source software license or a
+Creative Commons license.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..62c7813
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,62 @@
+# Contributing
+
+NETfishing is currently developed through focused changes on the repository's
+active development branch. Coordinate scope with the project owner before
+starting substantial work.
+
+By intentionally submitting material for inclusion, contributors agree to the
+applicable grants and representations in
+[`CONTRIBUTOR-TERMS.md`](CONTRIBUTOR-TERMS.md). Maintainers must preserve an
+affirmative record of that agreement. Contact the owners before submitting if
+separate written terms are needed.
+
+## Change discipline
+
+- Keep a change focused; avoid unrelated cleanup.
+- Inspect the working tree before editing and preserve existing work.
+- Do not change protocol, save, settings, identity, or portable-data versions
+ as release-label housekeeping.
+- Treat the host as authoritative for catches, purchases, sales, jobs, mail,
+ and other shared state.
+- Keep presentation-only systems out of save files and network messages.
+- Store runtime assets under repository-owned paths; never reference a
+ workstation sync or temporary directory.
+- Record the source and license of newly imported assets in
+ [`docs/ATTRIBUTION.md`](docs/ATTRIBUTION.md).
+- Do not assume the GPL code license applies to project assets; observe
+ [`ASSET-LICENSE.md`](ASSET-LICENSE.md) and third-party terms.
+- Do not commit `.godot/`, test data, logs, captures, or build outputs.
+
+## Before requesting review
+
+```sh
+git diff --check
+scripts/run_validations.sh quick
+git status --short
+```
+
+Choose additional focused tests from the validation section of
+[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#validation).
+Changes involving networking should run the network suite and a real
+two-process check. Visual changes still require graphical review; headless
+tests are supporting evidence, not a replacement.
+
+## Commits
+
+- Stage audited paths explicitly.
+- Use a concise imperative subject that describes the outcome.
+- Do not mix generated build artifacts with source changes.
+- Do not rewrite published release tags.
+- Describe behavior, compatibility impact, validation, and asset provenance in
+ the review or release record.
+
+## Style
+
+Follow the conventions already present in the surrounding GDScript, scene, and
+resource files. Prefer typed values, named constants, shared resources, and
+small domain-specific services. Avoid duplicating protocol definitions or
+using display strings as persistent identifiers.
+
+New standalone source files should use `SPDX-License-Identifier:
+GPL-3.0-or-later` where the file format permits comments. Do not add that
+identifier to asset files governed by the NETfishing Asset License.
diff --git a/CONTRIBUTOR-TERMS.md b/CONTRIBUTOR-TERMS.md
new file mode 100644
index 0000000..8ef207e
--- /dev/null
+++ b/CONTRIBUTOR-TERMS.md
@@ -0,0 +1,94 @@
+# NETfishing contributor terms
+
+These terms apply to contributions intentionally submitted for inclusion in
+NETfishing after these terms are published. They do not retroactively change
+ownership of earlier work or replace a separately signed agreement.
+
+“Owners” means the two NETfishing co-owners who publish the Project as
+Woofmeow and are publicly credited as Voyager and Endeavour. “Project” means
+the official NETfishing game, its servers, tools, documentation, websites,
+ports, updates, and promotional materials.
+
+## 1. Your ownership and authority
+
+You retain copyright in your original contribution. By submitting it, you
+represent that:
+
+- you created it or otherwise have authority to grant these permissions;
+- its submission does not knowingly violate another party’s copyright,
+ patent, trademark, privacy, contract, or other rights;
+- you have disclosed relevant third-party material and license terms; and
+- if an employer or another organization controls the contribution, you have
+ obtained its permission before submitting.
+
+Do not submit confidential information, unlawfully obtained material, or
+content whose origin and permitted use cannot be documented.
+
+## 2. Code contributions
+
+You license an accepted software-code contribution under the GNU General
+Public License, version 3 or, at the recipient’s option, any later version
+(GPL-3.0-or-later).
+
+You also grant each recipient a perpetual, worldwide, nonexclusive,
+royalty-free patent license under patent claims you can license that are
+necessarily infringed by your contribution alone or by its combination with
+the Project as submitted. This patent license does not cover later
+combinations that are unrelated to your submitted contribution.
+
+## 3. Asset, writing, and documentation contributions
+
+For an accepted contribution consisting of artwork, models, animation, audio,
+music, writing, level content, branding material, or other non-code creative
+work, you grant the Owners a perpetual, worldwide, nonexclusive,
+royalty-free, transferable, and sublicensable license to:
+
+- use, reproduce, modify, adapt, translate, and create derivative works;
+- distribute, publicly display, publicly perform, transmit, and make
+ available the contribution;
+- include it in free and commercial Project releases, ports, updates,
+ merchandise, marketing, and archival materials; and
+- license it to Project users under `ASSET-LICENSE.md` or a later Project asset
+ license jointly approved by the Owners.
+
+This grant is limited to NETfishing, its promotion, and official successor or
+derived NETfishing products. It does not transfer your copyright or authorize
+the Owners to sell your contribution as an unrelated standalone asset.
+
+## 4. Documentation accompanying code
+
+Documentation submitted as an ordinary part of a code contribution may be
+distributed under GPL-3.0-or-later with that code. Project credits and
+provenance records may be reproduced in any medium needed to document the
+Project and its releases.
+
+## 5. Attribution
+
+Provide the name or pseudonym you want used for credit. The Owners will make a
+reasonable effort to preserve material attribution, subject to formatting,
+privacy, platform, and practical limitations.
+
+Attribution does not imply that a contributor endorses every Project change
+or release.
+
+## 6. No automatic ownership, employment, or payment
+
+Submitting a contribution does not provide ownership of NETfishing, create an
+employment, agency, partnership, or fiduciary relationship, or create a right
+to compensation. Any payment, ownership, or employment arrangement requires a
+separate written agreement signed by the appropriate parties.
+
+## 7. Acceptance
+
+A contribution is not accepted merely because it was posted, uploaded, or
+discussed. The Owners or their authorized maintainers may accept, modify,
+decline, or later remove it.
+
+By intentionally submitting a patch, pull request, asset, or other material
+for Project inclusion after receiving notice of these terms, you agree to the
+applicable grants above. Maintainers should record the contributor’s
+affirmative agreement in the pull request, issue, email, or another durable
+written record.
+
+If you cannot agree to these terms, do not submit the contribution; contact
+the Owners to discuss a separate written arrangement.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f6cdd22
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,232 @@
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for software and other kinds of works.
+
+The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and modification follow.
+
+TERMS AND CONDITIONS
+
+0. Definitions.
+
+“This License” refers to version 3 of the GNU General Public License.
+
+“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
+
+“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
+
+To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
+
+A “covered work” means either the unmodified Program or a work based on the Program.
+
+To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
+
+To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
+
+A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
+
+The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
+
+The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same work.
+
+2. Basic Permissions.
+All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
+
+4. Conveying Verbatim Copies.
+You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
+
+ c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
+
+ d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
+
+A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
+
+“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
+
+The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
+
+7. Additional Terms.
+“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
+
+8. Termination.
+You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
+
+An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
+
+11. Patents.
+A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
+
+A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
+
+In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
+
+A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
+
+13. Use with the GNU Affero General Public License.
+Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
+
+14. Revised Versions of this License.
+The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
+
+Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
+
+15. Disclaimer of Warranty.
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
+
+You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see .
+
+The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read .
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b3d3e3f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,93 @@
+# NETfishing
+
+NETfishing is a multiplayer-first fishing game built with Godot 4.7. The
+current pre-alpha includes host-authoritative fishing and economy flows,
+freshwater and saltwater habitats, persistent player data, social features,
+customization, jobs, and a controller-aware interface.
+
+The project is under active development. Interfaces, balance, content, and
+save migrations may continue to change before a stable release.
+
+NETfishing is developed and published by **Woofmeow**, the independent team of
+co-owners publicly credited as Voyager and Endeavour.
+
+## Requirements
+
+- Godot 4.7.x with the GL Compatibility renderer
+- Linux, Windows, or an Android development environment supported by Godot
+- Bash for the repository helper scripts
+
+Open `project.godot` in Godot, or start the editor from the repository root:
+
+```sh
+godot --editor --path .
+```
+
+Run the game directly with:
+
+```sh
+godot --path .
+```
+
+## Validation
+
+The validation scripts isolate Godot's settings and application-data roots.
+Run the fast, deterministic suite before submitting a change:
+
+```sh
+scripts/run_validations.sh quick
+```
+
+Additional suites and individual commands are documented in the
+[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#validation) validation section.
+
+## Building
+
+Export presets are maintained for Linux, Windows, and Android. Repository
+scripts expect the matching Godot export templates and platform toolchains to
+already be installed:
+
+```sh
+scripts/build_playtest.sh
+scripts/build_android_debug.sh
+```
+
+Build outputs belong under the ignored `builds/` directory. Release builds
+must follow the project's separate tagged-release checklist; these convenience
+scripts do not create or publish tags.
+
+## Project map
+
+- `main/`: composition root and application orchestration
+- `world/`: map, environment, water, and world presentation
+- `player/`, `fishing/`, `fish/`: player and fishing domain
+- `network/`: transport, identity, and host-authoritative services
+- `inventory/`, `items/`, `economy/`, `progression/`, `jobs/`: game state
+- `save/`, `settings/`: versioned persistence
+- `ui/`: gameplay and menu presentation
+- `tests/`: focused Godot validation scripts
+- `scripts/`: build, bake, and validation entry points
+
+All maintained project guidance starts at the
+[`docs/` documentation index](docs/README.md). Architecture, content policy,
+world and UI authoring, and testing are consolidated in
+[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md).
+
+## Licensing
+
+- Project-owned software code is licensed under
+ [GPL-3.0-or-later](LICENSE).
+- Project-owned artwork, models, music, and other creative assets are covered
+ by the [NETfishing Asset License](ASSET-LICENSE.md). It permits community
+ Mods for official NETfishing software, but not use in forks, clones, or
+ unrelated products.
+- The NETfishing and Woofmeow names and branding are reserved as described in
+ [TRADEMARKS.md](TRADEMARKS.md).
+- Third-party materials retain their own terms; see the consolidated
+ [credits, notices, and provenance record](docs/ATTRIBUTION.md).
+- Contributions are accepted under [CONTRIBUTOR-TERMS.md](CONTRIBUTOR-TERMS.md).
+
+Material boundaries and binary-release requirements are recorded in that same
+[`docs/ATTRIBUTION.md`](docs/ATTRIBUTION.md) reference.
+
+Copyright © 2026 Woofmeow.
diff --git a/TRADEMARKS.md b/TRADEMARKS.md
new file mode 100644
index 0000000..6848f47
--- /dev/null
+++ b/TRADEMARKS.md
@@ -0,0 +1,42 @@
+# NETfishing and Woofmeow branding
+
+The NETfishing and Woofmeow names, logos, icons, trade dress, and other source
+identifiers are reserved by the NETfishing owners, publishing as Woofmeow.
+Their presence in a GPL-licensed repository does not grant trademark rights or
+imply permission to represent another product as official.
+
+No trademark registration is asserted by this notice. Applicable rights may
+exist through use and other law.
+
+## Permitted references
+
+You may use the names NETfishing and Woofmeow truthfully to:
+
+- refer to the official game or development team;
+- state that software interoperates with or is a Mod for NETfishing;
+- link to an official repository or distribution page; and
+- discuss, review, criticize, or report on the Project.
+
+Use only the amount of branding reasonably necessary to identify the Project.
+Do not imply sponsorship, certification, endorsement, or official status.
+
+An unofficial Mod may use wording such as “Example Mod for NETfishing” if its
+name and presentation remain clearly distinct and it displays an unofficial
+notice.
+
+## Forks and redistributed code
+
+The GNU GPL permits forks and redistribution of covered code. A fork or
+modified distribution must:
+
+- use a distinct product name;
+- remove NETfishing and Woofmeow logos, icons, and official trade dress;
+- avoid confusingly similar names, domains, package identifiers, and account
+ names; and
+- clearly identify itself as independent and unofficial.
+
+These branding terms do not limit rights granted by the GPL to the software
+code or lawful nominative, descriptive, comparative, or fair use.
+
+Permission for broader branding use must be obtained in writing from both
+NETfishing owners.
diff --git a/addons/netfishing_shoreline_baker/plugin.cfg b/addons/netfishing_shoreline_baker/plugin.cfg
new file mode 100644
index 0000000..c8c4d06
--- /dev/null
+++ b/addons/netfishing_shoreline_baker/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="NETfishing Shoreline Auto-Baker"
+description="Rebuilds configured static shoreline ribbons after authored terrain reimports."
+author="woofmeow"
+version="1.0.0"
+script="shoreline_auto_baker.gd"
diff --git a/addons/netfishing_shoreline_baker/shoreline_auto_baker.gd b/addons/netfishing_shoreline_baker/shoreline_auto_baker.gd
new file mode 100644
index 0000000..18da4af
--- /dev/null
+++ b/addons/netfishing_shoreline_baker/shoreline_auto_baker.gd
@@ -0,0 +1,66 @@
+@tool
+extends EditorPlugin
+
+const TERRAIN_RESOURCE := (
+ "res://art/exported/environment/terrain/starter_island.glb"
+)
+const REGION_SCENE := "res://world/regions/starter_island_region.tscn"
+
+var _bake_pending := false
+
+
+func _enter_tree() -> void:
+ var filesystem := get_editor_interface().get_resource_filesystem()
+ if not filesystem.resources_reimported.is_connected(_on_resources_reimported):
+ filesystem.resources_reimported.connect(_on_resources_reimported)
+
+
+func _exit_tree() -> void:
+ var filesystem := get_editor_interface().get_resource_filesystem()
+ if filesystem.resources_reimported.is_connected(_on_resources_reimported):
+ filesystem.resources_reimported.disconnect(_on_resources_reimported)
+
+
+func _on_resources_reimported(resources: PackedStringArray) -> void:
+ if not resources.has(TERRAIN_RESOURCE) or _bake_pending:
+ return
+ _bake_pending = true
+ call_deferred("_bake_configured_shorelines")
+
+
+func _bake_configured_shorelines() -> void:
+ _bake_pending = false
+ var packed_region := ResourceLoader.load(
+ REGION_SCENE,
+ "PackedScene",
+ ResourceLoader.CACHE_MODE_REPLACE,
+ ) as PackedScene
+ if packed_region == null:
+ push_error("Shoreline auto-bake could not load %s." % REGION_SCENE)
+ return
+ var region := packed_region.instantiate() as Node3D
+ add_child(region)
+ var baker := region.get_node_or_null(
+ "ShorelineRibbonBaker"
+ ) as ShorelineRibbonBaker
+ if baker == null:
+ push_error("Shoreline auto-bake found no configured baker.")
+ region.queue_free()
+ return
+ var results := baker.rebuild_all()
+ for result: Dictionary in results:
+ if result.get("skipped", false):
+ continue
+ var output_path: String = result.get("output_path", "")
+ if not output_path.is_empty():
+ ResourceLoader.load(
+ output_path,
+ "ArrayMesh",
+ ResourceLoader.CACHE_MODE_REPLACE,
+ )
+ print(
+ "Shoreline auto-bake: %s, %d loops, %d triangles"
+ % [output_path, result.loop_count, result.triangle_count]
+ )
+ region.queue_free()
+ get_editor_interface().get_resource_filesystem().scan()
diff --git a/addons/netfishing_shoreline_baker/shoreline_auto_baker.gd.uid b/addons/netfishing_shoreline_baker/shoreline_auto_baker.gd.uid
new file mode 100644
index 0000000..a143fcb
--- /dev/null
+++ b/addons/netfishing_shoreline_baker/shoreline_auto_baker.gd.uid
@@ -0,0 +1 @@
+uid://cbk4xpalei5v3
diff --git a/art/exported/characters/base/netfishing_base_character.glb b/art/exported/characters/base/netfishing_base_character.glb
new file mode 100644
index 0000000..b7bc2f7
Binary files /dev/null and b/art/exported/characters/base/netfishing_base_character.glb differ
diff --git a/art/exported/characters/base/netfishing_base_character.glb.import b/art/exported/characters/base/netfishing_base_character.glb.import
new file mode 100644
index 0000000..b1409e1
--- /dev/null
+++ b/art/exported/characters/base/netfishing_base_character.glb.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://w7n4gjqq1juc"
+path="res://.godot/imported/netfishing_base_character.glb-0167c087f16181d405afb962dc877d2c.scn"
+
+[deps]
+
+source_file="res://art/exported/characters/base/netfishing_base_character.glb"
+dest_files=["res://.godot/imported/netfishing_base_character.glb-0167c087f16181d405afb962dc877d2c.scn"]
+
+[params]
+
+nodes/root_type=""
+nodes/root_name=""
+nodes/root_script=null
+mesh_library/use_node_names_as_mesh_names=false
+array_mesh/deduplicate_surfaces=true
+nodes/apply_root_scale=true
+nodes/root_scale=1.0
+nodes/import_as_skeleton_bones=false
+nodes/use_name_suffixes=true
+nodes/use_node_type_suffixes=true
+meshes/ensure_tangents=true
+meshes/generate_lods=true
+meshes/create_shadow_meshes=true
+meshes/light_baking=1
+meshes/lightmap_texel_size=0.2
+meshes/force_disable_compression=false
+skins/use_named_skins=true
+animation/import=true
+animation/fps=30
+animation/trimming=false
+animation/remove_immutable_tracks=true
+animation/import_rest_as_RESET=false
+import_script/path=""
+materials/extract=0
+materials/extract_format=0
+materials/extract_path=""
+_subresources={}
+gltf/naming_version=2
+gltf/embedded_image_handling=1
+gltf/texture_map_mode=1
diff --git a/art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png b/art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png
new file mode 100644
index 0000000..36e6859
Binary files /dev/null and b/art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png differ
diff --git a/art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png.import b/art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png.import
new file mode 100644
index 0000000..4141bca
--- /dev/null
+++ b/art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dyjwgkgw0syo8"
+path.s3tc="res://.godot/imported/netfishing_base_character_eyes_simple_shine.png-140bfa3aa0b6f662e8ff9e5721213a61.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_base_character_eyes_simple_shine.png-140bfa3aa0b6f662e8ff9e5721213a61.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "c2f7c29aaaa95809e140eb308d9bb132"
+}
+
+[deps]
+
+source_file="res://art/exported/characters/base/netfishing_base_character_eyes_simple_shine.png"
+dest_files=["res://.godot/imported/netfishing_base_character_eyes_simple_shine.png-140bfa3aa0b6f662e8ff9e5721213a61.s3tc.ctex", "res://.godot/imported/netfishing_base_character_eyes_simple_shine.png-140bfa3aa0b6f662e8ff9e5721213a61.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/exported/characters/base/netfishing_base_character_mouth_three.png b/art/exported/characters/base/netfishing_base_character_mouth_three.png
new file mode 100644
index 0000000..527f300
Binary files /dev/null and b/art/exported/characters/base/netfishing_base_character_mouth_three.png differ
diff --git a/art/exported/characters/base/netfishing_base_character_mouth_three.png.import b/art/exported/characters/base/netfishing_base_character_mouth_three.png.import
new file mode 100644
index 0000000..10d0721
--- /dev/null
+++ b/art/exported/characters/base/netfishing_base_character_mouth_three.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dt5gitdtjdvcd"
+path.s3tc="res://.godot/imported/netfishing_base_character_mouth_three.png-90b4a98403d2a291a56a8ed42aaa8473.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_base_character_mouth_three.png-90b4a98403d2a291a56a8ed42aaa8473.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "f8bf25a1006c5cc2ab5323f4a758eec7"
+}
+
+[deps]
+
+source_file="res://art/exported/characters/base/netfishing_base_character_mouth_three.png"
+dest_files=["res://.godot/imported/netfishing_base_character_mouth_three.png-90b4a98403d2a291a56a8ed42aaa8473.s3tc.ctex", "res://.godot/imported/netfishing_base_character_mouth_three.png-90b4a98403d2a291a56a8ed42aaa8473.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/exported/characters/base/netfishing_base_character_nose_dog_round.png b/art/exported/characters/base/netfishing_base_character_nose_dog_round.png
new file mode 100644
index 0000000..18a4590
Binary files /dev/null and b/art/exported/characters/base/netfishing_base_character_nose_dog_round.png differ
diff --git a/art/exported/characters/base/netfishing_base_character_nose_dog_round.png.import b/art/exported/characters/base/netfishing_base_character_nose_dog_round.png.import
new file mode 100644
index 0000000..71d25b6
--- /dev/null
+++ b/art/exported/characters/base/netfishing_base_character_nose_dog_round.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bmho1ryst3smt"
+path.s3tc="res://.godot/imported/netfishing_base_character_nose_dog_round.png-0008adff5271dd0403e610ef13dd52cf.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_base_character_nose_dog_round.png-0008adff5271dd0403e610ef13dd52cf.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "3134f170152f9fa1512b41b61ecb01fc"
+}
+
+[deps]
+
+source_file="res://art/exported/characters/base/netfishing_base_character_nose_dog_round.png"
+dest_files=["res://.godot/imported/netfishing_base_character_nose_dog_round.png-0008adff5271dd0403e610ef13dd52cf.s3tc.ctex", "res://.godot/imported/netfishing_base_character_nose_dog_round.png-0008adff5271dd0403e610ef13dd52cf.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/exported/characters/faces/eyes/eyes_alligator_extra.png b/art/exported/characters/faces/eyes/eyes_alligator_extra.png
new file mode 100644
index 0000000..e8c7be1
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_alligator_extra.png differ
diff --git a/icon.svg.import b/art/exported/characters/faces/eyes/eyes_alligator_extra.png.import
similarity index 69%
rename from icon.svg.import
rename to art/exported/characters/faces/eyes/eyes_alligator_extra.png.import
index 1b9da13..c9348c7 100644
--- a/icon.svg.import
+++ b/art/exported/characters/faces/eyes/eyes_alligator_extra.png.import
@@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
-uid="uid://cxpiqjwgff0xr"
-path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+uid="uid://c4rj1r1doawb4"
+path="res://.godot/imported/eyes_alligator_extra.png-30730001eeef37c24fac0158f6613bd7.ctex"
metadata={
"vram_texture": false
}
[deps]
-source_file="res://icon.svg"
-dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
+source_file="res://art/exported/characters/faces/eyes/eyes_alligator_extra.png"
+dest_files=["res://.godot/imported/eyes_alligator_extra.png-30730001eeef37c24fac0158f6613bd7.ctex"]
[params]
@@ -38,6 +38,3 @@ process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/art/exported/characters/faces/eyes/eyes_alligator_eyes.png b/art/exported/characters/faces/eyes/eyes_alligator_eyes.png
new file mode 100644
index 0000000..74736eb
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_alligator_eyes.png differ
diff --git a/items/icons/placeholder/snack.svg.import b/art/exported/characters/faces/eyes/eyes_alligator_eyes.png.import
similarity index 67%
rename from items/icons/placeholder/snack.svg.import
rename to art/exported/characters/faces/eyes/eyes_alligator_eyes.png.import
index 5ad06b4..7f2eb8d 100644
--- a/items/icons/placeholder/snack.svg.import
+++ b/art/exported/characters/faces/eyes/eyes_alligator_eyes.png.import
@@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
-uid="uid://i611n7iwr8i6"
-path="res://.godot/imported/snack.svg-dfbab25d47c1c79c46c1d7fcf9b21cfe.ctex"
+uid="uid://br2h5sfqmool8"
+path="res://.godot/imported/eyes_alligator_eyes.png-843d36964858cdf7ffafe1972ffed681.ctex"
metadata={
"vram_texture": false
}
[deps]
-source_file="res://items/icons/placeholder/snack.svg"
-dest_files=["res://.godot/imported/snack.svg-dfbab25d47c1c79c46c1d7fcf9b21cfe.ctex"]
+source_file="res://art/exported/characters/faces/eyes/eyes_alligator_eyes.png"
+dest_files=["res://.godot/imported/eyes_alligator_eyes.png-843d36964858cdf7ffafe1972ffed681.ctex"]
[params]
@@ -38,6 +38,3 @@ process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/art/exported/characters/faces/eyes/eyes_angry.png b/art/exported/characters/faces/eyes/eyes_angry.png
new file mode 100644
index 0000000..f88220e
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_angry.png differ
diff --git a/items/icons/placeholder/coffee.svg.import b/art/exported/characters/faces/eyes/eyes_angry.png.import
similarity index 67%
rename from items/icons/placeholder/coffee.svg.import
rename to art/exported/characters/faces/eyes/eyes_angry.png.import
index f8cbaa9..3be46d5 100644
--- a/items/icons/placeholder/coffee.svg.import
+++ b/art/exported/characters/faces/eyes/eyes_angry.png.import
@@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
-uid="uid://ddx3pbgh2ele5"
-path="res://.godot/imported/coffee.svg-aecf16cc8c38e5e0f432cd5395949bcf.ctex"
+uid="uid://b1ieqa8qos1vk"
+path="res://.godot/imported/eyes_angry.png-a114dbeb3a8a14b8d1d86dd5a614b228.ctex"
metadata={
"vram_texture": false
}
[deps]
-source_file="res://items/icons/placeholder/coffee.svg"
-dest_files=["res://.godot/imported/coffee.svg-aecf16cc8c38e5e0f432cd5395949bcf.ctex"]
+source_file="res://art/exported/characters/faces/eyes/eyes_angry.png"
+dest_files=["res://.godot/imported/eyes_angry.png-a114dbeb3a8a14b8d1d86dd5a614b228.ctex"]
[params]
@@ -38,6 +38,3 @@ process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/art/exported/characters/faces/eyes/eyes_anime_blue.png b/art/exported/characters/faces/eyes/eyes_anime_blue.png
new file mode 100644
index 0000000..39a6b86
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_anime_blue.png differ
diff --git a/items/icons/placeholder/fish_coin.svg.import b/art/exported/characters/faces/eyes/eyes_anime_blue.png.import
similarity index 66%
rename from items/icons/placeholder/fish_coin.svg.import
rename to art/exported/characters/faces/eyes/eyes_anime_blue.png.import
index 1dca3c6..443c8d0 100644
--- a/items/icons/placeholder/fish_coin.svg.import
+++ b/art/exported/characters/faces/eyes/eyes_anime_blue.png.import
@@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
-uid="uid://trqvs4dgd4rt"
-path="res://.godot/imported/fish_coin.svg-9d00b5918d94a555938a84ee65e06386.ctex"
+uid="uid://2ecbidhb33vy"
+path="res://.godot/imported/eyes_anime_blue.png-a86f72e375fc30cac2cd8320813c7c6c.ctex"
metadata={
"vram_texture": false
}
[deps]
-source_file="res://items/icons/placeholder/fish_coin.svg"
-dest_files=["res://.godot/imported/fish_coin.svg-9d00b5918d94a555938a84ee65e06386.ctex"]
+source_file="res://art/exported/characters/faces/eyes/eyes_anime_blue.png"
+dest_files=["res://.godot/imported/eyes_anime_blue.png-a86f72e375fc30cac2cd8320813c7c6c.ctex"]
[params]
@@ -38,6 +38,3 @@ process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/art/exported/characters/faces/eyes/eyes_anime_green.png b/art/exported/characters/faces/eyes/eyes_anime_green.png
new file mode 100644
index 0000000..e273c6e
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_anime_green.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_anime_green.png.import b/art/exported/characters/faces/eyes/eyes_anime_green.png.import
new file mode 100644
index 0000000..a1be2b0
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_anime_green.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bwrxrtiv375ne"
+path="res://.godot/imported/eyes_anime_green.png-a789a8f21c23e3085bca8992b3a3fc25.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_anime_green.png"
+dest_files=["res://.godot/imported/eyes_anime_green.png-a789a8f21c23e3085bca8992b3a3fc25.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_beady.png b/art/exported/characters/faces/eyes/eyes_beady.png
new file mode 100644
index 0000000..cdc5c2a
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_beady.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_beady.png.import b/art/exported/characters/faces/eyes/eyes_beady.png.import
new file mode 100644
index 0000000..829845c
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_beady.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bahypju2dturm"
+path="res://.godot/imported/eyes_beady.png-18d3b3ff0818d80f773d4f72d0b0061e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_beady.png"
+dest_files=["res://.godot/imported/eyes_beady.png-18d3b3ff0818d80f773d4f72d0b0061e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_beady_extra.png b/art/exported/characters/faces/eyes/eyes_beady_extra.png
new file mode 100644
index 0000000..437d483
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_beady_extra.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_beady_extra.png.import b/art/exported/characters/faces/eyes/eyes_beady_extra.png.import
new file mode 100644
index 0000000..2951456
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_beady_extra.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bqgdrdc0hpi7s"
+path="res://.godot/imported/eyes_beady_extra.png-d07d79345b1d22e55a1cf140f7682248.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_beady_extra.png"
+dest_files=["res://.godot/imported/eyes_beady_extra.png-d07d79345b1d22e55a1cf140f7682248.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_beady_extra_shine.png b/art/exported/characters/faces/eyes/eyes_beady_extra_shine.png
new file mode 100644
index 0000000..6c2a02b
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_beady_extra_shine.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_beady_extra_shine.png.import b/art/exported/characters/faces/eyes/eyes_beady_extra_shine.png.import
new file mode 100644
index 0000000..fe258f0
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_beady_extra_shine.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://mvjcdk84ko6f"
+path="res://.godot/imported/eyes_beady_extra_shine.png-c72a35f216a8c1a06122f7bd743ca6a4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_beady_extra_shine.png"
+dest_files=["res://.godot/imported/eyes_beady_extra_shine.png-c72a35f216a8c1a06122f7bd743ca6a4.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_beady_eyebrows.png b/art/exported/characters/faces/eyes/eyes_beady_eyebrows.png
new file mode 100644
index 0000000..80b1077
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_beady_eyebrows.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_beady_eyebrows.png.import b/art/exported/characters/faces/eyes/eyes_beady_eyebrows.png.import
new file mode 100644
index 0000000..4368bec
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_beady_eyebrows.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dp4sm67vn52um"
+path="res://.godot/imported/eyes_beady_eyebrows.png-4f63e1a7ca603e9daf2d37a1d182e93f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_beady_eyebrows.png"
+dest_files=["res://.godot/imported/eyes_beady_eyebrows.png-4f63e1a7ca603e9daf2d37a1d182e93f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_beady_whites.png b/art/exported/characters/faces/eyes/eyes_beady_whites.png
new file mode 100644
index 0000000..960fe50
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_beady_whites.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_beady_whites.png.import b/art/exported/characters/faces/eyes/eyes_beady_whites.png.import
new file mode 100644
index 0000000..2b6680e
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_beady_whites.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cw34wh51s5sxi"
+path="res://.godot/imported/eyes_beady_whites.png-87ce5dcbdfca9ca43a6f7c5ddca77bfa.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_beady_whites.png"
+dest_files=["res://.godot/imported/eyes_beady_whites.png-87ce5dcbdfca9ca43a6f7c5ddca77bfa.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_carrot_eyes.png b/art/exported/characters/faces/eyes/eyes_carrot_eyes.png
new file mode 100644
index 0000000..5a11a4b
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_carrot_eyes.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_carrot_eyes.png.import b/art/exported/characters/faces/eyes/eyes_carrot_eyes.png.import
new file mode 100644
index 0000000..80ef07a
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_carrot_eyes.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://vn7mkjkx80ix"
+path="res://.godot/imported/eyes_carrot_eyes.png-4d4af86c340a0dd0633449a9c6cc1bae.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_carrot_eyes.png"
+dest_files=["res://.godot/imported/eyes_carrot_eyes.png-4d4af86c340a0dd0633449a9c6cc1bae.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_cat_real.png b/art/exported/characters/faces/eyes/eyes_cat_real.png
new file mode 100644
index 0000000..365dd61
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_cat_real.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_cat_real.png.import b/art/exported/characters/faces/eyes/eyes_cat_real.png.import
new file mode 100644
index 0000000..2a4cbd3
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_cat_real.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://sbxopn55femq"
+path="res://.godot/imported/eyes_cat_real.png-13c9b6d280ac1000d177e74cbd33f07f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_cat_real.png"
+dest_files=["res://.godot/imported/eyes_cat_real.png-13c9b6d280ac1000d177e74cbd33f07f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_cat_real_hunter.png b/art/exported/characters/faces/eyes/eyes_cat_real_hunter.png
new file mode 100644
index 0000000..17e6c94
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_cat_real_hunter.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_cat_real_hunter.png.import b/art/exported/characters/faces/eyes/eyes_cat_real_hunter.png.import
new file mode 100644
index 0000000..9d9b065
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_cat_real_hunter.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cg1l01m0ae2oa"
+path="res://.godot/imported/eyes_cat_real_hunter.png-6eb486fc423d7e3fa84f71aa30ec04f3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_cat_real_hunter.png"
+dest_files=["res://.godot/imported/eyes_cat_real_hunter.png-6eb486fc423d7e3fa84f71aa30ec04f3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_close.png b/art/exported/characters/faces/eyes/eyes_close.png
new file mode 100644
index 0000000..1e0123f
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_close.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_close.png.import b/art/exported/characters/faces/eyes/eyes_close.png.import
new file mode 100644
index 0000000..25986e6
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_close.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://kbigjk76ejx8"
+path="res://.godot/imported/eyes_close.png-13a7da90295acf57ea489edcf26fc555.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_close.png"
+dest_files=["res://.godot/imported/eyes_close.png-13a7da90295acf57ea489edcf26fc555.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_closed.png b/art/exported/characters/faces/eyes/eyes_closed.png
new file mode 100644
index 0000000..d37f0a7
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_closed.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_closed.png.import b/art/exported/characters/faces/eyes/eyes_closed.png.import
new file mode 100644
index 0000000..af51540
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_closed.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b2ufsni8mnpmq"
+path="res://.godot/imported/eyes_closed.png-80789498475c2cfbcb6e3ac14e067a76.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_closed.png"
+dest_files=["res://.godot/imported/eyes_closed.png-80789498475c2cfbcb6e3ac14e067a76.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_cute.png b/art/exported/characters/faces/eyes/eyes_cute.png
new file mode 100644
index 0000000..1e078b9
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_cute.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_cute.png.import b/art/exported/characters/faces/eyes/eyes_cute.png.import
new file mode 100644
index 0000000..c8d9f09
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_cute.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b5455dbwse02f"
+path="res://.godot/imported/eyes_cute.png-e2eabbdf5281c15927e85f1e2f22b127.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_cute.png"
+dest_files=["res://.godot/imported/eyes_cute.png-e2eabbdf5281c15927e85f1e2f22b127.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_cute_close.png b/art/exported/characters/faces/eyes/eyes_cute_close.png
new file mode 100644
index 0000000..c49f1c5
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_cute_close.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_cute_close.png.import b/art/exported/characters/faces/eyes/eyes_cute_close.png.import
new file mode 100644
index 0000000..5b51dc2
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_cute_close.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://lp21yuhfqbc1"
+path="res://.godot/imported/eyes_cute_close.png-7a8a1b4e10864cfef604b9ced31a3f0c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_cute_close.png"
+dest_files=["res://.godot/imported/eyes_cute_close.png-7a8a1b4e10864cfef604b9ced31a3f0c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_huge.png b/art/exported/characters/faces/eyes/eyes_huge.png
new file mode 100644
index 0000000..5c1026f
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_huge.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_huge.png.import b/art/exported/characters/faces/eyes/eyes_huge.png.import
new file mode 100644
index 0000000..2c449bd
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_huge.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://vb1eaupu8fpm"
+path="res://.godot/imported/eyes_huge.png-9747926f369b1134a94639907057a08d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_huge.png"
+dest_files=["res://.godot/imported/eyes_huge.png-9747926f369b1134a94639907057a08d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_interested.png b/art/exported/characters/faces/eyes/eyes_interested.png
new file mode 100644
index 0000000..4e1239e
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_interested.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_interested.png.import b/art/exported/characters/faces/eyes/eyes_interested.png.import
new file mode 100644
index 0000000..061fe34
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_interested.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b3481384do6m4"
+path="res://.godot/imported/eyes_interested.png-5e8a34c0afaf7b4543f1b892af2227c7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_interested.png"
+dest_files=["res://.godot/imported/eyes_interested.png-5e8a34c0afaf7b4543f1b892af2227c7.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_jackolantern.png b/art/exported/characters/faces/eyes/eyes_jackolantern.png
new file mode 100644
index 0000000..98a0492
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_jackolantern.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_jackolantern.png.import b/art/exported/characters/faces/eyes/eyes_jackolantern.png.import
new file mode 100644
index 0000000..c447cdd
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_jackolantern.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cdqqfoss11muq"
+path="res://.godot/imported/eyes_jackolantern.png-16dc8591f3bd827988f8fcb90bbced59.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_jackolantern.png"
+dest_files=["res://.godot/imported/eyes_jackolantern.png-16dc8591f3bd827988f8fcb90bbced59.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_jackolantern_small.png b/art/exported/characters/faces/eyes/eyes_jackolantern_small.png
new file mode 100644
index 0000000..1bb27fe
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_jackolantern_small.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_jackolantern_small.png.import b/art/exported/characters/faces/eyes/eyes_jackolantern_small.png.import
new file mode 100644
index 0000000..8de58a2
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_jackolantern_small.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6n6riqe60s2f"
+path="res://.godot/imported/eyes_jackolantern_small.png-7608ab74472a3edb79fcddb1cafbcc2f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_jackolantern_small.png"
+dest_files=["res://.godot/imported/eyes_jackolantern_small.png-7608ab74472a3edb79fcddb1cafbcc2f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_punk_purple.png b/art/exported/characters/faces/eyes/eyes_punk_purple.png
new file mode 100644
index 0000000..2ab8a17
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_punk_purple.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_punk_purple.png.import b/art/exported/characters/faces/eyes/eyes_punk_purple.png.import
new file mode 100644
index 0000000..547a560
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_punk_purple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b2c784my766ag"
+path="res://.godot/imported/eyes_punk_purple.png-fc0ccb3a5f1d2c23f2f367bb306d1ada.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_punk_purple.png"
+dest_files=["res://.godot/imported/eyes_punk_purple.png-fc0ccb3a5f1d2c23f2f367bb306d1ada.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_sexy_blue.png b/art/exported/characters/faces/eyes/eyes_sexy_blue.png
new file mode 100644
index 0000000..3dffd6c
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_sexy_blue.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_sexy_blue.png.import b/art/exported/characters/faces/eyes/eyes_sexy_blue.png.import
new file mode 100644
index 0000000..7d2046d
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_sexy_blue.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://drajn1tk8gq4a"
+path="res://.godot/imported/eyes_sexy_blue.png-f99ecb6d89cb6c453161f6ad9fd5f802.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_sexy_blue.png"
+dest_files=["res://.godot/imported/eyes_sexy_blue.png-f99ecb6d89cb6c453161f6ad9fd5f802.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_sexy_brown.png b/art/exported/characters/faces/eyes/eyes_sexy_brown.png
new file mode 100644
index 0000000..bc53f8f
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_sexy_brown.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_sexy_brown.png.import b/art/exported/characters/faces/eyes/eyes_sexy_brown.png.import
new file mode 100644
index 0000000..a2b30f1
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_sexy_brown.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c38u5tdksowpw"
+path="res://.godot/imported/eyes_sexy_brown.png-e77cd183e70234d3ee7772ed2837cf34.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_sexy_brown.png"
+dest_files=["res://.godot/imported/eyes_sexy_brown.png-e77cd183e70234d3ee7772ed2837cf34.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_spider.png b/art/exported/characters/faces/eyes/eyes_spider.png
new file mode 100644
index 0000000..239707f
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_spider.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_spider.png.import b/art/exported/characters/faces/eyes/eyes_spider.png.import
new file mode 100644
index 0000000..3b137b9
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_spider.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bevrdbag3eixb"
+path="res://.godot/imported/eyes_spider.png-4cbe85913f69207b18d4b23095aca596.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_spider.png"
+dest_files=["res://.godot/imported/eyes_spider.png-4cbe85913f69207b18d4b23095aca596.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_surprised.png b/art/exported/characters/faces/eyes/eyes_surprised.png
new file mode 100644
index 0000000..be78ec8
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_surprised.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_surprised.png.import b/art/exported/characters/faces/eyes/eyes_surprised.png.import
new file mode 100644
index 0000000..0d7907b
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_surprised.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://chvod4aqeqn10"
+path="res://.godot/imported/eyes_surprised.png-21858dc3f7520b1423e3d0aff59b6896.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_surprised.png"
+dest_files=["res://.godot/imported/eyes_surprised.png-21858dc3f7520b1423e3d0aff59b6896.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_tired.png b/art/exported/characters/faces/eyes/eyes_tired.png
new file mode 100644
index 0000000..061b3e4
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_tired.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_tired.png.import b/art/exported/characters/faces/eyes/eyes_tired.png.import
new file mode 100644
index 0000000..9de4b0f
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_tired.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bimnlu17t6u6v"
+path="res://.godot/imported/eyes_tired.png-752efd150fd50cc078ee25c8bbbeeb36.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_tired.png"
+dest_files=["res://.godot/imported/eyes_tired.png-752efd150fd50cc078ee25c8bbbeeb36.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_tired_flat.png b/art/exported/characters/faces/eyes/eyes_tired_flat.png
new file mode 100644
index 0000000..faf123c
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_tired_flat.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_tired_flat.png.import b/art/exported/characters/faces/eyes/eyes_tired_flat.png.import
new file mode 100644
index 0000000..5a2ec55
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_tired_flat.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b4hmrvoblu3cl"
+path="res://.godot/imported/eyes_tired_flat.png-c441cfd39c6147e6a13989a4dd86be05.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_tired_flat.png"
+dest_files=["res://.godot/imported/eyes_tired_flat.png-c441cfd39c6147e6a13989a4dd86be05.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/eyes/eyes_upset.png b/art/exported/characters/faces/eyes/eyes_upset.png
new file mode 100644
index 0000000..1e570a3
Binary files /dev/null and b/art/exported/characters/faces/eyes/eyes_upset.png differ
diff --git a/art/exported/characters/faces/eyes/eyes_upset.png.import b/art/exported/characters/faces/eyes/eyes_upset.png.import
new file mode 100644
index 0000000..0c455d4
--- /dev/null
+++ b/art/exported/characters/faces/eyes/eyes_upset.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://blm1i6xyjclp5"
+path="res://.godot/imported/eyes_upset.png-a8509f19cb25f577e84ebe86d28a31b1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/eyes/eyes_upset.png"
+dest_files=["res://.godot/imported/eyes_upset.png-a8509f19cb25f577e84ebe86d28a31b1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_3.png b/art/exported/characters/faces/mouth/mouth_3.png
new file mode 100644
index 0000000..dcdb30c
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_3.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_3.png.import b/art/exported/characters/faces/mouth/mouth_3.png.import
new file mode 100644
index 0000000..eab9f48
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_3.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b5wnxcm61q4ou"
+path="res://.godot/imported/mouth_3.png-9fd9edc312c3b2c33df59da33d62bb30.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_3.png"
+dest_files=["res://.godot/imported/mouth_3.png-9fd9edc312c3b2c33df59da33d62bb30.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_3_cheeky.png b/art/exported/characters/faces/mouth/mouth_3_cheeky.png
new file mode 100644
index 0000000..b5f9797
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_3_cheeky.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_3_cheeky.png.import b/art/exported/characters/faces/mouth/mouth_3_cheeky.png.import
new file mode 100644
index 0000000..a53b280
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_3_cheeky.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dhaec7illng60"
+path="res://.godot/imported/mouth_3_cheeky.png-1c9ae1bcb18ff29fe44e13b55b692f57.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_3_cheeky.png"
+dest_files=["res://.godot/imported/mouth_3_cheeky.png-1c9ae1bcb18ff29fe44e13b55b692f57.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_buck teeth.png b/art/exported/characters/faces/mouth/mouth_buck teeth.png
new file mode 100644
index 0000000..9fde3c3
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_buck teeth.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_buck teeth.png.import b/art/exported/characters/faces/mouth/mouth_buck teeth.png.import
new file mode 100644
index 0000000..8909223
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_buck teeth.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dmvxpg0jbiyqw"
+path="res://.godot/imported/mouth_buck teeth.png-84be0f368b98b8640d5cc51d8a9b956f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_buck teeth.png"
+dest_files=["res://.godot/imported/mouth_buck teeth.png-84be0f368b98b8640d5cc51d8a9b956f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_cheeky_smile.png b/art/exported/characters/faces/mouth/mouth_cheeky_smile.png
new file mode 100644
index 0000000..addb335
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_cheeky_smile.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_cheeky_smile.png.import b/art/exported/characters/faces/mouth/mouth_cheeky_smile.png.import
new file mode 100644
index 0000000..926ef6c
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_cheeky_smile.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8tdtfnv3bgft"
+path="res://.godot/imported/mouth_cheeky_smile.png-d55ec6f5fd7e73fd8793d524475f9f1f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_cheeky_smile.png"
+dest_files=["res://.godot/imported/mouth_cheeky_smile.png-d55ec6f5fd7e73fd8793d524475f9f1f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png b/art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png
new file mode 100644
index 0000000..cef99aa
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png.import b/art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png.import
new file mode 100644
index 0000000..0b1450a
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8xyawmp5gru8"
+path="res://.godot/imported/mouth_cheeky_smile_onesided.png-366c346f7be47b1521677b84666a672b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_cheeky_smile_onesided.png"
+dest_files=["res://.godot/imported/mouth_cheeky_smile_onesided.png-366c346f7be47b1521677b84666a672b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_jackolantern.png b/art/exported/characters/faces/mouth/mouth_jackolantern.png
new file mode 100644
index 0000000..80b5ebe
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_jackolantern.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_jackolantern.png.import b/art/exported/characters/faces/mouth/mouth_jackolantern.png.import
new file mode 100644
index 0000000..1756a04
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_jackolantern.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ck4bbdnpf6ekd"
+path="res://.godot/imported/mouth_jackolantern.png-7f9373a2b59e617edd25d92ef63041f0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_jackolantern.png"
+dest_files=["res://.godot/imported/mouth_jackolantern.png-7f9373a2b59e617edd25d92ef63041f0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_jowls.png b/art/exported/characters/faces/mouth/mouth_jowls.png
new file mode 100644
index 0000000..05ecbd1
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_jowls.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_jowls.png.import b/art/exported/characters/faces/mouth/mouth_jowls.png.import
new file mode 100644
index 0000000..1af65fe
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_jowls.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dyynmci0hf3vq"
+path="res://.godot/imported/mouth_jowls.png-88cd6159c02a5b4eee74b98dc8248dcd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_jowls.png"
+dest_files=["res://.godot/imported/mouth_jowls.png-88cd6159c02a5b4eee74b98dc8248dcd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_jowls_brock.png b/art/exported/characters/faces/mouth/mouth_jowls_brock.png
new file mode 100644
index 0000000..3b3d4a3
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_jowls_brock.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_jowls_brock.png.import b/art/exported/characters/faces/mouth/mouth_jowls_brock.png.import
new file mode 100644
index 0000000..d2ec5c4
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_jowls_brock.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://duajvlqrieray"
+path="res://.godot/imported/mouth_jowls_brock.png-de76cffdce72eb30289c44bcb2e68ce6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_jowls_brock.png"
+dest_files=["res://.godot/imported/mouth_jowls_brock.png-de76cffdce72eb30289c44bcb2e68ce6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_jowls_simple.png b/art/exported/characters/faces/mouth/mouth_jowls_simple.png
new file mode 100644
index 0000000..f96856e
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_jowls_simple.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_jowls_simple.png.import b/art/exported/characters/faces/mouth/mouth_jowls_simple.png.import
new file mode 100644
index 0000000..f6cf4a0
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_jowls_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c37gty8itl5oe"
+path="res://.godot/imported/mouth_jowls_simple.png-0586400ebfd9ef98c9067f0ebbe730be.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_jowls_simple.png"
+dest_files=["res://.godot/imported/mouth_jowls_simple.png-0586400ebfd9ef98c9067f0ebbe730be.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_kissy.png b/art/exported/characters/faces/mouth/mouth_kissy.png
new file mode 100644
index 0000000..65af5d9
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_kissy.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_kissy.png.import b/art/exported/characters/faces/mouth/mouth_kissy.png.import
new file mode 100644
index 0000000..ff0e1c1
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_kissy.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://druncaq416lvu"
+path="res://.godot/imported/mouth_kissy.png-52ec7a06301d8506497442f0ea0054a0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_kissy.png"
+dest_files=["res://.godot/imported/mouth_kissy.png-52ec7a06301d8506497442f0ea0054a0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_kissy_lips.png b/art/exported/characters/faces/mouth/mouth_kissy_lips.png
new file mode 100644
index 0000000..b20c0d1
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_kissy_lips.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_kissy_lips.png.import b/art/exported/characters/faces/mouth/mouth_kissy_lips.png.import
new file mode 100644
index 0000000..2141904
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_kissy_lips.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://babmei7rq8uaf"
+path="res://.godot/imported/mouth_kissy_lips.png-a90d7b9649dbba8cc17232f59291a2c8.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_kissy_lips.png"
+dest_files=["res://.godot/imported/mouth_kissy_lips.png-a90d7b9649dbba8cc17232f59291a2c8.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_lil_c.png b/art/exported/characters/faces/mouth/mouth_lil_c.png
new file mode 100644
index 0000000..bd1219a
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_lil_c.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_lil_c.png.import b/art/exported/characters/faces/mouth/mouth_lil_c.png.import
new file mode 100644
index 0000000..1a7c28d
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_lil_c.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bfafrjmy6liy6"
+path="res://.godot/imported/mouth_lil_c.png-e65a240e587f99537a3a9f1d3f241323.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_lil_c.png"
+dest_files=["res://.godot/imported/mouth_lil_c.png-e65a240e587f99537a3a9f1d3f241323.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_open_ah.png b/art/exported/characters/faces/mouth/mouth_open_ah.png
new file mode 100644
index 0000000..b028f06
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_open_ah.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_open_ah.png.import b/art/exported/characters/faces/mouth/mouth_open_ah.png.import
new file mode 100644
index 0000000..5f8c2e7
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_open_ah.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://44cqu18imydc"
+path="res://.godot/imported/mouth_open_ah.png-2710d397264f57dbe8f9bac82dd4b0de.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_open_ah.png"
+dest_files=["res://.godot/imported/mouth_open_ah.png-2710d397264f57dbe8f9bac82dd4b0de.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_open_oh.png b/art/exported/characters/faces/mouth/mouth_open_oh.png
new file mode 100644
index 0000000..eba37ab
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_open_oh.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_open_oh.png.import b/art/exported/characters/faces/mouth/mouth_open_oh.png.import
new file mode 100644
index 0000000..e4602aa
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_open_oh.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c78hwv0t3ph47"
+path="res://.godot/imported/mouth_open_oh.png-10fc59160b782879c415074831421088.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_open_oh.png"
+dest_files=["res://.godot/imported/mouth_open_oh.png-10fc59160b782879c415074831421088.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_open_small.png b/art/exported/characters/faces/mouth/mouth_open_small.png
new file mode 100644
index 0000000..97c1755
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_open_small.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_open_small.png.import b/art/exported/characters/faces/mouth/mouth_open_small.png.import
new file mode 100644
index 0000000..9b15e51
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_open_small.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bn68lfabdo7ga"
+path="res://.godot/imported/mouth_open_small.png-30e9081c075d21159d6563a981b15378.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_open_small.png"
+dest_files=["res://.godot/imported/mouth_open_small.png-30e9081c075d21159d6563a981b15378.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_open_smile.png b/art/exported/characters/faces/mouth/mouth_open_smile.png
new file mode 100644
index 0000000..57675f8
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_open_smile.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_open_smile.png.import b/art/exported/characters/faces/mouth/mouth_open_smile.png.import
new file mode 100644
index 0000000..06e410b
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_open_smile.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b12jrc4ao3r16"
+path="res://.godot/imported/mouth_open_smile.png-2561ffea934645e2eac40ab3a0a6395a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_open_smile.png"
+dest_files=["res://.godot/imported/mouth_open_smile.png-2561ffea934645e2eac40ab3a0a6395a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_p_tounge.png b/art/exported/characters/faces/mouth/mouth_p_tounge.png
new file mode 100644
index 0000000..7e71399
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_p_tounge.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_p_tounge.png.import b/art/exported/characters/faces/mouth/mouth_p_tounge.png.import
new file mode 100644
index 0000000..1a791ce
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_p_tounge.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://nt1q6gpij6y3"
+path="res://.godot/imported/mouth_p_tounge.png-f2e1b9018a58fa3c500b05c19941a1f3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_p_tounge.png"
+dest_files=["res://.godot/imported/mouth_p_tounge.png-f2e1b9018a58fa3c500b05c19941a1f3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_sad.png b/art/exported/characters/faces/mouth/mouth_sad.png
new file mode 100644
index 0000000..86687e0
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_sad.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_sad.png.import b/art/exported/characters/faces/mouth/mouth_sad.png.import
new file mode 100644
index 0000000..bfe50b3
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_sad.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://camwvowsgxnlw"
+path="res://.godot/imported/mouth_sad.png-ced7361b391677e61acbb1877a8e7156.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_sad.png"
+dest_files=["res://.godot/imported/mouth_sad.png-ced7361b391677e61acbb1877a8e7156.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_sigh.png b/art/exported/characters/faces/mouth/mouth_sigh.png
new file mode 100644
index 0000000..875da79
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_sigh.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_sigh.png.import b/art/exported/characters/faces/mouth/mouth_sigh.png.import
new file mode 100644
index 0000000..928a5d2
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_sigh.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b0842jxg3m1ca"
+path="res://.godot/imported/mouth_sigh.png-f58b2992f2709483fde720834a5e8820.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_sigh.png"
+dest_files=["res://.godot/imported/mouth_sigh.png-f58b2992f2709483fde720834a5e8820.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_simple_smile.png b/art/exported/characters/faces/mouth/mouth_simple_smile.png
new file mode 100644
index 0000000..9f8d430
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_simple_smile.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_simple_smile.png.import b/art/exported/characters/faces/mouth/mouth_simple_smile.png.import
new file mode 100644
index 0000000..82bd51a
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_simple_smile.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://nafybekpya31"
+path="res://.godot/imported/mouth_simple_smile.png-029050835f365c4976c1b4e62edbc7c8.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_simple_smile.png"
+dest_files=["res://.godot/imported/mouth_simple_smile.png-029050835f365c4976c1b4e62edbc7c8.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_slanted.png b/art/exported/characters/faces/mouth/mouth_slanted.png
new file mode 100644
index 0000000..568eb4f
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_slanted.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_slanted.png.import b/art/exported/characters/faces/mouth/mouth_slanted.png.import
new file mode 100644
index 0000000..01efb87
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_slanted.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://nuj6ednwjawn"
+path="res://.godot/imported/mouth_slanted.png-cb0a217a5bb11b80e53b1a6e3e94f43d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_slanted.png"
+dest_files=["res://.godot/imported/mouth_slanted.png-cb0a217a5bb11b80e53b1a6e3e94f43d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_squigle.png b/art/exported/characters/faces/mouth/mouth_squigle.png
new file mode 100644
index 0000000..cffb2e8
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_squigle.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_squigle.png.import b/art/exported/characters/faces/mouth/mouth_squigle.png.import
new file mode 100644
index 0000000..980021f
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_squigle.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://calw27urbe3hk"
+path="res://.godot/imported/mouth_squigle.png-263a4acb09e9f29aff38899ef1aac53f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_squigle.png"
+dest_files=["res://.godot/imported/mouth_squigle.png-263a4acb09e9f29aff38899ef1aac53f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_vampy.png b/art/exported/characters/faces/mouth/mouth_vampy.png
new file mode 100644
index 0000000..ee941f2
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_vampy.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_vampy.png.import b/art/exported/characters/faces/mouth/mouth_vampy.png.import
new file mode 100644
index 0000000..d191c95
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_vampy.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdpe0lrftqjkq"
+path="res://.godot/imported/mouth_vampy.png-0f16a1970484a70927f4063fa1bab422.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_vampy.png"
+dest_files=["res://.godot/imported/mouth_vampy.png-0f16a1970484a70927f4063fa1bab422.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_whiskers.png b/art/exported/characters/faces/mouth/mouth_whiskers.png
new file mode 100644
index 0000000..414634f
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_whiskers.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_whiskers.png.import b/art/exported/characters/faces/mouth/mouth_whiskers.png.import
new file mode 100644
index 0000000..7fe6438
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_whiskers.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cpxwladf5vobh"
+path="res://.godot/imported/mouth_whiskers.png-27b3d6495cd4a57de394a2ad8eb67d39.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_whiskers.png"
+dest_files=["res://.godot/imported/mouth_whiskers.png-27b3d6495cd4a57de394a2ad8eb67d39.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_y.png b/art/exported/characters/faces/mouth/mouth_y.png
new file mode 100644
index 0000000..600cd8c
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_y.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_y.png.import b/art/exported/characters/faces/mouth/mouth_y.png.import
new file mode 100644
index 0000000..989fa6b
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_y.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6w65l4rjbgq"
+path="res://.godot/imported/mouth_y.png-08338d9ca7fd2d139dd64add4385096d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_y.png"
+dest_files=["res://.godot/imported/mouth_y.png-08338d9ca7fd2d139dd64add4385096d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_y_small.png b/art/exported/characters/faces/mouth/mouth_y_small.png
new file mode 100644
index 0000000..f66357b
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_y_small.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_y_small.png.import b/art/exported/characters/faces/mouth/mouth_y_small.png.import
new file mode 100644
index 0000000..205579e
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_y_small.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cchv8fvnqbtm2"
+path="res://.godot/imported/mouth_y_small.png-493215adfb2e980f01f69152d24a8ef4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_y_small.png"
+dest_files=["res://.godot/imported/mouth_y_small.png-493215adfb2e980f01f69152d24a8ef4.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/mouth/mouth_y_small_pink.png b/art/exported/characters/faces/mouth/mouth_y_small_pink.png
new file mode 100644
index 0000000..7f0af4a
Binary files /dev/null and b/art/exported/characters/faces/mouth/mouth_y_small_pink.png differ
diff --git a/art/exported/characters/faces/mouth/mouth_y_small_pink.png.import b/art/exported/characters/faces/mouth/mouth_y_small_pink.png.import
new file mode 100644
index 0000000..f8237f1
--- /dev/null
+++ b/art/exported/characters/faces/mouth/mouth_y_small_pink.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dvqdmhhiet5ix"
+path="res://.godot/imported/mouth_y_small_pink.png-febc93e05453be68458eff8bab3d21c1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/mouth/mouth_y_small_pink.png"
+dest_files=["res://.godot/imported/mouth_y_small_pink.png-febc93e05453be68458eff8bab3d21c1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_cat_real.png b/art/exported/characters/faces/noses/nose_cat_real.png
new file mode 100644
index 0000000..0765f2e
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_cat_real.png differ
diff --git a/art/exported/characters/faces/noses/nose_cat_real.png.import b/art/exported/characters/faces/noses/nose_cat_real.png.import
new file mode 100644
index 0000000..aaedb17
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_cat_real.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://5n0kihgof38n"
+path="res://.godot/imported/nose_cat_real.png-25c7e8464e384043f0969f191e2c2950.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_cat_real.png"
+dest_files=["res://.godot/imported/nose_cat_real.png-25c7e8464e384043f0969f191e2c2950.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_clown.png b/art/exported/characters/faces/noses/nose_clown.png
new file mode 100644
index 0000000..cb9fb14
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_clown.png differ
diff --git a/art/exported/characters/faces/noses/nose_clown.png.import b/art/exported/characters/faces/noses/nose_clown.png.import
new file mode 100644
index 0000000..a9afce1
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_clown.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cc0nb34t7helb"
+path="res://.godot/imported/nose_clown.png-bfa33a86cf06540d45ba440868a39bb3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_clown.png"
+dest_files=["res://.godot/imported/nose_clown.png-bfa33a86cf06540d45ba440868a39bb3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_dog_real.png b/art/exported/characters/faces/noses/nose_dog_real.png
new file mode 100644
index 0000000..01b3a7e
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_dog_real.png differ
diff --git a/art/exported/characters/faces/noses/nose_dog_real.png.import b/art/exported/characters/faces/noses/nose_dog_real.png.import
new file mode 100644
index 0000000..9fe82cc
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_dog_real.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b8jwky86fl3ky"
+path="res://.godot/imported/nose_dog_real.png-f065da766aead62076c01fa08fbffb2a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_dog_real.png"
+dest_files=["res://.godot/imported/nose_dog_real.png-f065da766aead62076c01fa08fbffb2a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_dog_simple.png b/art/exported/characters/faces/noses/nose_dog_simple.png
new file mode 100644
index 0000000..d3743c7
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_dog_simple.png differ
diff --git a/art/exported/characters/faces/noses/nose_dog_simple.png.import b/art/exported/characters/faces/noses/nose_dog_simple.png.import
new file mode 100644
index 0000000..f61b74a
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_dog_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b6d4ubqjijjux"
+path="res://.godot/imported/nose_dog_simple.png-fdc0a92e3e2c82271db9ea87256058a7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_dog_simple.png"
+dest_files=["res://.godot/imported/nose_dog_simple.png-fdc0a92e3e2c82271db9ea87256058a7.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_dragon.png b/art/exported/characters/faces/noses/nose_dragon.png
new file mode 100644
index 0000000..157a28d
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_dragon.png differ
diff --git a/art/exported/characters/faces/noses/nose_dragon.png.import b/art/exported/characters/faces/noses/nose_dragon.png.import
new file mode 100644
index 0000000..0ba2695
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_dragon.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8n1bhbyt5id6"
+path="res://.godot/imported/nose_dragon.png-6c98cf6f5692ec43ffe43650115cd010.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_dragon.png"
+dest_files=["res://.godot/imported/nose_dragon.png-6c98cf6f5692ec43ffe43650115cd010.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_hippo.png b/art/exported/characters/faces/noses/nose_hippo.png
new file mode 100644
index 0000000..7ceabb2
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_hippo.png differ
diff --git a/art/exported/characters/faces/noses/nose_hippo.png.import b/art/exported/characters/faces/noses/nose_hippo.png.import
new file mode 100644
index 0000000..0206809
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_hippo.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bmfa44uf6u4or"
+path="res://.godot/imported/nose_hippo.png-059f63ab00597454d369b0d51b5712a6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_hippo.png"
+dest_files=["res://.godot/imported/nose_hippo.png-059f63ab00597454d369b0d51b5712a6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_human.png b/art/exported/characters/faces/noses/nose_human.png
new file mode 100644
index 0000000..e0e7846
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_human.png differ
diff --git a/art/exported/characters/faces/noses/nose_human.png.import b/art/exported/characters/faces/noses/nose_human.png.import
new file mode 100644
index 0000000..2381527
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_human.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cuthoacwggro3"
+path="res://.godot/imported/nose_human.png-337d9ba4f724d6241a6fa9a307a30008.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_human.png"
+dest_files=["res://.godot/imported/nose_human.png-337d9ba4f724d6241a6fa9a307a30008.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_nostrils.png b/art/exported/characters/faces/noses/nose_nostrils.png
new file mode 100644
index 0000000..11fb0a3
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_nostrils.png differ
diff --git a/art/exported/characters/faces/noses/nose_nostrils.png.import b/art/exported/characters/faces/noses/nose_nostrils.png.import
new file mode 100644
index 0000000..8e9c447
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_nostrils.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bvu2lox5px3ab"
+path="res://.godot/imported/nose_nostrils.png-9e1c2b5c8e7adb019d72a39b1e805781.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_nostrils.png"
+dest_files=["res://.godot/imported/nose_nostrils.png-9e1c2b5c8e7adb019d72a39b1e805781.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_piercing.png b/art/exported/characters/faces/noses/nose_piercing.png
new file mode 100644
index 0000000..f35b3ac
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_piercing.png differ
diff --git a/art/exported/characters/faces/noses/nose_piercing.png.import b/art/exported/characters/faces/noses/nose_piercing.png.import
new file mode 100644
index 0000000..3584e75
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_piercing.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://3w37sblwbd8b"
+path="res://.godot/imported/nose_piercing.png-0c3316905c9f379988cc52353c88569c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_piercing.png"
+dest_files=["res://.godot/imported/nose_piercing.png-0c3316905c9f379988cc52353c88569c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_pig_snout.png b/art/exported/characters/faces/noses/nose_pig_snout.png
new file mode 100644
index 0000000..0e403cf
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_pig_snout.png differ
diff --git a/art/exported/characters/faces/noses/nose_pig_snout.png.import b/art/exported/characters/faces/noses/nose_pig_snout.png.import
new file mode 100644
index 0000000..5e34829
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_pig_snout.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bvran6dr0u22l"
+path="res://.godot/imported/nose_pig_snout.png-40e08884db61f222d4371c6c6a798bca.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_pig_snout.png"
+dest_files=["res://.godot/imported/nose_pig_snout.png-40e08884db61f222d4371c6c6a798bca.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_pig_snout_pink.png b/art/exported/characters/faces/noses/nose_pig_snout_pink.png
new file mode 100644
index 0000000..6fe994f
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_pig_snout_pink.png differ
diff --git a/art/exported/characters/faces/noses/nose_pig_snout_pink.png.import b/art/exported/characters/faces/noses/nose_pig_snout_pink.png.import
new file mode 100644
index 0000000..52ab860
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_pig_snout_pink.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cyigqco0dp8ph"
+path="res://.godot/imported/nose_pig_snout_pink.png-da6fcfc06a8aa02d6ad664e2ed669558.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_pig_snout_pink.png"
+dest_files=["res://.godot/imported/nose_pig_snout_pink.png-da6fcfc06a8aa02d6ad664e2ed669558.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_squidward.png b/art/exported/characters/faces/noses/nose_squidward.png
new file mode 100644
index 0000000..a878daa
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_squidward.png differ
diff --git a/art/exported/characters/faces/noses/nose_squidward.png.import b/art/exported/characters/faces/noses/nose_squidward.png.import
new file mode 100644
index 0000000..dbba070
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_squidward.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ilvb5vre4kv7"
+path="res://.godot/imported/nose_squidward.png-579e15cf6acfb402abda29a8a555d504.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_squidward.png"
+dest_files=["res://.godot/imported/nose_squidward.png-579e15cf6acfb402abda29a8a555d504.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_squidward_wide.png b/art/exported/characters/faces/noses/nose_squidward_wide.png
new file mode 100644
index 0000000..1f55a69
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_squidward_wide.png differ
diff --git a/art/exported/characters/faces/noses/nose_squidward_wide.png.import b/art/exported/characters/faces/noses/nose_squidward_wide.png.import
new file mode 100644
index 0000000..92d6c8a
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_squidward_wide.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dtisqywq2rv14"
+path="res://.godot/imported/nose_squidward_wide.png-fca231f913f03b97c273c870770f7948.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_squidward_wide.png"
+dest_files=["res://.godot/imported/nose_squidward_wide.png-fca231f913f03b97c273c870770f7948.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_triangle.png b/art/exported/characters/faces/noses/nose_triangle.png
new file mode 100644
index 0000000..5f15f3a
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_triangle.png differ
diff --git a/art/exported/characters/faces/noses/nose_triangle.png.import b/art/exported/characters/faces/noses/nose_triangle.png.import
new file mode 100644
index 0000000..bc7aa21
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_triangle.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bitins3gtewe"
+path="res://.godot/imported/nose_triangle.png-018974a6bbf68652b8c5533c2675cc2f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_triangle.png"
+dest_files=["res://.godot/imported/nose_triangle.png-018974a6bbf68652b8c5533c2675cc2f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_triangle_pink.png b/art/exported/characters/faces/noses/nose_triangle_pink.png
new file mode 100644
index 0000000..2915456
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_triangle_pink.png differ
diff --git a/art/exported/characters/faces/noses/nose_triangle_pink.png.import b/art/exported/characters/faces/noses/nose_triangle_pink.png.import
new file mode 100644
index 0000000..56712ab
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_triangle_pink.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://oi8qhxbtijmk"
+path="res://.godot/imported/nose_triangle_pink.png-051ce6df5e7fd5932f19fd353123d7d1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_triangle_pink.png"
+dest_files=["res://.godot/imported/nose_triangle_pink.png-051ce6df5e7fd5932f19fd353123d7d1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_triangle_small.png b/art/exported/characters/faces/noses/nose_triangle_small.png
new file mode 100644
index 0000000..719e67b
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_triangle_small.png differ
diff --git a/art/exported/characters/faces/noses/nose_triangle_small.png.import b/art/exported/characters/faces/noses/nose_triangle_small.png.import
new file mode 100644
index 0000000..a659077
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_triangle_small.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cedgrca26ponk"
+path="res://.godot/imported/nose_triangle_small.png-64129c5fc69bf7095f684c2b7de2855b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_triangle_small.png"
+dest_files=["res://.godot/imported/nose_triangle_small.png-64129c5fc69bf7095f684c2b7de2855b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_triangle_small_pink.png b/art/exported/characters/faces/noses/nose_triangle_small_pink.png
new file mode 100644
index 0000000..3a4bf19
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_triangle_small_pink.png differ
diff --git a/art/exported/characters/faces/noses/nose_triangle_small_pink.png.import b/art/exported/characters/faces/noses/nose_triangle_small_pink.png.import
new file mode 100644
index 0000000..c875ba4
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_triangle_small_pink.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://0kird82ka1ug"
+path="res://.godot/imported/nose_triangle_small_pink.png-fba736a5f1806c1286f1d3040592933b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_triangle_small_pink.png"
+dest_files=["res://.godot/imported/nose_triangle_small_pink.png-fba736a5f1806c1286f1d3040592933b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/faces/noses/nose_under.png b/art/exported/characters/faces/noses/nose_under.png
new file mode 100644
index 0000000..d7c4d7b
Binary files /dev/null and b/art/exported/characters/faces/noses/nose_under.png differ
diff --git a/art/exported/characters/faces/noses/nose_under.png.import b/art/exported/characters/faces/noses/nose_under.png.import
new file mode 100644
index 0000000..5bf860f
--- /dev/null
+++ b/art/exported/characters/faces/noses/nose_under.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://u0ih35v73j5h"
+path="res://.godot/imported/nose_under.png-269bae96966b3c6367d43ac9bd5b2a3f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/faces/noses/nose_under.png"
+dest_files=["res://.godot/imported/nose_under.png-269bae96966b3c6367d43ac9bd5b2a3f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/bengal/body_arms_bengal.png b/art/exported/characters/patterns/bengal/body_arms_bengal.png
new file mode 100644
index 0000000..dd4c735
Binary files /dev/null and b/art/exported/characters/patterns/bengal/body_arms_bengal.png differ
diff --git a/art/exported/characters/patterns/bengal/body_arms_bengal.png.import b/art/exported/characters/patterns/bengal/body_arms_bengal.png.import
new file mode 100644
index 0000000..8e98690
--- /dev/null
+++ b/art/exported/characters/patterns/bengal/body_arms_bengal.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b2e0uxfn187gu"
+path="res://.godot/imported/body_arms_bengal.png-350ed15f218f0efa6ae7573ad27d3c65.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/bengal/body_arms_bengal.png"
+dest_files=["res://.godot/imported/body_arms_bengal.png-350ed15f218f0efa6ae7573ad27d3c65.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/bengal/body_main_bengal.png b/art/exported/characters/patterns/bengal/body_main_bengal.png
new file mode 100644
index 0000000..c6eae5b
Binary files /dev/null and b/art/exported/characters/patterns/bengal/body_main_bengal.png differ
diff --git a/art/exported/characters/patterns/bengal/body_main_bengal.png.import b/art/exported/characters/patterns/bengal/body_main_bengal.png.import
new file mode 100644
index 0000000..389ec4f
--- /dev/null
+++ b/art/exported/characters/patterns/bengal/body_main_bengal.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cn71sswmj0tue"
+path="res://.godot/imported/body_main_bengal.png-59815350b807220c52317e683582847e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/bengal/body_main_bengal.png"
+dest_files=["res://.godot/imported/body_main_bengal.png-59815350b807220c52317e683582847e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/bengal/head_round_bengal.png b/art/exported/characters/patterns/bengal/head_round_bengal.png
new file mode 100644
index 0000000..d5326c4
Binary files /dev/null and b/art/exported/characters/patterns/bengal/head_round_bengal.png differ
diff --git a/art/exported/characters/patterns/bengal/head_round_bengal.png.import b/art/exported/characters/patterns/bengal/head_round_bengal.png.import
new file mode 100644
index 0000000..aa5113b
--- /dev/null
+++ b/art/exported/characters/patterns/bengal/head_round_bengal.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://rv36ws5wuakb"
+path="res://.godot/imported/head_round_bengal.png-dfa502a4514c1605f5c8aff839dd8765.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/bengal/head_round_bengal.png"
+dest_files=["res://.godot/imported/head_round_bengal.png-dfa502a4514c1605f5c8aff839dd8765.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/fox/body_arms_fox.png b/art/exported/characters/patterns/fox/body_arms_fox.png
new file mode 100644
index 0000000..aab265a
Binary files /dev/null and b/art/exported/characters/patterns/fox/body_arms_fox.png differ
diff --git a/art/exported/characters/patterns/fox/body_arms_fox.png.import b/art/exported/characters/patterns/fox/body_arms_fox.png.import
new file mode 100644
index 0000000..af10b42
--- /dev/null
+++ b/art/exported/characters/patterns/fox/body_arms_fox.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://p5cg2bwrxf7v"
+path="res://.godot/imported/body_arms_fox.png-91ab21ce544fdc29b8a0ebf3e54ef7cd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/fox/body_arms_fox.png"
+dest_files=["res://.godot/imported/body_arms_fox.png-91ab21ce544fdc29b8a0ebf3e54ef7cd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/fox/body_main_fox.png b/art/exported/characters/patterns/fox/body_main_fox.png
new file mode 100644
index 0000000..5c87bc5
Binary files /dev/null and b/art/exported/characters/patterns/fox/body_main_fox.png differ
diff --git a/art/exported/characters/patterns/fox/body_main_fox.png.import b/art/exported/characters/patterns/fox/body_main_fox.png.import
new file mode 100644
index 0000000..467c2b8
--- /dev/null
+++ b/art/exported/characters/patterns/fox/body_main_fox.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://wcsadkjjpaei"
+path="res://.godot/imported/body_main_fox.png-25e4fa03a48053f6b37b3bbadb1f01ba.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/fox/body_main_fox.png"
+dest_files=["res://.godot/imported/body_main_fox.png-25e4fa03a48053f6b37b3bbadb1f01ba.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/fox/head_pointy_fox.png b/art/exported/characters/patterns/fox/head_pointy_fox.png
new file mode 100644
index 0000000..71be5c3
Binary files /dev/null and b/art/exported/characters/patterns/fox/head_pointy_fox.png differ
diff --git a/art/exported/characters/patterns/fox/head_pointy_fox.png.import b/art/exported/characters/patterns/fox/head_pointy_fox.png.import
new file mode 100644
index 0000000..e3fa7e7
--- /dev/null
+++ b/art/exported/characters/patterns/fox/head_pointy_fox.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c4vlbrp7qevvo"
+path="res://.godot/imported/head_pointy_fox.png-ef3535a6c05a49aae36fe5514844a3df.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/fox/head_pointy_fox.png"
+dest_files=["res://.godot/imported/head_pointy_fox.png-ef3535a6c05a49aae36fe5514844a3df.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/fox/head_round_fox.png b/art/exported/characters/patterns/fox/head_round_fox.png
new file mode 100644
index 0000000..2963975
Binary files /dev/null and b/art/exported/characters/patterns/fox/head_round_fox.png differ
diff --git a/art/exported/characters/patterns/fox/head_round_fox.png.import b/art/exported/characters/patterns/fox/head_round_fox.png.import
new file mode 100644
index 0000000..6d958dd
--- /dev/null
+++ b/art/exported/characters/patterns/fox/head_round_fox.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bwiwt76kyyevw"
+path="res://.godot/imported/head_round_fox.png-27dc2125a4e5b85a622291762225a303.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/fox/head_round_fox.png"
+dest_files=["res://.godot/imported/head_round_fox.png-27dc2125a4e5b85a622291762225a303.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/characters/patterns/fox/tails_fox_fox.png b/art/exported/characters/patterns/fox/tails_fox_fox.png
new file mode 100644
index 0000000..7283c2c
Binary files /dev/null and b/art/exported/characters/patterns/fox/tails_fox_fox.png differ
diff --git a/art/exported/characters/patterns/fox/tails_fox_fox.png.import b/art/exported/characters/patterns/fox/tails_fox_fox.png.import
new file mode 100644
index 0000000..46f6e60
--- /dev/null
+++ b/art/exported/characters/patterns/fox/tails_fox_fox.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cyr0wcavb1cmi"
+path="res://.godot/imported/tails_fox_fox.png-061a224c36e90f10b33d37e616ff7e94.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/characters/patterns/fox/tails_fox_fox.png"
+dest_files=["res://.godot/imported/tails_fox_fox.png-061a224c36e90f10b33d37e616ff7e94.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/environment/terrain/starter_island.glb b/art/exported/environment/terrain/starter_island.glb
index 9612f8d..e812b19 100644
Binary files a/art/exported/environment/terrain/starter_island.glb and b/art/exported/environment/terrain/starter_island.glb differ
diff --git a/art/exported/environment/terrain/starter_island.glb.import b/art/exported/environment/terrain/starter_island.glb.import
index bedce7c..18e4e12 100644
--- a/art/exported/environment/terrain/starter_island.glb.import
+++ b/art/exported/environment/terrain/starter_island.glb.import
@@ -41,5 +41,5 @@ materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
-gltf/embedded_image_handling=1
+gltf/embedded_image_handling=3
gltf/texture_map_mode=1
diff --git a/art/exported/system_icons/netfishing.ico b/art/exported/system_icons/netfishing.ico
new file mode 100644
index 0000000..dc5bdae
Binary files /dev/null and b/art/exported/system_icons/netfishing.ico differ
diff --git a/art/exported/system_icons/netfishing_1024.png b/art/exported/system_icons/netfishing_1024.png
new file mode 100644
index 0000000..a433e80
Binary files /dev/null and b/art/exported/system_icons/netfishing_1024.png differ
diff --git a/art/exported/system_icons/netfishing_1024.png.import b/art/exported/system_icons/netfishing_1024.png.import
new file mode 100644
index 0000000..08aa925
--- /dev/null
+++ b/art/exported/system_icons/netfishing_1024.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bkh5hw8wwu8pc"
+path="res://.godot/imported/netfishing_1024.png-7c53a099ed2743b4a623c258641b1d29.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/system_icons/netfishing_1024.png"
+dest_files=["res://.godot/imported/netfishing_1024.png-7c53a099ed2743b4a623c258641b1d29.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/exported/system_icons/netfishing_256.png b/art/exported/system_icons/netfishing_256.png
new file mode 100644
index 0000000..1e7be72
Binary files /dev/null and b/art/exported/system_icons/netfishing_256.png differ
diff --git a/art/exported/system_icons/netfishing_256.png.import b/art/exported/system_icons/netfishing_256.png.import
new file mode 100644
index 0000000..34d5d27
--- /dev/null
+++ b/art/exported/system_icons/netfishing_256.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cty16m8ydla06"
+path="res://.godot/imported/netfishing_256.png-d9dfc9c7871ac38bc73d09d00df679be.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/exported/system_icons/netfishing_256.png"
+dest_files=["res://.godot/imported/netfishing_256.png-d9dfc9c7871ac38bc73d09d00df679be.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/patterns/pattern_moneyfish.png b/art/patterns/pattern_moneyfish.png
new file mode 100644
index 0000000..6266f29
Binary files /dev/null and b/art/patterns/pattern_moneyfish.png differ
diff --git a/art/patterns/pattern_moneyfish.png.import b/art/patterns/pattern_moneyfish.png.import
new file mode 100644
index 0000000..790317c
--- /dev/null
+++ b/art/patterns/pattern_moneyfish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b6xws1d2dnbnn"
+path="res://.godot/imported/pattern_moneyfish.png-c20edef3e829648b3e6aae4e190efaed.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/patterns/pattern_moneyfish.png"
+dest_files=["res://.godot/imported/pattern_moneyfish.png-c20edef3e829648b3e6aae4e190efaed.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/patterns/pattern_tealdot.png b/art/patterns/pattern_tealdot.png
new file mode 100644
index 0000000..9b901b6
Binary files /dev/null and b/art/patterns/pattern_tealdot.png differ
diff --git a/art/patterns/pattern_tealdot.png.import b/art/patterns/pattern_tealdot.png.import
new file mode 100644
index 0000000..a6664e4
--- /dev/null
+++ b/art/patterns/pattern_tealdot.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://f8a0cs0s7ler"
+path="res://.godot/imported/pattern_tealdot.png-ea096873729758aad8d393553ab96b2d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/patterns/pattern_tealdot.png"
+dest_files=["res://.godot/imported/pattern_tealdot.png-ea096873729758aad8d393553ab96b2d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/art/source/characters/base/netfishing_base_character.blend b/art/source/characters/base/netfishing_base_character.blend
new file mode 100644
index 0000000..ed0a7a9
Binary files /dev/null and b/art/source/characters/base/netfishing_base_character.blend differ
diff --git a/art/source/characters/base/netfishing_base_character.blend.import b/art/source/characters/base/netfishing_base_character.blend.import
new file mode 100644
index 0000000..f6df489
--- /dev/null
+++ b/art/source/characters/base/netfishing_base_character.blend.import
@@ -0,0 +1,63 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://cb4bcakhoprm8"
+path="res://.godot/imported/netfishing_base_character.blend-65bf3bc5b77cc1a37fa3f81ebd808d37.scn"
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_base_character.blend"
+dest_files=["res://.godot/imported/netfishing_base_character.blend-65bf3bc5b77cc1a37fa3f81ebd808d37.scn"]
+
+[params]
+
+nodes/root_type=""
+nodes/root_name=""
+nodes/root_script=null
+mesh_library/use_node_names_as_mesh_names=false
+array_mesh/deduplicate_surfaces=true
+nodes/apply_root_scale=true
+nodes/root_scale=1.0
+nodes/import_as_skeleton_bones=false
+nodes/use_name_suffixes=true
+nodes/use_node_type_suffixes=true
+meshes/ensure_tangents=true
+meshes/generate_lods=true
+meshes/create_shadow_meshes=true
+meshes/light_baking=1
+meshes/lightmap_texel_size=0.2
+meshes/force_disable_compression=false
+skins/use_named_skins=true
+animation/import=true
+animation/fps=30
+animation/trimming=false
+animation/remove_immutable_tracks=true
+animation/import_rest_as_RESET=false
+import_script/path=""
+materials/extract=0
+materials/extract_format=0
+materials/extract_path=""
+_subresources={}
+blender/nodes/visible=0
+blender/nodes/active_collection_only=false
+blender/nodes/punctual_lights=true
+blender/nodes/cameras=true
+blender/nodes/custom_properties=true
+blender/nodes/modifiers=1
+blender/meshes/vertex_colors=1
+blender/meshes/uvs=true
+blender/meshes/normals=true
+blender/meshes/export_geometry_nodes_instances=false
+blender/meshes/gpu_instances=false
+blender/meshes/tangents=true
+blender/meshes/skins=2
+blender/meshes/export_bones_deforming_mesh_only=false
+blender/materials/unpack_enabled=true
+blender/materials/export_materials=1
+blender/animation/limit_playback=true
+blender/animation/always_sample=true
+blender/animation/group_tracks=true
+gltf/naming_version=2
+gltf/texture_map_mode=1
diff --git a/art/source/characters/base/netfishing_base_character.glb.import b/art/source/characters/base/netfishing_base_character.glb.import
new file mode 100644
index 0000000..5a38b76
--- /dev/null
+++ b/art/source/characters/base/netfishing_base_character.glb.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://bfhnup14638r7"
+path="res://.godot/imported/netfishing_base_character.glb-cbdc33eaa4fcd1d2d2a8f4a463b228d6.scn"
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_base_character.glb"
+dest_files=["res://.godot/imported/netfishing_base_character.glb-cbdc33eaa4fcd1d2d2a8f4a463b228d6.scn"]
+
+[params]
+
+nodes/root_type=""
+nodes/root_name=""
+nodes/root_script=null
+mesh_library/use_node_names_as_mesh_names=false
+array_mesh/deduplicate_surfaces=true
+nodes/apply_root_scale=true
+nodes/root_scale=1.0
+nodes/import_as_skeleton_bones=false
+nodes/use_name_suffixes=true
+nodes/use_node_type_suffixes=true
+meshes/ensure_tangents=true
+meshes/generate_lods=true
+meshes/create_shadow_meshes=true
+meshes/light_baking=1
+meshes/lightmap_texel_size=0.2
+meshes/force_disable_compression=false
+skins/use_named_skins=true
+animation/import=true
+animation/fps=30
+animation/trimming=false
+animation/remove_immutable_tracks=true
+animation/import_rest_as_RESET=false
+import_script/path=""
+materials/extract=0
+materials/extract_format=0
+materials/extract_path=""
+_subresources={}
+gltf/naming_version=2
+gltf/embedded_image_handling=1
+gltf/texture_map_mode=1
diff --git a/art/source/characters/base/netfishing_character_source.blend b/art/source/characters/base/netfishing_character_source.blend
new file mode 100644
index 0000000..e75f254
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source.blend differ
diff --git a/art/source/characters/base/netfishing_character_source.blend.import b/art/source/characters/base/netfishing_character_source.blend.import
new file mode 100644
index 0000000..8288280
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source.blend.import
@@ -0,0 +1,63 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://d38y8blk2w5c5"
+path="res://.godot/imported/netfishing_character_source.blend-1d18c95606937d4c8bcb87e334e02ee5.scn"
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source.blend"
+dest_files=["res://.godot/imported/netfishing_character_source.blend-1d18c95606937d4c8bcb87e334e02ee5.scn"]
+
+[params]
+
+nodes/root_type=""
+nodes/root_name=""
+nodes/root_script=null
+mesh_library/use_node_names_as_mesh_names=false
+array_mesh/deduplicate_surfaces=true
+nodes/apply_root_scale=true
+nodes/root_scale=1.0
+nodes/import_as_skeleton_bones=false
+nodes/use_name_suffixes=true
+nodes/use_node_type_suffixes=true
+meshes/ensure_tangents=true
+meshes/generate_lods=true
+meshes/create_shadow_meshes=true
+meshes/light_baking=1
+meshes/lightmap_texel_size=0.2
+meshes/force_disable_compression=false
+skins/use_named_skins=true
+animation/import=true
+animation/fps=30
+animation/trimming=false
+animation/remove_immutable_tracks=true
+animation/import_rest_as_RESET=false
+import_script/path=""
+materials/extract=0
+materials/extract_format=0
+materials/extract_path=""
+_subresources={}
+blender/nodes/visible=0
+blender/nodes/active_collection_only=false
+blender/nodes/punctual_lights=true
+blender/nodes/cameras=true
+blender/nodes/custom_properties=true
+blender/nodes/modifiers=1
+blender/meshes/vertex_colors=1
+blender/meshes/uvs=true
+blender/meshes/normals=true
+blender/meshes/export_geometry_nodes_instances=false
+blender/meshes/gpu_instances=false
+blender/meshes/tangents=true
+blender/meshes/skins=2
+blender/meshes/export_bones_deforming_mesh_only=false
+blender/materials/unpack_enabled=true
+blender/materials/export_materials=1
+blender/animation/limit_playback=true
+blender/animation/always_sample=true
+blender/animation/group_tracks=true
+gltf/naming_version=2
+gltf/texture_map_mode=1
diff --git a/art/source/characters/base/netfishing_character_source.glb.import b/art/source/characters/base/netfishing_character_source.glb.import
new file mode 100644
index 0000000..61158c5
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source.glb.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://3uq4cxiomttt"
+path="res://.godot/imported/netfishing_character_source.glb-9c98ee44b142c5b3d06f0838b7d2c9c4.scn"
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source.glb"
+dest_files=["res://.godot/imported/netfishing_character_source.glb-9c98ee44b142c5b3d06f0838b7d2c9c4.scn"]
+
+[params]
+
+nodes/root_type=""
+nodes/root_name=""
+nodes/root_script=null
+mesh_library/use_node_names_as_mesh_names=false
+array_mesh/deduplicate_surfaces=true
+nodes/apply_root_scale=true
+nodes/root_scale=1.0
+nodes/import_as_skeleton_bones=false
+nodes/use_name_suffixes=true
+nodes/use_node_type_suffixes=true
+meshes/ensure_tangents=true
+meshes/generate_lods=true
+meshes/create_shadow_meshes=true
+meshes/light_baking=1
+meshes/lightmap_texel_size=0.2
+meshes/force_disable_compression=false
+skins/use_named_skins=true
+animation/import=true
+animation/fps=30
+animation/trimming=false
+animation/remove_immutable_tracks=true
+animation/import_rest_as_RESET=false
+import_script/path=""
+materials/extract=0
+materials/extract_format=0
+materials/extract_path=""
+_subresources={}
+gltf/naming_version=2
+gltf/embedded_image_handling=1
+gltf/texture_map_mode=1
diff --git a/art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png b/art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png
new file mode 100644
index 0000000..d381bf6
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png differ
diff --git a/art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png.import b/art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png.import
new file mode 100644
index 0000000..e7aade1
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdomsntfeovnc"
+path.s3tc="res://.godot/imported/netfishing_character_source_eyes1_eyebrows1.png-a0b9f287e407afd9688e141dc5f2b801.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_character_source_eyes1_eyebrows1.png-a0b9f287e407afd9688e141dc5f2b801.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "58f88343d07131ad09a2872ff719be5e"
+}
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source_eyes1_eyebrows1.png"
+dest_files=["res://.godot/imported/netfishing_character_source_eyes1_eyebrows1.png-a0b9f287e407afd9688e141dc5f2b801.s3tc.ctex", "res://.godot/imported/netfishing_character_source_eyes1_eyebrows1.png-a0b9f287e407afd9688e141dc5f2b801.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/source/characters/base/netfishing_character_source_eyes_simple_shine.png b/art/source/characters/base/netfishing_character_source_eyes_simple_shine.png
new file mode 100644
index 0000000..36e6859
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source_eyes_simple_shine.png differ
diff --git a/art/source/characters/base/netfishing_character_source_eyes_simple_shine.png.import b/art/source/characters/base/netfishing_character_source_eyes_simple_shine.png.import
new file mode 100644
index 0000000..e49d401
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source_eyes_simple_shine.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://du4u6meapay05"
+path.s3tc="res://.godot/imported/netfishing_character_source_eyes_simple_shine.png-965743f3716320bbc385096956ffef2d.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_character_source_eyes_simple_shine.png-965743f3716320bbc385096956ffef2d.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "c2f7c29aaaa95809e140eb308d9bb132"
+}
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source_eyes_simple_shine.png"
+dest_files=["res://.godot/imported/netfishing_character_source_eyes_simple_shine.png-965743f3716320bbc385096956ffef2d.s3tc.ctex", "res://.godot/imported/netfishing_character_source_eyes_simple_shine.png-965743f3716320bbc385096956ffef2d.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/source/characters/base/netfishing_character_source_mouth1.png b/art/source/characters/base/netfishing_character_source_mouth1.png
new file mode 100644
index 0000000..a230423
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source_mouth1.png differ
diff --git a/art/source/characters/base/netfishing_character_source_mouth1.png.import b/art/source/characters/base/netfishing_character_source_mouth1.png.import
new file mode 100644
index 0000000..8ff49a4
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source_mouth1.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://css0k0virgoph"
+path.s3tc="res://.godot/imported/netfishing_character_source_mouth1.png-b323b9153acd3f552f35c1d1108567c8.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_character_source_mouth1.png-b323b9153acd3f552f35c1d1108567c8.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "5d01fd41e8705956256c951d0cb53924"
+}
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source_mouth1.png"
+dest_files=["res://.godot/imported/netfishing_character_source_mouth1.png-b323b9153acd3f552f35c1d1108567c8.s3tc.ctex", "res://.godot/imported/netfishing_character_source_mouth1.png-b323b9153acd3f552f35c1d1108567c8.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/source/characters/base/netfishing_character_source_mouth_three.png b/art/source/characters/base/netfishing_character_source_mouth_three.png
new file mode 100644
index 0000000..527f300
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source_mouth_three.png differ
diff --git a/art/source/characters/base/netfishing_character_source_mouth_three.png.import b/art/source/characters/base/netfishing_character_source_mouth_three.png.import
new file mode 100644
index 0000000..6abae09
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source_mouth_three.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://di20c1x8vksi1"
+path.s3tc="res://.godot/imported/netfishing_character_source_mouth_three.png-6bf79d4c9c660d30fb7fc365f0039313.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_character_source_mouth_three.png-6bf79d4c9c660d30fb7fc365f0039313.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "f8bf25a1006c5cc2ab5323f4a758eec7"
+}
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source_mouth_three.png"
+dest_files=["res://.godot/imported/netfishing_character_source_mouth_three.png-6bf79d4c9c660d30fb7fc365f0039313.s3tc.ctex", "res://.godot/imported/netfishing_character_source_mouth_three.png-6bf79d4c9c660d30fb7fc365f0039313.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/source/characters/base/netfishing_character_source_nose1.2.png b/art/source/characters/base/netfishing_character_source_nose1.2.png
new file mode 100644
index 0000000..57896ed
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source_nose1.2.png differ
diff --git a/art/source/characters/base/netfishing_character_source_nose1.2.png.import b/art/source/characters/base/netfishing_character_source_nose1.2.png.import
new file mode 100644
index 0000000..2fbb165
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source_nose1.2.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://7dwa0bfev1rk"
+path.s3tc="res://.godot/imported/netfishing_character_source_nose1.2.png-ef4dd456c5b0f0db73b3dc063bfba941.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_character_source_nose1.2.png-ef4dd456c5b0f0db73b3dc063bfba941.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "ad576beb7a7035dde96eb8e8f3ffdee7"
+}
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source_nose1.2.png"
+dest_files=["res://.godot/imported/netfishing_character_source_nose1.2.png-ef4dd456c5b0f0db73b3dc063bfba941.s3tc.ctex", "res://.godot/imported/netfishing_character_source_nose1.2.png-ef4dd456c5b0f0db73b3dc063bfba941.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/source/characters/base/netfishing_character_source_nose_dog_round.png b/art/source/characters/base/netfishing_character_source_nose_dog_round.png
new file mode 100644
index 0000000..18a4590
Binary files /dev/null and b/art/source/characters/base/netfishing_character_source_nose_dog_round.png differ
diff --git a/art/source/characters/base/netfishing_character_source_nose_dog_round.png.import b/art/source/characters/base/netfishing_character_source_nose_dog_round.png.import
new file mode 100644
index 0000000..12333e8
--- /dev/null
+++ b/art/source/characters/base/netfishing_character_source_nose_dog_round.png.import
@@ -0,0 +1,45 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b0p55hwdry2cr"
+path.s3tc="res://.godot/imported/netfishing_character_source_nose_dog_round.png-1c89150861ebff3f930698c1747a0369.s3tc.ctex"
+path.etc2="res://.godot/imported/netfishing_character_source_nose_dog_round.png-1c89150861ebff3f930698c1747a0369.etc2.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc", "etc2_astc"],
+"vram_texture": true
+}
+generator_parameters={
+"md5": "3134f170152f9fa1512b41b61ecb01fc"
+}
+
+[deps]
+
+source_file="res://art/source/characters/base/netfishing_character_source_nose_dog_round.png"
+dest_files=["res://.godot/imported/netfishing_character_source_nose_dog_round.png-1c89150861ebff3f930698c1747a0369.s3tc.ctex", "res://.godot/imported/netfishing_character_source_nose_dog_round.png-1c89150861ebff3f930698c1747a0369.etc2.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/art/source/environment/terrain/starter_island.blend b/art/source/environment/terrain/starter_island.blend
index aeee334..8b82c4e 100644
Binary files a/art/source/environment/terrain/starter_island.blend and b/art/source/environment/terrain/starter_island.blend differ
diff --git a/art/source/environment/textures/.gdignore b/art/source/environment/textures/.gdignore
new file mode 100644
index 0000000..e69de29
diff --git a/art/source/environment/textures/cliff_wall.png b/art/source/environment/textures/cliff_wall.png
new file mode 100644
index 0000000..3fe157f
Binary files /dev/null and b/art/source/environment/textures/cliff_wall.png differ
diff --git a/art/source/environment/textures/dirt.png b/art/source/environment/textures/dirt.png
new file mode 100644
index 0000000..588c8c6
Binary files /dev/null and b/art/source/environment/textures/dirt.png differ
diff --git a/art/source/environment/textures/dirt_light_wall.png b/art/source/environment/textures/dirt_light_wall.png
new file mode 100644
index 0000000..623dcd5
Binary files /dev/null and b/art/source/environment/textures/dirt_light_wall.png differ
diff --git a/art/source/environment/textures/dirt_wall.png b/art/source/environment/textures/dirt_wall.png
new file mode 100644
index 0000000..dcc2364
Binary files /dev/null and b/art/source/environment/textures/dirt_wall.png differ
diff --git a/art/source/environment/textures/grass.png b/art/source/environment/textures/grass.png
new file mode 100644
index 0000000..8d9761c
Binary files /dev/null and b/art/source/environment/textures/grass.png differ
diff --git a/art/source/environment/textures/grass_heavy.png b/art/source/environment/textures/grass_heavy.png
new file mode 100644
index 0000000..29bfa2a
Binary files /dev/null and b/art/source/environment/textures/grass_heavy.png differ
diff --git a/art/source/environment/textures/grass_lite.png b/art/source/environment/textures/grass_lite.png
new file mode 100644
index 0000000..52c7ccb
Binary files /dev/null and b/art/source/environment/textures/grass_lite.png differ
diff --git a/art/source/environment/textures/grass_olive_tile.png b/art/source/environment/textures/grass_olive_tile.png
new file mode 100644
index 0000000..b701eed
Binary files /dev/null and b/art/source/environment/textures/grass_olive_tile.png differ
diff --git a/art/source/environment/textures/grass_olive_tile.xcf b/art/source/environment/textures/grass_olive_tile.xcf
new file mode 100644
index 0000000..d81b02d
Binary files /dev/null and b/art/source/environment/textures/grass_olive_tile.xcf differ
diff --git a/art/source/environment/textures/sand.png b/art/source/environment/textures/sand.png
new file mode 100644
index 0000000..06f807a
Binary files /dev/null and b/art/source/environment/textures/sand.png differ
diff --git a/art/source/environment/textures/sand_dark.png b/art/source/environment/textures/sand_dark.png
new file mode 100644
index 0000000..80633b5
Binary files /dev/null and b/art/source/environment/textures/sand_dark.png differ
diff --git a/art/source/environment/textures/sand_lite.png b/art/source/environment/textures/sand_lite.png
new file mode 100644
index 0000000..06f807a
Binary files /dev/null and b/art/source/environment/textures/sand_lite.png differ
diff --git a/art/source/environment/textures/sandstone_heavy.png b/art/source/environment/textures/sandstone_heavy.png
new file mode 100644
index 0000000..1bb282c
Binary files /dev/null and b/art/source/environment/textures/sandstone_heavy.png differ
diff --git a/art/source/environment/textures/sandstone_lite.png b/art/source/environment/textures/sandstone_lite.png
new file mode 100644
index 0000000..c329585
Binary files /dev/null and b/art/source/environment/textures/sandstone_lite.png differ
diff --git a/art/ui/ui_notepad.png b/art/ui/ui_notepad.png
new file mode 100644
index 0000000..eab6ff4
Binary files /dev/null and b/art/ui/ui_notepad.png differ
diff --git a/art/ui/ui_notepad.png.import b/art/ui/ui_notepad.png.import
new file mode 100644
index 0000000..9844e04
--- /dev/null
+++ b/art/ui/ui_notepad.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://qmot6d6u44yq"
+path="res://.godot/imported/ui_notepad.png-4c175e6e85c581f3c18d5efd8a7cf669.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://art/ui/ui_notepad.png"
+dest_files=["res://.godot/imported/ui_notepad.png-4c175e6e85c581f3c18d5efd8a7cf669.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/audio/ambience/rain_loop_ontario.ogg b/audio/ambience/rain_loop_ontario.ogg
new file mode 100644
index 0000000..179987b
Binary files /dev/null and b/audio/ambience/rain_loop_ontario.ogg differ
diff --git a/audio/ambience/rain_loop_ontario.ogg.import b/audio/ambience/rain_loop_ontario.ogg.import
new file mode 100644
index 0000000..280fc22
--- /dev/null
+++ b/audio/ambience/rain_loop_ontario.ogg.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://cfqskba5j2iig"
+path="res://.godot/imported/rain_loop_ontario.ogg-c206e8a7ee5398c9fdc6d59462050964.oggvorbisstr"
+
+[deps]
+
+source_file="res://audio/ambience/rain_loop_ontario.ogg"
+dest_files=["res://.godot/imported/rain_loop_ontario.ogg-c206e8a7ee5398c9fdc6d59462050964.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/audio/ambience/waves.wav b/audio/ambience/waves.wav
new file mode 100644
index 0000000..15f258e
Binary files /dev/null and b/audio/ambience/waves.wav differ
diff --git a/audio/ambience/waves.wav.import b/audio/ambience/waves.wav.import
new file mode 100644
index 0000000..5ff6ba8
--- /dev/null
+++ b/audio/ambience/waves.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dck3tf8qkadea"
+path="res://.godot/imported/waves.wav-90ffb886ce064ba838eebb25ec72a17e.sample"
+
+[deps]
+
+source_file="res://audio/ambience/waves.wav"
+dest_files=["res://.godot/imported/waves.wav-90ffb886ce064ba838eebb25ec72a17e.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=1
+edit/loop_begin=0
+edit/loop_end=1044956
+compress/mode=0
diff --git a/audio/music/title/as_in_four_wolves.ogg b/audio/music/title/as_in_four_wolves.ogg
index 64b2463..5fa9818 100644
Binary files a/audio/music/title/as_in_four_wolves.ogg and b/audio/music/title/as_in_four_wolves.ogg differ
diff --git a/audio/music/title/as_in_four_wolves.ogg.import b/audio/music/title/as_in_four_wolves.ogg.import
index 109abb6..71c6ca2 100644
--- a/audio/music/title/as_in_four_wolves.ogg.import
+++ b/audio/music/title/as_in_four_wolves.ogg.import
@@ -13,7 +13,7 @@ dest_files=["res://.godot/imported/as_in_four_wolves.ogg-3d5f167a45d25a52b56a9dd
[params]
loop=true
-loop_offset=0
+loop_offset=231.996145833
bpm=0
beat_count=0
bar_beats=4
diff --git a/audio/music/world/craft.mp3 b/audio/music/world/craft.mp3
new file mode 100644
index 0000000..b73cd15
Binary files /dev/null and b/audio/music/world/craft.mp3 differ
diff --git a/audio/music/world/craft.mp3.import b/audio/music/world/craft.mp3.import
new file mode 100644
index 0000000..b8e054c
--- /dev/null
+++ b/audio/music/world/craft.mp3.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="mp3"
+type="AudioStreamMP3"
+uid="uid://c4y8puv0n6xk8"
+path="res://.godot/imported/craft.mp3-ba8d0c0c16ba90585f5d8eb4f37eb87d.mp3str"
+
+[deps]
+
+source_file="res://audio/music/world/craft.mp3"
+dest_files=["res://.godot/imported/craft.mp3-ba8d0c0c16ba90585f5d8eb4f37eb87d.mp3str"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/audio/sfx/fishing/bobber.wav b/audio/sfx/fishing/bobber.wav
new file mode 100644
index 0000000..8c9d916
Binary files /dev/null and b/audio/sfx/fishing/bobber.wav differ
diff --git a/audio/sfx/fishing/bobber.wav.import b/audio/sfx/fishing/bobber.wav.import
new file mode 100644
index 0000000..d5fc82d
--- /dev/null
+++ b/audio/sfx/fishing/bobber.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://sxydt4wnmrj3"
+path="res://.godot/imported/bobber.wav-b435bb251a112445f1ddebcacc74804a.sample"
+
+[deps]
+
+source_file="res://audio/sfx/fishing/bobber.wav"
+dest_files=["res://.godot/imported/bobber.wav-b435bb251a112445f1ddebcacc74804a.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/audio/sfx/fishing/fighting.wav b/audio/sfx/fishing/fighting.wav
new file mode 100644
index 0000000..f647069
Binary files /dev/null and b/audio/sfx/fishing/fighting.wav differ
diff --git a/audio/sfx/fishing/fighting.wav.import b/audio/sfx/fishing/fighting.wav.import
new file mode 100644
index 0000000..ec59561
--- /dev/null
+++ b/audio/sfx/fishing/fighting.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://df6yg85cldetw"
+path="res://.godot/imported/fighting.wav-1427843e18acf5134cc66d4a813033b6.sample"
+
+[deps]
+
+source_file="res://audio/sfx/fishing/fighting.wav"
+dest_files=["res://.godot/imported/fighting.wav-1427843e18acf5134cc66d4a813033b6.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=1
+edit/loop_begin=0
+edit/loop_end=124487
+compress/mode=0
diff --git a/audio/sfx/fishing/reeling.wav b/audio/sfx/fishing/reeling.wav
new file mode 100644
index 0000000..6be5d03
Binary files /dev/null and b/audio/sfx/fishing/reeling.wav differ
diff --git a/audio/sfx/fishing/reeling.wav.import b/audio/sfx/fishing/reeling.wav.import
new file mode 100644
index 0000000..e5e6749
--- /dev/null
+++ b/audio/sfx/fishing/reeling.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://v4yjxtslbrd7"
+path="res://.godot/imported/reeling.wav-97a9415ae2d9e7ca4c976bb15b10e6fd.sample"
+
+[deps]
+
+source_file="res://audio/sfx/fishing/reeling.wav"
+dest_files=["res://.godot/imported/reeling.wav-97a9415ae2d9e7ca4c976bb15b10e6fd.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=1
+edit/loop_begin=0
+edit/loop_end=114628
+compress/mode=0
diff --git a/collection/collection_log.gd b/collection/collection_log.gd
index ad0ab56..da36eb8 100644
--- a/collection/collection_log.gd
+++ b/collection/collection_log.gd
@@ -2,9 +2,13 @@ class_name CollectionLog
extends Node
signal fish_discovered(fish_id: StringName)
+signal fish_quality_discovered(fish_id: StringName, quality: int)
signal collection_changed
+const FishQualityType = preload("res://fish/fish_quality.gd")
+
var _discovered: Dictionary[StringName, bool] = {}
+var _quality_masks: Dictionary[StringName, int] = {}
func has_discovered(fish_id: StringName) -> bool:
@@ -19,6 +23,24 @@ func mark_discovered(fish_id: StringName) -> void:
collection_changed.emit()
+func mark_quality_discovered(fish_id: StringName, quality: int) -> void:
+ if fish_id.is_empty() or not FishQualityType.is_valid(quality):
+ return
+ var species_was_discovered: bool = has_discovered(fish_id)
+ if not species_was_discovered:
+ _discovered[fish_id] = true
+ var previous_mask: int = _quality_masks.get(fish_id, 0)
+ var next_mask: int = previous_mask | FishQualityType.bit_for(quality)
+ if species_was_discovered and next_mask == previous_mask:
+ return
+ _quality_masks[fish_id] = next_mask
+ if not species_was_discovered:
+ fish_discovered.emit(fish_id)
+ if next_mask != previous_mask:
+ fish_quality_discovered.emit(fish_id, quality)
+ collection_changed.emit()
+
+
func get_discovered_ids() -> Array[StringName]:
var discovered_ids: Array[StringName] = []
for fish_id: StringName in _discovered:
@@ -27,12 +49,53 @@ func get_discovered_ids() -> Array[StringName]:
return discovered_ids
+func has_discovered_quality(fish_id: StringName, quality: int) -> bool:
+ return (
+ not fish_id.is_empty()
+ and FishQualityType.is_valid(quality)
+ and (_quality_masks.get(fish_id, 0) & FishQualityType.bit_for(quality)) != 0
+ )
+
+
+func get_quality_mask(fish_id: StringName) -> int:
+ return _quality_masks.get(fish_id, 0) if has_discovered(fish_id) else 0
+
+
+func get_discovered_quality_masks() -> Dictionary[StringName, int]:
+ return _quality_masks.duplicate()
+
+
+func has_mastered(fish_id: StringName) -> bool:
+ return get_quality_mask(fish_id) == FishQualityType.ALL_TIERS_MASK
+
+
func replace_discovered_ids(fish_ids: Array[StringName]) -> bool:
+ var empty_masks: Dictionary[StringName, int] = {}
+ return replace_discovery_state(fish_ids, empty_masks)
+
+
+func replace_discovery_state(
+ fish_ids: Array[StringName],
+ quality_masks: Dictionary[StringName, int],
+) -> bool:
var replacement: Dictionary[StringName, bool] = {}
for fish_id: StringName in fish_ids:
if fish_id.is_empty():
return false
replacement[fish_id] = true
+ var replacement_masks: Dictionary[StringName, int] = {}
+ for fish_id: StringName in quality_masks:
+ var mask: int = quality_masks[fish_id]
+ if (
+ fish_id.is_empty()
+ or not replacement.has(fish_id)
+ or mask < 0
+ or (mask & ~FishQualityType.ALL_TIERS_MASK) != 0
+ ):
+ return false
+ if mask != 0:
+ replacement_masks[fish_id] = mask
_discovered = replacement
+ _quality_masks = replacement_masks
collection_changed.emit()
return true
diff --git a/default_bus_layout.tres b/default_bus_layout.tres
index ca38684..d780a19 100644
--- a/default_bus_layout.tres
+++ b/default_bus_layout.tres
@@ -13,3 +13,15 @@ bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = &"Master"
+bus/2/name = &"SFX"
+bus/2/solo = false
+bus/2/mute = false
+bus/2/bypass_fx = false
+bus/2/volume_db = 0.0
+bus/2/send = &"Master"
+bus/3/name = &"Environment"
+bus/3/solo = false
+bus/3/mute = false
+bus/3/bypass_fx = false
+bus/3/volume_db = 0.0
+bus/3/send = &"Master"
diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md
new file mode 100644
index 0000000..321b73d
--- /dev/null
+++ b/docs/ATTRIBUTION.md
@@ -0,0 +1,327 @@
+# Attribution, licensing, and asset provenance
+
+This is the authoritative project record for ownership, credits, license
+boundaries, third-party notices, asset provenance, and redistribution checks.
+A Git commit proves when bytes entered the repository; it does not by itself
+prove authorship or licensing.
+
+## Ownership and credits
+
+NETfishing is jointly owned, developed, and published by its creators,
+publicly credited as Voyager and Endeavour and operating collectively as the
+independent team Woofmeow.
+
+- Voyager: co-owner, developer, project director, creator of the game's 3D
+ art, and composer of the original title/ambient track
+ `audio/music/title/as_in_four_wolves.ogg`, world track
+ `audio/music/world/craft.mp3`, and animalese placeholder tones.
+- Endeavour: co-owner, developer, and creator of the game's 2D art, including
+ the original fish, environment, UI, and other artwork.
+- chillnfill: contributor of original 3D models for the character bodies and
+ arms, ears and tails, and multiple world props and decorative assets,
+ including trees and bridges. The contributor requested to be credited as
+ `chillnfill`.
+- Tekgator: creator of the cooler-capacity, reel-speed, and barrier-power shop
+ icons.
+
+Voyager is also the original catalog porter credit in PortMaster metadata.
+Third-party creators are credited with their individual records below. Those
+credits do not imply endorsement of NETfishing, Woofmeow, or its owners.
+
+## License map
+
+Do not describe the complete repository as covered only by the GPL.
+
+| Material | Governing terms |
+| --- | --- |
+| Project-owned software code, shaders, code-oriented scene/resource configuration, tests, build tooling, and server software | GPL-3.0-or-later; source-root `LICENSE` and packaged `GPL-3.0-or-later.txt` |
+| Project-owned artwork, textures, models, animation, music, audio, narrative content, characters, and other creative assets | Source-root or packaged `ASSET-LICENSE.md` |
+| NETfishing and Woofmeow names, logos, icons, and official identity | Source-root or packaged `TRADEMARKS.md` |
+| Third-party engines, fonts, sounds, and other external material | Their original terms, recorded below and in adjacent license texts where applicable |
+| New outside contributions | Source-root `CONTRIBUTOR-TERMS.md` plus any separately signed agreement |
+
+Generated Godot import metadata and deterministic generated resources follow
+the terms of their source material or generating code. Repository location
+alone does not relicense third-party material.
+
+The GPL permits study, modification, redistribution, and forks of covered
+code. It does not grant rights to NETfishing creative assets or branding. The
+NETfishing Asset License separately permits qualifying noncommercial add-on
+Mods for official NETfishing software; it does not permit those assets to
+accompany code forks, clones, standalone games, unrelated products, or
+general-purpose asset packs. A GPL fork must replace NETfishing assets and
+reserved branding with independently licensed material and a distinct
+identity.
+
+Project-wide code, asset, branding, or contributor-license changes require
+written approval from both NETfishing owners under their private joint
+ownership agreement.
+
+## Godot Engine notice
+
+NETfishing uses Godot Engine under the MIT License:
+
+> Copyright (c) 2014-present Godot Engine contributors.
+> Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
+>
+> 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.
+
+Godot licensing and bundled third-party-component notices are available at
+https://godotengine.org/license/ and
+https://github.com/godotengine/godot/blob/master/COPYRIGHT.txt. Godot uses
+FreeType for font rendering; portions are copyright The FreeType Project
+(https://www.freetype.org) under its applicable license.
+
+## Font notices
+
+- **Tuffy Bold** — Thatcher Ulrich, Karoly Barta, and Michael Evans; dedicated
+ to the public domain. Runtime file: `ui/fonts/Tuffy_Bold.otf`; original notice:
+ `ui/fonts/Tuffy-LICENSE.txt`.
+- **Seattle Avenue** — JLH Fonts; dedicated to the public domain by the creator.
+ Runtime file: `ui/fonts/seattle_avenue.otf`; source, hash, and original notice:
+ `ui/fonts/Seattle-Avenue-LICENSE.txt`.
+
+Public-domain fonts are not governed by the NETfishing Asset License.
+
+## Intake policy
+
+For every new external or supplied asset, record:
+
+- creator or supplying party;
+- original source location or delivery channel;
+- source filename and repository destination;
+- source and destination SHA-256 when copied without modification;
+- license or explicit permission;
+- any permitted transformations;
+- the importing commit or release.
+
+Maintainers must also obtain affirmative agreement to the source-root
+`CONTRIBUTOR-TERMS.md` before accepting an outside contribution and retain
+that agreement in a durable pull request, issue, email, or signed document.
+
+Runtime resources must use `res://` paths. Workstation Sync folders are intake
+locations only and must never appear in scenes or resources.
+
+## Asset inventory and provenance
+
+| Asset family | Repository location | Recorded provenance |
+| --- | --- | --- |
+| Fish art and portraits | `fish/species/`, `art/exported/fish/` | Original 2D artwork by co-owner Endeavour. |
+| Item icons | `items/icons/` | Original 2D artwork by co-owner Endeavour except where separately credited below. |
+| Cooler-capacity shop icon | `items/icons/equipment/64_cooler_plus.png` | Original artwork by Tekgator. Repository SHA-256: `b63705be5b78915753c068170323781a90e22b555b57a5b2ca9466e2726539f7`. |
+| Reel-speed shop icon | `items/icons/shop/64_speed_plus.png` | Original artwork by Tekgator. Repository SHA-256: `938452798f2be505e06ce3fbef178f52459331fd3bced8763fbc2a80157125ca`. |
+| Barrier-power shop icon | `items/icons/shop/64_power_plus.png` | Original artwork by Tekgator. Repository SHA-256: `164d34ce152aa6d75de79b69f0be2d9074f7891057de9636678889028ff98ae7`. |
+| Inventory notepad | `art/ui/ui_notepad.png` | Original 2D artwork by co-owner Endeavour; integrated without a runtime external path. |
+| Environment textures | `art/exported/environment/textures/` | Original 2D artwork by co-owner Endeavour. |
+| UI patterns | `art/patterns/` | Original 2D artwork by co-owner Endeavour. |
+| Fur customization channel maps | `art/exported/characters/patterns/` | Original 2D channel-map artwork by co-owner Endeavour. Exact file hashes are recorded below. |
+| Character base meshes and appendages | `art/source/characters/base/`, `art/exported/characters/base/`, and derived character resources | Original body and arm models and original ear and tail models by chillnfill. Any embedded original 2D artwork is by Endeavour. |
+| World and decorative prop models | `art/source/environment/`, `art/exported/environment/`, and derived world resources | Original prop and decorative models by chillnfill, including trees and bridges. Any embedded original 2D artwork is by Endeavour. |
+| Tuffy font | `ui/fonts/Tuffy_Bold.otf` | Public-domain dedication in `ui/fonts/Tuffy-LICENSE.txt`. |
+| Seattle Avenue font | `ui/fonts/seattle_avenue.otf` | Public-domain font by JLH Fonts; source record and hash in `ui/fonts/Seattle-Avenue-LICENSE.txt`. |
+| Title music | `audio/music/title/as_in_four_wolves.ogg` | Original music composed and owned by co-owner Voyager. |
+| Dusk music | `audio/music/world/craft.mp3` | Original music composed and owned by co-owner Voyager. |
+| Fishing fight loop | `audio/sfx/fishing/fighting.wav` | Edited from “Spinning reel.wav” by Freesound user tosha73, sound 509902, CC0. |
+| Manual-reeling loop | `audio/sfx/fishing/reeling.wav` | Edited from the same CC0 “Spinning reel.wav” source. |
+| Saltwater wave ambience | `audio/ambience/waves.wav` | “Gentle Ocean Waves Loop” by Freesound user kkenny101, sound 852826, CC0. |
+| Bobber water impact | `audio/sfx/fishing/bobber.wav` | “Quick Water Droplet” by Freesound user qubodup, sound 792931, CC0. |
+| Rain ambience | `audio/ambience/rain_loop_ontario.ogg` | Converted from “Rain Loop Ontario” by Freesound user Ayton, sound 212799, CC BY 3.0. |
+| Character bark call | `sound/dialogue/calls/bark.wav` | Edited from `Dog_Bark.wav` by Freesound user ivolipa, sound 328729, CC0. |
+| Character meow call | `sound/dialogue/calls/meow.wav` | Edited from `Cat meow.m4a` by Freesound user Christyboy100, sound 495694, Attribution 3.0. |
+| Animalese placeholder tones | `sound/dialogue/animalese/placeholder/` | Original tones composed and owned by co-owner Voyager without third-party samples. |
+
+Creative Commons Zero information:
+https://creativecommons.org/publicdomain/zero/1.0/. Creative Commons
+Attribution 3.0 legal text:
+https://creativecommons.org/licenses/by/3.0/legalcode.
+
+### chillnfill 3D model contribution record
+
+- Requested credit: `chillnfill`.
+- Original contribution families: character bodies and arms; character ears
+ and tails; and multiple world props and decorative assets, including trees
+ and bridges.
+- Repository mapping: character contributions are incorporated into the
+ character source/export families identified above. Prop contributions may
+ be incorporated into combined environment source and export files rather
+ than retained as one file per originally supplied model.
+- Permission record: because these historical contributions predate the
+ current `CONTRIBUTOR-TERMS.md`, retain the original permission record or a
+ later written confirmation covering Project use, modification,
+ distribution, commercial release, and applicable sublicensing.
+
+### Fur customization channel-map record
+
+Original channel-map artwork by Endeavour. Repository paths preserve the
+authored `mesh_style.png` filenames.
+
+| Style and mesh | Source filename | Repository file | SHA-256 |
+| --- | --- | --- | --- |
+| Bengal spots, body and arms | `body_arms_bengal.png` | `art/exported/characters/patterns/bengal/body_arms_bengal.png` | `fa90bd4e74b0b770ce97bf69d069676f5d4bd1f5d649f89e09638a66b61a8470` |
+| Bengal spots, main body | `body_main_bengal.png` | `art/exported/characters/patterns/bengal/body_main_bengal.png` | `8662fa198220cfdbe0aa83bdeeef7b855e20124d74f8481467f750d32b67ee8b` |
+| Bengal spots, round head | `head_round_bengal.png` | `art/exported/characters/patterns/bengal/head_round_bengal.png` | `f397ed8d152e3fda8181757a2bbd01925b7b8c2c4362f515d91c7f0fbf3ddfb6` |
+| Fox, body and arms | `body_arms_fox.png` | `art/exported/characters/patterns/fox/body_arms_fox.png` | `9930808718f31d84b415902f1ba55ac2a508bb2ac127300e793d73a8a013022d` |
+| Fox, main body | `body_main_fox.png` | `art/exported/characters/patterns/fox/body_main_fox.png` | `c65e4390173b8c31c9396edd053f940f0f809e47e01d18042cd7babc712dde9d` |
+| Fox, pointy head | `head_pointy_fox.png` | `art/exported/characters/patterns/fox/head_pointy_fox.png` | `71a9d2383e62884dbd3981460fcd985b18805fa907f61ffb16dce08ff9418ddf` |
+| Fox, round head | `head_round_fox.png` | `art/exported/characters/patterns/fox/head_round_fox.png` | `86dd7af8a2346faf67359ad668a117a74e2408676137d6b0626403cfcf2aba73` |
+| Fox tail | `tails_fox_fox.png` | `art/exported/characters/patterns/fox/tails_fox_fox.png` | `72da9673f038e2c3bb8aca19ed99932085fa3b162cab95366901c4e97153dd3e` |
+
+### Tekgator shop icon contribution record
+
+- Requested credit: `Tekgator`.
+- Original contributions: cooler-capacity, reel-speed, and barrier-power shop
+ icons.
+- Repository files and SHA-256:
+ - `items/icons/equipment/64_cooler_plus.png`:
+ `b63705be5b78915753c068170323781a90e22b555b57a5b2ca9466e2726539f7`
+ - `items/icons/shop/64_speed_plus.png`:
+ `938452798f2be505e06ce3fbef178f52459331fd3bced8763fbc2a80157125ca`
+ - `items/icons/shop/64_power_plus.png`:
+ `164d34ce152aa6d75de79b69f0be2d9074f7891057de9636678889028ff98ae7`
+- Permission record: retain the original permission record or a later written
+ confirmation covering Project use, modification, distribution, commercial
+ release, and applicable sublicensing.
+
+### Fishing fight loop source record
+
+- Source page: https://freesound.org/s/509902/
+- Creator: tosha73
+- Source title: `Spinning reel.wav`
+- License: Creative Commons Zero (CC0)
+- Downloaded source filename: `509902__tosha73__spinning-reel.wav`
+- Downloaded source SHA-256:
+ `9741afdb1ec9c73f5e039f0e5ac174998cb0a1f737b626531e63919d4d232fad`
+- Audacity working project: `fighting.aup3`
+- Audacity project SHA-256:
+ `9928f077ecf48d01416f8d05700707e1b07d0f731846db80e27116f5b82dbbf5`
+- Runtime edit source/destination SHA-256:
+ `b6f3f2f94bfaf9254ac04dd96b1fd28c15e1011a1db44bb3e70eeaa95de3c212`
+- Runtime format: 48 kHz, 16-bit, stereo PCM WAV; 2.593479 seconds;
+ configured as a forward loop by the fishing presentation.
+- Manual-reeling Audacity project: `reeling.aup3`
+- Manual-reeling Audacity project SHA-256:
+ `20ad52f6bdd16ae6561f06f8355fe309e95023e815053fd5dfbd5a5ce3c1f9f9`
+- Manual-reeling runtime source/destination SHA-256:
+ `e403d5b7c8af1da4b28599cc0f51de3eda70d9e9296db27852fe7dc95617a29d`
+- Manual-reeling runtime format: 48 kHz, 16-bit, stereo PCM WAV;
+ 2.388083 seconds; configured as a forward loop.
+- The downloaded source and Audacity projects remain in the project owner's
+ source-work archive; only the finished runtime edits ship in the game.
+
+### Saltwater wave ambience source record
+
+- Source page: https://freesound.org/s/852826/
+- Creator: kkenny101
+- Source title: `Gentle Ocean Waves Loop`
+- License: Creative Commons Zero (CC0)
+- Downloaded source filename:
+ `852826__kkenny101__gentle-ocean-waves-loop.wav`
+- Downloaded source and runtime source/destination SHA-256:
+ `f93e9890583f072ac52cf197b8b5942397d3d5e889fbdb9112a0776142f50229`
+- Runtime format: 48 kHz, 24-bit, mono PCM WAV; 21.769917 seconds;
+ configured as a forward loop.
+- The downloaded source remains in the project owner's source-work archive;
+ only the finished runtime loop ships in the game.
+
+### Bobber water-impact source record
+
+- Source page: https://freesound.org/s/792931/
+- Creator: qubodup
+- Source title: `Quick Water Droplet`
+- License: Creative Commons Zero (CC0)
+- Downloaded source filename: `792931__qubodup__quick-water-droplet.wav`
+- Downloaded source and runtime source/destination SHA-256:
+ `aef0d16384efed4a7b071c4d80c21597617f882f4e0c1d5295ac194d315eafb7`
+- Runtime format: 48 kHz, 16-bit, mono PCM WAV; 0.197146 seconds; one-shot.
+- The downloaded source remains in the project owner's source-work archive;
+ only the finished runtime sound ships in the game.
+
+### Character-call source records
+
+#### Bark
+
+- Source page: https://freesound.org/s/328729/
+- Creator: ivolipa
+- Source title: `Dog_Bark.wav`
+- License: Creative Commons Zero (CC0)
+- Downloaded source filename: `328729__ivolipa__dog_bark.wav`
+- Downloaded source SHA-256:
+ `c3d542dee7bfcc1901428e0c1408c0bd522d778d8e5e1dbca156cab008f3d712`
+- Runtime edit source/destination SHA-256:
+ `5ac2f32f33724a2fc232d725c5c37d4cfe3e93b3c1f19aa4177d2c8e3009ec78`
+- Runtime format: 44.1 kHz, 16-bit, stereo PCM WAV; 0.446780 seconds;
+ one-shot.
+
+#### Meow
+
+- Source page: https://freesound.org/s/495694/
+- Creator: Christyboy100
+- Source title: `Cat meow.m4a`
+- License: Attribution 3.0
+- Downloaded source filename: `495694__christyboy100__cat-meow.m4a`
+- Downloaded source SHA-256:
+ `afa3f6ef7afa2f7fee1e634d5505d2101cbba4421f8d8ff9adf562939c7a18f8`
+- Converted working WAV SHA-256:
+ `a505e6e230e7d9d76b65c46270d97a35a1acc510b43aa5e7343d7b04f424cade`
+- Runtime edit source/destination SHA-256:
+ `cce22ce1fbbc9325126a153aedc4526fde1aacf78780574fb432197dd44eb6b3`
+- Runtime format: 44.1 kHz, 16-bit, stereo PCM WAV; 0.605420 seconds;
+ one-shot.
+- The downloaded sources and working edits remain in the project owner's
+ source-work archive; only the finished runtime sounds ship in the game.
+
+### Rain ambience source record
+
+- Source page: https://freesound.org/s/212799/
+- Creator: Ayton
+- Source title: `Rain Loop Ontario`
+- License: Creative Commons Attribution 3.0
+- Downloaded source filename: `212799__ayton__rain-loop-ontario.aiff`
+- Downloaded source SHA-256:
+ `b491fc036b78a274d9ac7f6acbd014af1b5d24f5cba9bd7e3fdc05c5432ee66f`
+- Runtime edit SHA-256:
+ `b1979609b20fe34ed6f8902f3de65ae787a09abd71429e8aab850e87b74a4fa2`
+- Changes: converted from AIFF to Ogg Vorbis and configured as looping
+ weather ambience.
+
+## Generated resources
+
+Godot `.import` sidecars and deterministic shoreline `.tres` meshes are derived
+repository resources, not original artwork. `.godot/imported/`, editor caches,
+captures, exports, and temporary test data are not tracked.
+
+## Release gate
+
+Before a public binary or source release:
+
+1. Search exported resources for external filesystem paths.
+2. Include or make readily accessible the complete GPL text and identify the
+ exact corresponding source tag or commit.
+3. Include `ASSET-LICENSE.md`, `TRADEMARKS.md`, this attribution/provenance
+ record, both font notices, and every applicable third-party license.
+4. Retain the complete Godot Engine MIT notice above.
+5. Preserve third-party terms without adding incompatible restrictions.
+6. Avoid referring to a dirty or unavailable source revision.
+7. Publish or offer equivalent access to exact corresponding GPL source for
+ every distributed binary release.
+
+Desktop and PortMaster build scripts stage these authoritative files as
+sidecars. Godot export presets include them in exported resource packs for
+platforms where sidecar documents are not normally visible. Store and release
+pages should link to the exact source revision and public notices.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..63d3b08
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,352 @@
+# Development reference
+
+This is the authoritative reference for NETfishing architecture, engineering
+decisions, content policy, authoring, and validation.
+
+## Architecture
+
+### Composition
+
+`main/main.tscn` is the application composition root. It owns long-lived
+services, the active world, player container, save/settings managers, and the
+pixelated UI viewport. Services are scene-owned rather than global autoloads,
+which makes their dependencies visible in the main scene and allows focused
+tests to instantiate the same composition.
+
+The main script coordinates title, session, world, and UI lifecycle. Domain
+logic remains in typed resources and services rather than being stored solely
+in controls.
+
+### Domain boundaries
+
+- `fish/` defines species, availability, catches, pools, quality, and selection.
+- `fishing/` owns cast/chase state and resolves authoritative fishing surfaces.
+- `inventory/`, `items/`, `economy/`, `progression/`, and `jobs/` own persistent
+ player-facing game state.
+- `world/` owns authored map composition and presentation. Water type is a
+ shared typed definition used by water bodies, fish habitat, fishing
+ validation, Logbook classification, and saltwater shoreline presentation.
+- `ui/` observes and invokes domain services. It does not define stable item or
+ fish identity.
+
+Resource files (`.tres`) are authored data. Stable IDs, not display names or
+node names, connect persistent and networked state to that data.
+
+### Networking and authority
+
+`NetworkSession` owns ENet session lifecycle and peer registration. Dedicated
+network services validate and replicate bounded domains such as fishing,
+sales, shops, item use, profiles, jobs, mail, chat, drawings, time, and weather.
+
+`DiscoveryClient` is an optional directory layer beside `NetworkSession`. An
+open host may publish a short-lived room lease, and the shared Join Game page
+may browse compatible leases before handing the selected address back to the
+existing direct ENet connection flow. The directory does not carry gameplay
+traffic or become a gameplay authority. Its base URL comes from
+`network/discovery/base_url`, with `NETFISHING_DISCOVERY_URL` available as a
+development/deployment override.
+
+The host is authoritative. Clients submit requests or evidence; the host
+derives trusted context from registered peers, authoritative regions, and
+server-owned state before mutating inventory, wallet, progression, or shared
+world state. Private single-player uses the same host path. Client evidence is
+bounded and reconstructed where possible; UI activation is never proof that a
+server-side mutation succeeded.
+
+Moderation follows the same boundary. Player hosts may grant session-scoped
+operator status to an authenticated identity. Dedicated servers derive
+operators from their configured fingerprint allowlist. Operator status is
+replicated for presentation, but kick, ban, unban, and artwork-reset requests
+are always reauthorized against the authenticated sender by the host. Only a
+player host can grant or revoke operators; operators cannot moderate the host
+or another operator.
+
+Protocol compatibility is defined in `network/network_protocol.gd`. A visible
+release version is not a reason to change the protocol number.
+
+### Persistence and stable identifiers
+
+`PlayerDataRoot` selects and validates a portable data root. Stores receive
+paths from that owner rather than inventing unrelated locations. Progression is
+written by `PlayerSaveManager`; device settings and social/identity stores have
+separate formats and lifecycles.
+
+Save migrations are sequential and explicit. Existing catches and ownership
+are keyed by stable IDs so authored metadata can evolve without rewriting
+historical records. Current source constants—not documentation—are
+authoritative for format versions.
+
+Repository-owned resources are authoritative authored data. Persistent and
+networked records refer to stable IDs; display names, filenames, node names,
+coordinates, and pool names are not compatibility identifiers. Cross-domain
+concepts such as water type use one typed definition. Removing or changing a
+stable ID requires an explicit compatibility plan.
+
+### Presentation
+
+Gameplay is rendered in 3D with the GL Compatibility renderer. The main UI is
+presented through a uniformly scaled SubViewport. Shared UI components and
+palette resources prevent page-specific geometry and style drift.
+
+World presentation systems (time/weather visuals, procedural sky and water,
+shoreline ribbons, player blob shadows) do not own gameplay collision or
+network authority. Generated shoreline meshes are deterministic presentation
+resources baked from explicitly configured static terrain.
+
+### Engineering consequences
+
+- Shared mutations must behave consistently for private hosts, open hosts,
+ dedicated servers, and joined clients.
+- Networking changes require local-host and paired host/client validation.
+- Protocol payloads and rejection cleanup are explicit and bounded.
+- Renaming visible UI does not require a save migration.
+- Content validation covers stable-ID uniqueness, catalog completeness, pool
+ membership, and typed habitat compatibility.
+- New authored assets require provenance records as well as resource
+ references.
+
+## Content and balance
+
+Balance is authored data and should change deliberately. Exact current values
+remain authoritative in `.tres` resources and typed scripts.
+
+### Fish
+
+- Stable fish IDs and catalog numbers must not be changed for display cleanup.
+- Species availability may constrain water type, time, weather, bait, and
+ other authored context.
+- Location pools control selection weight; the global catalog remains
+ comprehensive.
+- Freshwater and saltwater habitat is validated by the host in addition to
+ pool membership.
+- Value, rarity, quality, weight ranges, and authored barrier-health bands
+ should be reviewed together because they affect economy and catch
+ difficulty.
+
+### Economy and progression
+
+- Sales and purchases are host-authoritative and must mutate inventory and
+ wallet exactly once.
+- Reserved assets are rejected atomically; mixed valid and reserved batches
+ must not partially succeed.
+- Item effects, shop prices, cooler capacity, fishing upgrades, jobs, and
+ experience are separate balance axes. Avoid changing several in an
+ unrelated presentation pass.
+
+Record affected stable IDs and resource paths, old and new values, intended
+player-facing outcome, interactions with quality/availability/economy,
+deterministic validation, and save-compatibility impact with each balance
+change. Do not bump a save schema or network protocol merely because an
+authored number changed.
+
+## Authoring
+
+### World composition
+
+The active test world uses the canonical starter island through
+`world/regions/starter_island_region.tscn`.
+
+`world/test_world.tscn` owns the gameplay-wide environment, sun, world bounds,
+and below-world failsafe. `StarterIslandRegion` owns the placed island content:
+
+```text
+StarterIslandRegion
+├── Terrain
+│ ├── Visual
+│ └── Collision
+├── WaterBodies
+│ ├── Pond
+│ │ ├── VisualWater
+│ │ ├── FishingRegion
+│ │ └── RecoveryRegion
+│ └── Ocean
+│ ├── VisualWater
+│ ├── FishingRegions
+│ └── RecoveryRegions
+├── PlayerSpawn
+├── SafeRespawns
+└── Interactables
+ ├── FishingShopWorld
+ └── PelicanCoolerPerch
+```
+
+Move a meaningful feature root rather than one of its implementation children.
+Child transforms are local offsets owned by that feature.
+
+Every fishable region authors its `water_type` explicitly. The starter pond is
+`FRESH_WATER`; the starter coast is `SALT_WATER`. Fish species use the same
+central type through an allowed-water-type bitmask. Do not infer habitat from
+node names, pool filenames, coordinates, or water height.
+
+Select `WaterBodies/Pond` to move or resize the pond. Its transform is the
+authoritative surface position; surface size and fishing/recovery coverage are
+owned together. Select `WaterBodies/Ocean` to move the surrounding water as one
+feature. Its explicit visual, fishing, and recovery lobes remain local to that
+root.
+
+Placed-feature ownership:
+
+- `PlayerSpawn` defines the initial player transform and facing.
+- `SafeRespawns` contains authoritative recovery destinations. Recovery
+ preserves current facing.
+- `Interactables/FishingShopWorld` owns the shop visual, collision, interaction
+ area, prompt, and entrance marker.
+- `Interactables/PelicanCoolerPerch` owns the Pelican landmark visual; selling
+ remains a Cooler action.
+- `Terrain` owns imported visuals and generated collision.
+- `BelowWorldFailsafe/Coverage` owns below-world recovery coverage.
+
+The starter-island GLB hierarchy is the terrain-collision authority. The
+region rebuilds one concave collision shape from every imported
+`MeshInstance3D` when it loads, so newly exported terrain and props participate
+without a separate stale collision bake. Keep purely decorative meshes out of
+that hierarchy if they should not block players.
+
+The active `Environment`, `Sun`, and starter-island grass material remain
+Inspector-authored and are not recreated by runtime scripts.
+
+### Reusable world props
+
+Use one placed `WorldProp` root for a complete landmark:
+
+```text
+WorldProp
+├── Visual
+└── Collision
+ └── CollisionShape3D
+```
+
+Put optional interaction areas, labels, and markers beneath the same root.
+Instance imported GLB content under `Visual` and keep gameplay collision under
+`Collision`. Author reusable assets at scale `(1, 1, 1)`. Uniform provisional
+root scaling is acceptable when visual and collision scale together; avoid
+non-uniform root scaling. Make mutable per-instance shapes, meshes, and
+materials local to the scene.
+
+### Shoreline tide ribbons
+
+Maps using generated tide ribbons own a `ShorelineRibbonBaker` and one
+`ShorelineRibbonConfig` per water body. Each configuration selects only its
+static terrain source, water height, generation bounds, water-facing reference,
+smoothing overrides, and generated `.tres` path. Props, players, bobbers, and
+gameplay areas are not scanned unless selected explicitly. Only `SALT_WATER`
+bodies generate ribbons.
+
+The editor automatically rebuilds configured shoreline resources when an
+authored terrain GLB is reimported. Select the baker and press **Rebuild
+Shoreline Ribbons** only when intentionally forcing a rebuild without a terrain
+reimport. The equivalent headless fallback and validation command is:
+
+```sh
+godot --headless --path . --script scripts/bake_shoreline_ribbons.gd
+```
+
+Use `debug_path_stage` only while comparing extraction stages and leave it Off
+in committed scenes. A rebuild is required when saltwater terrain at the
+waterline, water height, or generation bounds change; terrain reimports handle
+the usual case automatically. Normal gameplay loads committed generated meshes
+and never runs extraction.
+
+### Facial-feature textures
+
+Put new PNGs under the matching directory in
+`art/exported/characters/faces/`:
+
+```text
+eyes/.png
+noses/.png
+mouth/.png
+```
+
+The category directory is authoritative. A category prefix is optional, so
+both `sleepy.png` and `eyes_sleepy.png` produce the stable option ID `sleepy`.
+Filenames normalize to lowercase snake_case and are stored in appearance
+snapshots, so do not casually rename them. Preserve the established RGBA
+transparent canvas. Restart a development build after adding an image; rebuild
+exports to package new `res://` files.
+
+### Texture sampling
+
+NETfishing artwork always uses nearest-neighbor sampling. Do not enable linear,
+bilinear, trilinear, or anisotropic texture filtering, and do not generate
+mipmaps. Shader samplers must declare `filter_nearest`. The
+`TextureSamplingPolicy` autoload applies nearest sampling to 2D canvas items,
+Sprite3D presentations, imported 3D materials, and dynamically constructed
+mesh materials at runtime.
+
+After importing new artwork, normalize its tracked import metadata where
+needed and run the focused policy check:
+
+```sh
+godot --headless --path . --script scripts/normalize_texture_imports.gd \
+ -- --apply --root res://path/to/new/artwork
+godot --headless --path . --script tests/texture_sampling_validation.gd
+```
+
+### Bubble menus
+
+Instance `ui/components/bubble_menu/bubble_button.tscn` for each action, or
+attach `bubble_button.gd` to an existing button. Author neutral size, desktop
+and compact anchors, font-size limits, and deterministic motion in the
+Inspector. A label child may be assigned with `label_control_path`.
+
+Place buttons under a Control using `bubble_cluster.gd`, pass ordered button
+references to `configure()`, and call `apply_layout()` when available size or
+responsive layout changes. Order defines keyboard/controller focus neighbors.
+The shared profile owns palette, styles, proportional-font ratio, hover
+response, and contact tuning. Parent menus own labels, actions, anchors,
+availability, and confirmation behavior.
+
+`motion_scale = 0.0` disables idle drift/deformation while preserving hover and
+focus feedback. Contact correction is deterministic and bounded; do not replace
+it with physics that destabilizes layout, focus order, or hit targets.
+
+## Validation
+
+Tests are executable Godot `SceneTree` scripts. Some are pure content/domain
+checks, some instantiate the main scene, and multiplayer checks run paired host
+and client processes on loopback. The repository runner provides isolation and
+consistent entry points.
+
+### Consolidated runner
+
+```sh
+scripts/run_validations.sh quick
+scripts/run_validations.sh full
+scripts/run_validations.sh host
+scripts/run_validations.sh network
+scripts/run_validations.sh all
+scripts/run_validations.sh --list
+```
+
+- `quick` runs deterministic content and domain checks.
+- `full` adds socket-free scene/runtime checks.
+- `host` runs single-process authority checks that bind local UDP.
+- `network` runs loopback host/client pairs.
+- `all` runs `full`, `host`, and `network`.
+
+Set `GODOT_BIN` to select an executable and `TEST_TIMEOUT_SECONDS` to change
+the per-process timeout. The runner isolates and removes XDG roots.
+
+For one focused test:
+
+```sh
+test_root="$(mktemp -d)"
+XDG_DATA_HOME="$test_root/data" \
+XDG_CONFIG_HOME="$test_root/config" \
+godot --headless --path . --script tests/fish_catalog_content_validation.gd
+rm -rf -- "$test_root"
+```
+
+Do not point `NETFISHING_DATA_DIR` at an arbitrary empty directory; it is an
+explicit portable-data override and must identify a valid data root.
+
+Headless tests cannot prove visual alignment, mouse routing, shader appearance,
+controller feel, or resize behavior. Presentation changes require graphical
+review at the canonical 1280×720 layout and relevant low/high and ultrawide
+resolutions.
+
+Network tests require free loopback UDP ports and local-socket permission. A
+release audit additionally requires clean Git/tag verification, editor import,
+platform exports, executable smoke tests, archive inspection, and SHA-256
+verification; those checks are intentionally outside the development runner.
diff --git a/docs/PORTMASTER.md b/docs/PORTMASTER.md
new file mode 100644
index 0000000..2cd6038
--- /dev/null
+++ b/docs/PORTMASTER.md
@@ -0,0 +1,169 @@
+# PortMaster reference
+
+NETfishing's PortMaster release is a canonical PortMaster package, not a
+version-named generic ZIP. The installer-facing archive must be named
+`netfishing.zip`, and its root must contain exactly:
+
+```text
+NETfishing.sh
+netfishing/
+port.json
+```
+
+Do not derive a release package from an older local `portmaster-stage`
+directory. The templates in `scripts/portmaster/` are authoritative.
+
+## Release contract
+
+- `port.json` uses schema version 4 and names `netfishing.zip`.
+- `NETfishing.sh` starts with `# PORTMASTER: netfishing.zip, NETfishing.sh`.
+- The executable is `netfishing/NETfishing.aarch64`.
+- The package declares AArch64, two analog sticks, GLIBC 2.28, and
+ `weston_pkg_0.2.squashfs`.
+- The launcher must not start GPTOKEYB. Godot and NETfishing's controller
+ mapping manager handle controller input directly.
+- Persistent device data remains under `netfishing/conf/data`,
+ `netfishing/conf/config`, and `netfishing/conf/cache`.
+- The archive must not contain `conf/`, saves, identities, logs, source files,
+ `.git`, or `.godot` content.
+- Release downloads must publish `netfishing.zip`. A renamed versioned ZIP is
+ not a substitute because HarbourMaster identifies the canonical port by
+ archive name.
+
+## Build
+
+Run this only after the release commit and annotated `v` tag
+are pinned to the same clean `main` commit:
+
+```bash
+bash scripts/build_portmaster.sh
+```
+
+The script deletes and regenerates the Linux ARM64 export, stages the package,
+validates its structure, and writes:
+
+```text
+builds/v/netfishing.zip
+```
+
+`--package-only` is reserved for repackaging an already validated ARM64 export.
+It does not rebuild game content.
+
+## Local muOS installation
+
+The muOS auto-install directory is:
+
+```text
+/mnt/mmc/MUOS/PortMaster/autoinstall/
+```
+
+For an explicit offline installation test, copy `netfishing.zip` to a temporary
+device directory and run:
+
+```bash
+/mnt/mmc/MUOS/PortMaster/harbourmaster \
+ --offline --no-check install ./netfishing.zip
+```
+
+Before upgrading an existing installation, preserve
+`/mnt/mmc/ports/netfishing/conf/` on the device. After installation:
+
+1. Confirm the installed executable and PCK hashes match the staged release.
+2. Confirm the installed launcher contains the canonical PortMaster header and
+ does not contain `GPTOKEYB`.
+3. Confirm `conf/` was not replaced or removed.
+4. Launch the installed port through the normal muOS menu.
+5. Verify the displayed game version and controller face-button mapping.
+
+Installing the public PortMaster catalog entry can still install an older
+catalog build until the upstream `netfishing.zip` is updated. A local release
+test must install the generated local archive explicitly.
+
+## Template provenance
+
+The current catalog screenshot is a 640x480 PNG copied
+byte-for-byte into the package template:
+
+```text
+f24daae48f543b3a270a5aa46b5e73a31904bf0c7216d4c8a9e15a8dc48a5eed screenshot.png
+```
+
+The remaining catalog metadata assets were copied from the installed official
+`netfishing.zip` package and are intentionally preserved byte-for-byte:
+
+```text
+e2a9d132744684c67865c01eca027a8c9946b4c5a19da57c82f1af0373dc83b7 gameinfo.xml
+```
+
+The package’s `licenses/` directory is assembled from the authoritative root
+licenses and trademarks, the consolidated `docs/ATTRIBUTION.md`, and the font
+notices at build time. It also records the exact release tag and corresponding
+GPL source commit. Do not restore stale duplicated notice templates beneath
+`scripts/portmaster/`.
+
+The original catalog porter credit, `Voyager`, remains in `port.json`.
+
+## muOS H700 controller mapping
+
+Godot 4.7.1 and the SDL2 utilities shipped by muOS identify the same virtual
+controller differently. The PortMaster SDL2 database uses GUID
+`19000000010000000100000000010000`, while Godot reports GUID
+`19004ca6010000000100000000010000`. Passing the SDL2 entry unchanged leaves
+most controls unmapped in Godot.
+
+The device-verified Godot mapping is:
+
+- A `b0`, B `b1`, Y `b2`, X `b3`
+- left bumper `b4`, right bumper `b5`
+- select `b6`, start `b7`, guide `b8`
+- left-stick click `b9`, left trigger `b10`, right trigger `b11`, right-stick click `b12`
+- D-pad `h0`; left and right sticks `a0..a3`
+
+`scripts/portmaster/NETfishing.sh` must replace the incompatible SDL2 entry
+with the Godot entry when that SDL2 GUID is selected. Do not append the two
+entries with a newline: WestonPack evaluates launcher arguments through a
+shell and treats the second line as a command. Pass the single selected mapping
+as `SDL_GAMECONTROLLERCONFIG` in the game command after Weston initializes;
+Weston sources PortMaster's control file internally and otherwise restores the
+SDL2 value.
+
+Do not call `Input.add_joy_mapping(..., true)` for a recognized connected muOS
+controller. Updating this virtual controller after connection can stop Godot
+from delivering its standardized controller events until restart.
+
+Before shipping a PortMaster build on H700 hardware, verify all of the
+following in the installed game:
+
+- A, B, X, and Y each register independently.
+- The D-pad navigates every menu direction.
+- Both sticks, both bumpers, both triggers, Select, Start, L3, and R3 register.
+- A single button never cancels auto-map.
+- Holding both bumpers together for 1.25 seconds cancels auto-map.
+
+## Low-end performance profile
+
+The launcher keeps the normal game profile on unknown and stronger hardware.
+It enables the low-end profile only when the Linux device tree reports one of
+the identifiers used by Allwinner H616/H700 XX handhelds:
+
+- `allwinner,h616`
+- `sun50iw9p1`
+- `allwinner,sun50i-h700`
+
+The low-end profile passes these Godot options:
+
+- `--single-window`
+- `--disable-vsync`
+- `--max-fps 30`
+- `--audio-output-latency 40`
+
+It also passes `NETFISHING_LOW_END=1`. The game renders the 3D world at 75%
+linear resolution with nearest-neighbor scaling, reducing 3D pixel work by
+about 44% while the separately rendered UI retains its canonical resolution.
+
+Do not use Godot's low processor mode for this profile. It reduces idle CPU
+usage by sleeping between updates and is not a game-performance optimization.
+
+Before adding a device family, record its exact NUL-separated device-tree
+`compatible` value from hardware. Do not infer detection from a retail product
+name alone.
diff --git a/playtest/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt
similarity index 80%
rename from playtest/README-PLAYTEST.txt
rename to docs/README-PLAYTEST.txt
index 060f1cf..9d895f1 100644
--- a/playtest/README-PLAYTEST.txt
+++ b/docs/README-PLAYTEST.txt
@@ -1,12 +1,13 @@
-NETFISHING
-Pre-Alpha Playtest 0.1
-Version 0.1.0-prealpha
+NETfishing
+v0.7.1-alpha
+Alpha 0.7.1
Thank you for trying this early private playtest.
-NETFISHING is currently a local gameplay prototype for a future
-multiplayer-first game. Networking and multiplayer are NOT implemented in
-this build.
+NETfishing is an early multiplayer fishing game. This alpha includes local
+progression, player-hosted and dedicated multiplayer sessions, public room
+discovery, and direct UDP connections. Expect rough edges and please keep a
+backup of any save or identity data you care about.
INSTALLATION
@@ -54,12 +55,15 @@ FISHING
- Coffee improves movement; other supplies temporarily improve fishing.
- The Cooler starts at 12 fish; buy permanent capacity expansions at the shop.
- Reel Speed upgrades increase authoritative green reeling progress.
-- Barrier Power upgrades increase damage per valid barrier action.
+- Rod Power upgrades increase damage per valid barrier action.
- Shop upgrades persist in the local progression save.
- Escape closes shop confirmations before closing the shop itself.
FEATURES TO TRY
+- Hosting a private or public room and joining compatible online sessions
+- Direct-IP, saved-server, recent-server, and public-room connection flows
+- Text chat, player calls, player lists, relationships, and moderation tools
- Movement, jumping, camera rotation, and zoom
- Short, medium, and maximum-distance casts
- Valid water and invalid land casts
@@ -101,11 +105,11 @@ Please include:
- How movement, casting, chase pressure, barrier clicking, menus, and camera
controls felt
-KNOWN PRE-ALPHA LIMITATIONS
+KNOWN ALPHA LIMITATIONS
-- Multiplayer/networking is not implemented.
-- Fish and character art are temporary.
-- UI and balance values are placeholders.
-- There is no audio.
+- Public sessions remain direct connections; restrictive NAT or carrier-grade
+ networks may prevent some players from joining.
+- Multiplayer, dedicated-server deployment, UI, and balance are still alpha.
+- Some fish and character artwork remains temporary.
- There is no installer or automatic updater.
- Windows builds are unsigned.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..49645b3
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,60 @@
+# NETfishing documentation
+
+This directory is the single home for maintained project documentation.
+Start here instead of searching feature, asset, world, or script directories
+for local README files.
+
+## Authoritative references
+
+- [`DEVELOPMENT.md`](DEVELOPMENT.md): architecture, engineering decisions,
+ content and balance policy, authoring guidance, and validation.
+- [`ATTRIBUTION.md`](ATTRIBUTION.md): ownership, credits, licensing map,
+ third-party notices, asset provenance, and redistribution requirements.
+- [`PORTMASTER.md`](PORTMASTER.md): PortMaster packaging, installation,
+ controller mapping, performance behavior, and hardware release checks.
+- [`README-PLAYTEST.txt`](README-PLAYTEST.txt): the player-facing README staged
+ into desktop playtest packages.
+
+## Related repositories
+
+- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-discovery-server/src/branch/main/docs/API.md)
+ stays with the service implementation and defines its versioned HTTP
+ contract.
+- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-dedicated-server/src/branch/main/README.md)
+ stays with its installer and update scripts. It consumes this repository's
+ attribution and legal notices from the exact pinned game commit instead of
+ maintaining copies.
+
+Those code-coupled references remain in their own repositories. Game-wide
+architecture, authoring, licensing, credits, and provenance are not duplicated
+there.
+
+Exact runtime values and behavior remain authoritative in source code, scenes,
+and resources. These documents describe boundaries, policy, and maintenance
+procedures; they should not duplicate changing constants or inventories that
+can be derived reliably from the project.
+
+## Files intentionally kept at the repository root
+
+The following are entry points or governing legal documents rather than
+general project documentation, so they remain at conventional root paths:
+
+- `README.md`
+- `CONTRIBUTING.md`
+- `LICENSE`
+- `ASSET-LICENSE.md`
+- `TRADEMARKS.md`
+- `CONTRIBUTOR-TERMS.md`
+
+Third-party font license texts stay beside their font files under `ui/fonts/`
+so the licensed files and their original notices cannot drift apart. Build
+scripts collect those legal files and the authoritative attribution record
+when assembling releases.
+
+## Maintenance rule
+
+Do not add feature-specific README files elsewhere in the repository. Add or
+update the appropriate reference above and link to its heading when source
+comments need more context. Player-facing package instructions belong only in
+`README-PLAYTEST.txt`; source and scripts remain authoritative for command-line
+usage.
diff --git a/drawing/surface_drawing_canvas.gd b/drawing/surface_drawing_canvas.gd
new file mode 100644
index 0000000..f6ceb94
--- /dev/null
+++ b/drawing/surface_drawing_canvas.gd
@@ -0,0 +1,393 @@
+class_name SurfaceDrawingCanvas
+extends Node3D
+
+const GUIDED_PIXEL_FILL: float = 0.88
+const FINISHED_PIXEL_FILL: float = 1.0
+const SURFACE_OFFSET: float = 0.012
+const LAYER_OFFSET_STEP: float = 0.0002
+const SURFACE_SAMPLE_DEPTH: float = 0.45
+
+var canvas_id: String = ""
+var grid_width: int = 0
+var grid_height: int = 0
+var cell_size: float = 0.0
+var revision: int = -1
+var creator_fingerprint: String = ""
+
+var _surface_origin: Vector3
+var _surface_normal: Vector3
+var _surface_tangent: Vector3
+var _surface_bitangent: Vector3
+var _cells: Dictionary[int, Dictionary] = {}
+var _color_meshes: Dictionary[StringName, MultiMeshInstance3D] = {}
+var _grid_instance: MeshInstance3D
+var _guide_visible: bool = true
+var _finalized: bool = false
+var _layer: int = 0
+var _stencil_requested_visible: bool = false
+var _relationships: PlayerRelationshipStore
+var _solid_surface_mask: int = 1
+
+
+func setup(
+ data: Dictionary,
+ relationships: PlayerRelationshipStore,
+ solid_surface_mask: int = 1,
+) -> bool:
+ if not SurfaceDrawingProtocol.validate_canvas_state(data):
+ return false
+ canvas_id = str(data["canvas_id"])
+ grid_width = int(data["width"])
+ grid_height = int(data["height"])
+ cell_size = float(data["cell_size"])
+ revision = int(data["revision"])
+ creator_fingerprint = str(data["creator_fingerprint"])
+ _finalized = bool(data.get("finalized", false))
+ _guide_visible = bool(data.get("guide_visible", true)) and not _finalized
+ _layer = int(data.get("layer", 0))
+ _surface_origin = SurfaceDrawingProtocol.array_to_vector(data["origin"])
+ _surface_normal = SurfaceDrawingProtocol.array_to_vector(
+ data["normal"]
+ ).normalized()
+ _surface_tangent = SurfaceDrawingProtocol.array_to_vector(
+ data["tangent"]
+ )
+ _surface_tangent = (
+ _surface_tangent - _surface_normal * _surface_tangent.dot(_surface_normal)
+ ).normalized()
+ if _surface_normal.is_zero_approx() or _surface_tangent.is_zero_approx():
+ return false
+ _surface_bitangent = _surface_normal.cross(_surface_tangent).normalized()
+ _relationships = relationships
+ _solid_surface_mask = solid_surface_mask
+ _cells.clear()
+ for cell_value: Variant in data["cells"]:
+ var cell: Dictionary = cell_value
+ _cells[_cell_key(int(cell["x"]), int(cell["y"]))] = cell.duplicate(true)
+ _build_grid()
+ _rebuild_pixels()
+ return true
+
+
+func apply_update(data: Dictionary) -> bool:
+ if (
+ not SurfaceDrawingProtocol.validate_canvas_update(data)
+ or str(data["canvas_id"]) != canvas_id
+ or int(data["revision"]) <= revision
+ ):
+ return false
+ for edit_value: Variant in data["edits"]:
+ var edit: Dictionary = edit_value
+ var key: int = _cell_key(int(edit["x"]), int(edit["y"]))
+ if str(edit["color_id"]).is_empty():
+ _cells.erase(key)
+ else:
+ _cells[key] = edit.duplicate(true)
+ revision = int(data["revision"])
+ _rebuild_pixels()
+ return true
+
+
+func apply_guide_update(data: Dictionary) -> bool:
+ if (
+ not SurfaceDrawingProtocol.validate_guide_update(data)
+ or str(data["canvas_id"]) != canvas_id
+ or int(data["revision"]) <= revision
+ ):
+ return false
+ revision = int(data["revision"])
+ _finalized = bool(data["finalized"])
+ _guide_visible = bool(data["guide_visible"]) and not _finalized
+ _refresh_grid_visibility()
+ _rebuild_pixels()
+ return true
+
+
+func refresh_relationship_visibility() -> void:
+ _rebuild_pixels()
+
+
+func set_stencil_visible(should_be_visible: bool) -> void:
+ _stencil_requested_visible = should_be_visible
+ _refresh_grid_visibility()
+
+
+func is_guide_visible() -> bool:
+ return _guide_visible
+
+
+func is_finalized() -> bool:
+ return _finalized
+
+
+func get_rendered_pixel_size() -> float:
+ return cell_size * _pixel_fill()
+
+
+func contains_world_point(world_point: Vector3, tolerance: float = 0.3) -> bool:
+ var relative: Vector3 = world_point - _surface_origin
+ if absf(relative.dot(_surface_normal)) > tolerance:
+ return false
+ var half_width: float = float(grid_width) * cell_size * 0.5
+ var half_height: float = float(grid_height) * cell_size * 0.5
+ var horizontal: float = relative.dot(_surface_tangent)
+ var vertical: float = relative.dot(_surface_bitangent)
+ return (
+ horizontal >= -half_width
+ and horizontal < half_width
+ and vertical >= -half_height
+ and vertical < half_height
+ )
+
+
+func get_surface_plane_distance(world_point: Vector3) -> float:
+ return absf((world_point - _surface_origin).dot(_surface_normal))
+
+
+func cell_at_world_point(world_point: Vector3) -> Vector2i:
+ var relative: Vector3 = world_point - _surface_origin
+ var half_width: float = float(grid_width) * cell_size * 0.5
+ var half_height: float = float(grid_height) * cell_size * 0.5
+ var x: int = floori(
+ (relative.dot(_surface_tangent) + half_width) / cell_size
+ )
+ var y: int = floori(
+ (relative.dot(_surface_bitangent) + half_height) / cell_size
+ )
+ if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
+ return Vector2i(-1, -1)
+ return Vector2i(x, y)
+
+
+func get_cell_world_position(x: int, y: int) -> Vector3:
+ var horizontal: float = (
+ (float(x) + 0.5 - float(grid_width) * 0.5) * cell_size
+ )
+ var vertical: float = (
+ (float(y) + 0.5 - float(grid_height) * 0.5) * cell_size
+ )
+ return (
+ _surface_origin
+ + _surface_tangent * horizontal
+ + _surface_bitangent * vertical
+ )
+
+
+func get_surface_normal() -> Vector3:
+ return _surface_normal
+
+
+func get_surface_tangent() -> Vector3:
+ return _surface_tangent
+
+
+func get_surface_bitangent() -> Vector3:
+ return _surface_bitangent
+
+
+func get_cell_surface_transform(
+ x: int,
+ y: int,
+ fill: float = 0.94,
+) -> Transform3D:
+ if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
+ return Transform3D.IDENTITY
+ return global_transform * _sample_cell_transform(x, y, fill)
+
+
+func get_authoritative_cells() -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for value: Dictionary in _cells.values():
+ result.append(value.duplicate(true))
+ result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ var a_key: int = _cell_key(int(a["x"]), int(a["y"]))
+ var b_key: int = _cell_key(int(b["x"]), int(b["y"]))
+ return a_key < b_key
+ )
+ return result
+
+
+func _build_grid() -> void:
+ if _grid_instance != null:
+ _grid_instance.queue_free()
+ var immediate := ImmediateMesh.new()
+ var material := StandardMaterial3D.new()
+ material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ material.albedo_color = Color(0.75, 0.94, 0.96, 0.38)
+ material.no_depth_test = false
+ immediate.surface_begin(Mesh.PRIMITIVE_LINES, material)
+ var half_width: float = float(grid_width) * cell_size * 0.5
+ var half_height: float = float(grid_height) * cell_size * 0.5
+ for x: int in range(grid_width + 1):
+ var horizontal: float = -half_width + float(x) * cell_size
+ for y_segment: int in range(grid_height):
+ var start_vertical: float = (
+ -half_height + float(y_segment) * cell_size
+ )
+ _add_grid_vertex(
+ immediate,
+ _surface_origin + _surface_tangent * horizontal
+ + _surface_bitangent * start_vertical,
+ )
+ _add_grid_vertex(
+ immediate,
+ _surface_origin + _surface_tangent * horizontal
+ + _surface_bitangent * (start_vertical + cell_size),
+ )
+ for y: int in range(grid_height + 1):
+ var vertical: float = -half_height + float(y) * cell_size
+ for x_segment: int in range(grid_width):
+ var start_horizontal: float = (
+ -half_width + float(x_segment) * cell_size
+ )
+ _add_grid_vertex(
+ immediate,
+ _surface_origin + _surface_tangent * start_horizontal
+ + _surface_bitangent * vertical,
+ )
+ _add_grid_vertex(
+ immediate,
+ _surface_origin
+ + _surface_tangent * (start_horizontal + cell_size)
+ + _surface_bitangent * vertical,
+ )
+ immediate.surface_end()
+ _grid_instance = MeshInstance3D.new()
+ _grid_instance.name = "PixelGrid"
+ _grid_instance.mesh = immediate
+ _grid_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
+ add_child(_grid_instance)
+ _refresh_grid_visibility()
+
+
+func _add_grid_vertex(immediate: ImmediateMesh, world_point: Vector3) -> void:
+ var sampled: Dictionary = _sample_surface(world_point)
+ var point: Vector3 = sampled["position"]
+ var normal: Vector3 = sampled["normal"]
+ immediate.surface_add_vertex(
+ to_local(point + normal * (_surface_offset() * 1.5))
+ )
+
+
+func _rebuild_pixels() -> void:
+ for instance: MultiMeshInstance3D in _color_meshes.values():
+ instance.queue_free()
+ _color_meshes.clear()
+ var cells_by_color: Dictionary[StringName, Array] = {}
+ for cell: Dictionary in _cells.values():
+ var author_fingerprint: String = str(cell.get("author_fingerprint", ""))
+ if (
+ _relationships != null
+ and _relationships.is_blocked(author_fingerprint)
+ ):
+ continue
+ var color_id := StringName(str(cell.get("color_id", "")))
+ if not SurfaceDrawingPalette.has_color(color_id):
+ continue
+ var color_cells: Array = cells_by_color.get(color_id, [])
+ color_cells.append(cell)
+ cells_by_color[color_id] = color_cells
+ for color_id: StringName in cells_by_color:
+ _create_color_mesh(color_id, cells_by_color[color_id])
+
+
+func _create_color_mesh(color_id: StringName, cells: Array) -> void:
+ if cells.is_empty():
+ return
+ var quad := QuadMesh.new()
+ quad.size = Vector2.ONE
+ var material := StandardMaterial3D.new()
+ material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ material.albedo_color = SurfaceDrawingPalette.get_color(color_id)
+ material.cull_mode = BaseMaterial3D.CULL_DISABLED
+ quad.material = material
+ var multimesh := MultiMesh.new()
+ multimesh.transform_format = MultiMesh.TRANSFORM_3D
+ multimesh.mesh = quad
+ multimesh.instance_count = cells.size()
+ for cell_index: int in range(cells.size()):
+ var cell: Dictionary = cells[cell_index]
+ multimesh.set_instance_transform(
+ cell_index,
+ _sample_cell_transform(
+ int(cell["x"]), int(cell["y"]), _pixel_fill()
+ ),
+ )
+ var instance := MultiMeshInstance3D.new()
+ instance.name = "Pixels_%s" % color_id
+ instance.multimesh = multimesh
+ instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
+ add_child(instance)
+ _color_meshes[color_id] = instance
+
+
+func _sample_cell_transform(
+ x: int,
+ y: int,
+ fill: float = GUIDED_PIXEL_FILL,
+) -> Transform3D:
+ var expected: Vector3 = get_cell_world_position(x, y)
+ var sampled: Dictionary = _sample_surface(expected)
+ var point: Vector3 = sampled["position"]
+ var normal: Vector3 = sampled["normal"]
+ var tangent: Vector3 = (
+ _surface_tangent - normal * _surface_tangent.dot(normal)
+ ).normalized()
+ if tangent.is_zero_approx():
+ tangent = normal.cross(Vector3.UP).normalized()
+ if tangent.is_zero_approx():
+ tangent = Vector3.RIGHT
+ var bitangent: Vector3 = normal.cross(tangent).normalized()
+ var pixel_size: float = cell_size * clampf(fill, 0.05, 1.0)
+ var cell_basis := Basis(
+ tangent * pixel_size,
+ bitangent * pixel_size,
+ normal,
+ )
+ return Transform3D(
+ cell_basis,
+ to_local(point + normal * _surface_offset()),
+ )
+
+
+func _sample_surface(expected: Vector3) -> Dictionary:
+ var point: Vector3 = expected
+ var normal: Vector3 = _surface_normal
+ var world: World3D = get_world_3d()
+ if world != null:
+ var query := PhysicsRayQueryParameters3D.create(
+ expected + _surface_normal * SURFACE_SAMPLE_DEPTH,
+ expected - _surface_normal * SURFACE_SAMPLE_DEPTH,
+ _solid_surface_mask,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ var hit: Dictionary = world.direct_space_state.intersect_ray(query)
+ if (
+ not hit.is_empty()
+ and hit.get("collider") is StaticBody3D
+ ):
+ var hit_normal: Vector3 = hit.get("normal", _surface_normal)
+ if hit_normal.dot(_surface_normal) >= 0.35:
+ var hit_position: Vector3 = hit.get("position", expected)
+ point = hit_position
+ normal = hit_normal.normalized()
+ return {"position": point, "normal": normal}
+
+
+func _pixel_fill() -> float:
+ return GUIDED_PIXEL_FILL if _guide_visible else FINISHED_PIXEL_FILL
+
+
+func _surface_offset() -> float:
+ return SURFACE_OFFSET + float(_layer) * LAYER_OFFSET_STEP
+
+
+func _refresh_grid_visibility() -> void:
+ if _grid_instance != null:
+ _grid_instance.visible = _stencil_requested_visible and _guide_visible
+
+
+func _cell_key(x: int, y: int) -> int:
+ return y * grid_width + x
diff --git a/drawing/surface_drawing_canvas.gd.uid b/drawing/surface_drawing_canvas.gd.uid
new file mode 100644
index 0000000..9da19e3
--- /dev/null
+++ b/drawing/surface_drawing_canvas.gd.uid
@@ -0,0 +1 @@
+uid://cj2gq63l4mysl
diff --git a/drawing/surface_drawing_highlight.gdshader b/drawing/surface_drawing_highlight.gdshader
new file mode 100644
index 0000000..02dae33
--- /dev/null
+++ b/drawing/surface_drawing_highlight.gdshader
@@ -0,0 +1,12 @@
+shader_type spatial;
+render_mode unshaded, cull_disabled, depth_draw_never;
+
+uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_nearest;
+
+
+void fragment() {
+ vec3 surface_color = textureLod(screen_texture, SCREEN_UV, 0.0).rgb;
+ vec3 inverted_color = vec3(1.0) - surface_color;
+ ALBEDO = inverted_color;
+ EMISSION = inverted_color;
+}
diff --git a/drawing/surface_drawing_highlight.gdshader.uid b/drawing/surface_drawing_highlight.gdshader.uid
new file mode 100644
index 0000000..aebbc9f
--- /dev/null
+++ b/drawing/surface_drawing_highlight.gdshader.uid
@@ -0,0 +1 @@
+uid://k82me23ylsos
diff --git a/drawing/surface_drawing_palette.gd b/drawing/surface_drawing_palette.gd
new file mode 100644
index 0000000..c8bfc8e
--- /dev/null
+++ b/drawing/surface_drawing_palette.gd
@@ -0,0 +1,57 @@
+class_name SurfaceDrawingPalette
+extends RefCounted
+
+const DEFAULT_COLOR_ID: StringName = &"chalk_white"
+
+# Stable IDs are the network and future progression boundary. New colors may
+# be appended without changing drawings created with an older palette.
+const COLORS: Array[Dictionary] = [
+ {"id": &"chalk_white", "name": "Chalk white", "color": Color("f5eed9")},
+ {"id": &"ocean_teal", "name": "Ocean teal", "color": Color("35b9c7")},
+ {"id": &"coral", "name": "Coral", "color": Color("ef5b62")},
+ {"id": &"sunny", "name": "Sunny", "color": Color("ffd166")},
+ {"id": &"leaf", "name": "Leaf", "color": Color("46c878")},
+ {"id": &"blue", "name": "Blue", "color": Color("5596f6")},
+ {"id": &"violet", "name": "Violet", "color": Color("b176e8")},
+ {"id": &"charcoal", "name": "Charcoal", "color": Color("28251f")},
+]
+
+
+static func has_color(color_id: StringName) -> bool:
+ return not get_entry(color_id).is_empty()
+
+
+static func get_entry(color_id: StringName) -> Dictionary:
+ for entry: Dictionary in COLORS:
+ if StringName(entry.get("id", &"")) == color_id:
+ return entry
+ return {}
+
+
+static func get_color(color_id: StringName) -> Color:
+ var entry: Dictionary = get_entry(color_id)
+ var value: Variant = entry.get("color", Color.WHITE)
+ return value if typeof(value) == TYPE_COLOR else Color.WHITE
+
+
+static func get_display_name(color_id: StringName) -> String:
+ return str(get_entry(color_id).get("name", "Unknown"))
+
+
+static func get_color_ids() -> Array[StringName]:
+ var result: Array[StringName] = []
+ for entry: Dictionary in COLORS:
+ result.append(StringName(entry.get("id", &"")))
+ return result
+
+
+static func filter_unlocked_ids(
+ unlocked_ids: Array[StringName],
+) -> Array[StringName]:
+ var result: Array[StringName] = []
+ for color_id: StringName in unlocked_ids:
+ if has_color(color_id) and color_id not in result:
+ result.append(color_id)
+ if result.is_empty():
+ result.append(DEFAULT_COLOR_ID)
+ return result
diff --git a/drawing/surface_drawing_palette.gd.uid b/drawing/surface_drawing_palette.gd.uid
new file mode 100644
index 0000000..f6ece03
--- /dev/null
+++ b/drawing/surface_drawing_palette.gd.uid
@@ -0,0 +1 @@
+uid://bk16cl6f3v887
diff --git a/drawing/surface_drawing_placement.gd b/drawing/surface_drawing_placement.gd
new file mode 100644
index 0000000..bfa1abc
--- /dev/null
+++ b/drawing/surface_drawing_placement.gd
@@ -0,0 +1,92 @@
+class_name SurfaceDrawingPlacement
+extends RefCounted
+
+const SNAP_DISTANCE: float = 0.55
+const SNAP_PLANE_DISTANCE: float = 0.18
+const SNAP_NORMAL_DOT: float = 0.94
+
+
+static func resolve(
+ origin: Vector3,
+ normal: Vector3,
+ fallback_tangent: Vector3,
+ canvas_states: Array[Dictionary],
+ requested_grid_size: int = SurfaceDrawingProtocol.DEFAULT_GRID_SIZE,
+) -> Dictionary:
+ var surface_normal: Vector3 = normal.normalized()
+ var surface_tangent: Vector3 = _projected_tangent(
+ fallback_tangent, surface_normal
+ )
+ var best: Dictionary = {
+ "origin": origin,
+ "normal": surface_normal,
+ "tangent": surface_tangent,
+ "snapped": false,
+ }
+ var nearest_distance: float = SNAP_DISTANCE
+ for state: Dictionary in canvas_states:
+ if not SurfaceDrawingProtocol.validate_canvas_state(state):
+ continue
+ var anchor_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["normal"]
+ ).normalized()
+ if anchor_normal.dot(surface_normal) < SNAP_NORMAL_DOT:
+ continue
+ var anchor_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["origin"]
+ )
+ var relative: Vector3 = origin - anchor_origin
+ if absf(relative.dot(anchor_normal)) > SNAP_PLANE_DISTANCE:
+ continue
+ var anchor_tangent: Vector3 = _projected_tangent(
+ SurfaceDrawingProtocol.array_to_vector(state["tangent"]),
+ anchor_normal,
+ )
+ var anchor_bitangent: Vector3 = anchor_normal.cross(
+ anchor_tangent
+ ).normalized()
+ var width: float = float(state["width"]) * float(state["cell_size"])
+ var height: float = float(state["height"]) * float(state["cell_size"])
+ var requested_extent: float = (
+ float(requested_grid_size) * SurfaceDrawingProtocol.CELL_SIZE
+ )
+ if width <= 0.0 or height <= 0.0:
+ continue
+ var horizontal_spacing: float = (width + requested_extent) * 0.5
+ var vertical_spacing: float = (height + requested_extent) * 0.5
+ var horizontal_step: int = roundi(
+ relative.dot(anchor_tangent) / horizontal_spacing
+ )
+ var vertical_step: int = roundi(
+ relative.dot(anchor_bitangent) / vertical_spacing
+ )
+ if horizontal_step == 0 and vertical_step == 0:
+ continue
+ var candidate: Vector3 = (
+ anchor_origin
+ + anchor_tangent * float(horizontal_step) * horizontal_spacing
+ + anchor_bitangent * float(vertical_step) * vertical_spacing
+ )
+ var distance: float = candidate.distance_to(origin)
+ if distance > nearest_distance:
+ continue
+ nearest_distance = distance
+ best = {
+ "origin": candidate,
+ "normal": anchor_normal,
+ "tangent": anchor_tangent,
+ "snapped": true,
+ }
+ return best
+
+
+static func _projected_tangent(
+ value: Vector3,
+ normal: Vector3,
+) -> Vector3:
+ var tangent: Vector3 = (value - normal * value.dot(normal)).normalized()
+ if tangent.is_zero_approx():
+ tangent = Vector3.UP.cross(normal).normalized()
+ if tangent.is_zero_approx():
+ tangent = Vector3.RIGHT
+ return tangent
diff --git a/drawing/surface_drawing_placement.gd.uid b/drawing/surface_drawing_placement.gd.uid
new file mode 100644
index 0000000..9cf47f2
--- /dev/null
+++ b/drawing/surface_drawing_placement.gd.uid
@@ -0,0 +1 @@
+uid://dgb7stq5k85d0
diff --git a/drawing/surface_drawing_protocol.gd b/drawing/surface_drawing_protocol.gd
new file mode 100644
index 0000000..5ca4ae0
--- /dev/null
+++ b/drawing/surface_drawing_protocol.gd
@@ -0,0 +1,257 @@
+class_name SurfaceDrawingProtocol
+extends RefCounted
+
+const CAPABILITY: StringName = &"surface_drawing_v2"
+const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
+const GRID_SIZES: Array[int] = [16, 32, 64, 128]
+const DEFAULT_GRID_SIZE: int = 16
+const MAX_GRID_SIZE: int = 128
+# Compatibility aliases for callers that only need the default dimensions.
+const GRID_WIDTH: int = DEFAULT_GRID_SIZE
+const GRID_HEIGHT: int = DEFAULT_GRID_SIZE
+const CELL_SIZE: float = 0.075
+const MAX_ACTIVE_CANVASES: int = 24
+const MAX_CANVASES: int = 48
+const MAX_SESSION_GRID_CELLS: int = 49152
+const MAX_EDITS_PER_REQUEST: int = 16
+const MAX_CANVAS_ID_LENGTH: int = 64
+const MAX_REQUEST_ID_LENGTH: int = 64
+const MAX_STROKE_ID_LENGTH: int = 64
+const MAX_SESSION_ID_LENGTH: int = 96
+const MAX_COORDINATE: float = 10000.0
+
+
+static func validate_canvas_request(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ _valid_common(value)
+ and _valid_vector(value.get("origin"))
+ and _valid_vector(value.get("normal"))
+ and _valid_vector(value.get("tangent"))
+ and typeof(value.get("width")) == TYPE_INT
+ and int(value["width"]) in GRID_SIZES
+ and typeof(value.get("height")) == TYPE_INT
+ and int(value["height"]) == int(value["width"])
+ and typeof(value.get("cell_size")) in [TYPE_FLOAT, TYPE_INT]
+ and is_equal_approx(float(value["cell_size"]), CELL_SIZE)
+ )
+
+
+static func validate_edit_request(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ if (
+ not _valid_common(value)
+ or typeof(value.get("canvas_id")) != TYPE_STRING
+ or str(value["canvas_id"]).is_empty()
+ or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH
+ or not _valid_stroke_id(value.get("stroke_id"))
+ or typeof(value.get("brush_size")) != TYPE_INT
+ or int(value["brush_size"]) < 1
+ or int(value["brush_size"]) > 4
+ or typeof(value.get("edits")) != TYPE_ARRAY
+ ):
+ return false
+ var edits: Array = value["edits"]
+ if edits.is_empty() or edits.size() > MAX_EDITS_PER_REQUEST:
+ return false
+ for edit_value: Variant in edits:
+ if not validate_cell_edit(edit_value):
+ return false
+ return true
+
+
+static func validate_guide_request(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ _valid_common(value)
+ and _valid_canvas_id(value.get("canvas_id"))
+ and typeof(value.get("guide_visible")) == TYPE_BOOL
+ and typeof(value.get("finalized")) == TYPE_BOOL
+ )
+
+
+static func validate_undo_request(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return _valid_common(value) and _valid_stroke_id(value.get("stroke_id"))
+
+
+static func validate_canvas_state(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ if (
+ typeof(value.get("session_id")) != TYPE_STRING
+ or str(value["session_id"]).is_empty()
+ or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH
+ or typeof(value.get("canvas_id")) != TYPE_STRING
+ or str(value["canvas_id"]).is_empty()
+ or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH
+ or not _valid_vector(value.get("origin"))
+ or not _valid_vector(value.get("normal"))
+ or not _valid_vector(value.get("tangent"))
+ or typeof(value.get("width")) != TYPE_INT
+ or int(value["width"]) not in GRID_SIZES
+ or typeof(value.get("height")) != TYPE_INT
+ or int(value["height"]) != int(value["width"])
+ or typeof(value.get("cell_size")) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_equal_approx(float(value["cell_size"]), CELL_SIZE)
+ or typeof(value.get("revision")) != TYPE_INT
+ or int(value["revision"]) < 0
+ or typeof(value.get("guide_visible", true)) != TYPE_BOOL
+ or typeof(value.get("finalized", false)) != TYPE_BOOL
+ or typeof(value.get("layer", 0)) != TYPE_INT
+ or int(value.get("layer", 0)) < 0
+ or not NetworkIdentityCrypto.valid_fingerprint(
+ value.get("creator_fingerprint")
+ )
+ or typeof(value.get("cells")) != TYPE_ARRAY
+ ):
+ return false
+ var cells: Array = value["cells"]
+ var grid_width: int = int(value["width"])
+ var grid_height: int = int(value["height"])
+ if cells.size() > grid_width * grid_height:
+ return false
+ for cell_value: Variant in cells:
+ if (
+ not validate_authoritative_cell(cell_value)
+ or int((cell_value as Dictionary)["x"]) >= grid_width
+ or int((cell_value as Dictionary)["y"]) >= grid_height
+ ):
+ return false
+ return true
+
+
+static func validate_guide_update(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ typeof(value.get("session_id")) == TYPE_STRING
+ and not str(value["session_id"]).is_empty()
+ and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH
+ and _valid_canvas_id(value.get("canvas_id"))
+ and typeof(value.get("revision")) == TYPE_INT
+ and int(value["revision"]) >= 1
+ and typeof(value.get("guide_visible")) == TYPE_BOOL
+ and typeof(value.get("finalized")) == TYPE_BOOL
+ )
+
+
+static func validate_canvas_update(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ if (
+ typeof(value.get("session_id")) != TYPE_STRING
+ or str(value["session_id"]).is_empty()
+ or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH
+ or typeof(value.get("canvas_id")) != TYPE_STRING
+ or str(value["canvas_id"]).is_empty()
+ or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH
+ or typeof(value.get("revision")) != TYPE_INT
+ or int(value["revision"]) < 1
+ or typeof(value.get("edits")) != TYPE_ARRAY
+ ):
+ return false
+ var edits: Array = value["edits"]
+ if edits.is_empty() or edits.size() > MAX_EDITS_PER_REQUEST:
+ return false
+ for edit_value: Variant in edits:
+ if not validate_authoritative_cell(edit_value, true):
+ return false
+ return true
+
+
+static func validate_cell_edit(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ if (
+ typeof(value.get("x")) != TYPE_INT
+ or int(value["x"]) < 0
+ or int(value["x"]) >= MAX_GRID_SIZE
+ or typeof(value.get("y")) != TYPE_INT
+ or int(value["y"]) < 0
+ or int(value["y"]) >= MAX_GRID_SIZE
+ or typeof(value.get("color_id")) not in [TYPE_STRING, TYPE_STRING_NAME]
+ ):
+ return false
+ var color_id := StringName(str(value["color_id"]))
+ return color_id.is_empty() or SurfaceDrawingPalette.has_color(color_id)
+
+
+static func validate_authoritative_cell(
+ data: Variant,
+ allow_erased: bool = false,
+) -> bool:
+ if not validate_cell_edit(data):
+ return false
+ var value: Dictionary = data
+ var color_id := StringName(str(value["color_id"]))
+ if color_id.is_empty():
+ return allow_erased and str(value.get("author_fingerprint", "")).is_empty()
+ return NetworkIdentityCrypto.valid_fingerprint(
+ value.get("author_fingerprint")
+ )
+
+
+static func vector_to_array(value: Vector3) -> Array[float]:
+ return [value.x, value.y, value.z]
+
+
+static func array_to_vector(value: Variant) -> Vector3:
+ if not _valid_vector(value):
+ return Vector3.ZERO
+ var array: Array = value
+ return Vector3(float(array[0]), float(array[1]), float(array[2]))
+
+
+static func _valid_common(value: Dictionary) -> bool:
+ return (
+ typeof(value.get("request_id")) == TYPE_STRING
+ and not str(value["request_id"]).is_empty()
+ and str(value["request_id"]).length() <= MAX_REQUEST_ID_LENGTH
+ and typeof(value.get("session_id")) == TYPE_STRING
+ and not str(value["session_id"]).is_empty()
+ and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH
+ )
+
+
+static func _valid_canvas_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_CANVAS_ID_LENGTH
+ )
+
+
+static func _valid_stroke_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_STROKE_ID_LENGTH
+ )
+
+
+static func _valid_vector(value: Variant) -> bool:
+ if typeof(value) != TYPE_ARRAY:
+ return false
+ var array: Array = value
+ if array.size() != 3:
+ return false
+ for component: Variant in array:
+ if typeof(component) not in [TYPE_FLOAT, TYPE_INT]:
+ return false
+ var number: float = float(component)
+ if not is_finite(number) or absf(number) > MAX_COORDINATE:
+ return false
+ return true
diff --git a/drawing/surface_drawing_protocol.gd.uid b/drawing/surface_drawing_protocol.gd.uid
new file mode 100644
index 0000000..9c45515
--- /dev/null
+++ b/drawing/surface_drawing_protocol.gd.uid
@@ -0,0 +1 @@
+uid://c258sivd8k8nc
diff --git a/economy/art_shop_stock.gd b/economy/art_shop_stock.gd
new file mode 100644
index 0000000..acc5382
--- /dev/null
+++ b/economy/art_shop_stock.gd
@@ -0,0 +1,64 @@
+class_name ArtShopStock
+extends RefCounted
+
+const ART_KIT_ITEM_ID: StringName = &"art_kit"
+const ART_KIT_PRICE: int = 50
+const UPGRADE_PRICE: int = 50
+
+const MARKER_PRODUCTS: Array[StringName] = [
+ &"marker_ocean_teal",
+ &"marker_coral",
+ &"marker_sunny",
+ &"marker_leaf",
+ &"marker_blue",
+ &"marker_violet",
+ &"marker_charcoal",
+]
+const BRUSH_PRODUCTS: Array[StringName] = [
+ &"brush_2x",
+ &"brush_3x",
+ &"brush_4x",
+]
+const GRID_PRODUCTS: Array[StringName] = [
+ &"grid_32x",
+ &"grid_64x",
+ &"grid_128x",
+]
+
+
+static func get_price(product_id: StringName) -> int:
+ if product_id == ART_KIT_ITEM_ID:
+ return ART_KIT_PRICE
+ return UPGRADE_PRICE if PlayerArtUnlocks.is_product_id(product_id) else -1
+
+
+static func get_display_name(product_id: StringName) -> String:
+ if product_id == ART_KIT_ITEM_ID:
+ return "Art Kit"
+ var color_id: StringName = PlayerArtUnlocks.color_id_for_product(product_id)
+ if not color_id.is_empty():
+ return "%s marker" % SurfaceDrawingPalette.get_display_name(color_id)
+ var brush_size: int = PlayerArtUnlocks.brush_size_for_product(product_id)
+ if brush_size > 0:
+ return "%d× brush" % brush_size
+ var grid_size: int = PlayerArtUnlocks.grid_size_for_product(product_id)
+ if grid_size > 0:
+ return "%d×%d grid" % [grid_size, grid_size]
+ return "Unknown art supply"
+
+
+static func get_description(product_id: StringName) -> String:
+ if product_id == ART_KIT_ITEM_ID:
+ return "Press P in the game world to paint!"
+ var color_id: StringName = PlayerArtUnlocks.color_id_for_product(product_id)
+ if not color_id.is_empty():
+ return "Unlocks this marker color in the Paint UI."
+ var brush_size: int = PlayerArtUnlocks.brush_size_for_product(product_id)
+ if brush_size > 0:
+ return "Unlocks the %d× brush in the Paint UI." % brush_size
+ var grid_size: int = PlayerArtUnlocks.grid_size_for_product(product_id)
+ if grid_size > 0:
+ return "Unlocks the %d×%d grid in the Paint UI." % [
+ grid_size, grid_size,
+ ]
+ return ""
diff --git a/economy/art_shop_stock.gd.uid b/economy/art_shop_stock.gd.uid
new file mode 100644
index 0000000..3358b54
--- /dev/null
+++ b/economy/art_shop_stock.gd.uid
@@ -0,0 +1 @@
+uid://cqagg27unh12c
diff --git a/economy/buyers/pelicans.tres b/economy/buyers/pelicans.tres
index 7dce4ec..f1a4807 100644
--- a/economy/buyers/pelicans.tres
+++ b/economy/buyers/pelicans.tres
@@ -9,4 +9,4 @@ display_name = "pelicans"
animal_name_singular = "pelican"
animal_name_plural = "pelicans"
payout_multiplier = 0.25
-sale_message_template = "You sold your {fish} to the pelicans for ${payout}."
+sale_message_template = "You sold your {fish} to the pelicans."
diff --git a/economy/fish_buyer_profile.gd b/economy/fish_buyer_profile.gd
index bca1cea..9bb59de 100644
--- a/economy/fish_buyer_profile.gd
+++ b/economy/fish_buyer_profile.gd
@@ -1,6 +1,8 @@
class_name FishBuyerProfile
extends Resource
+const FishQualityType = preload("res://fish/fish_quality.gd")
+
@export var id: StringName
@export var display_name: String
@export var animal_name_singular: String
@@ -25,6 +27,18 @@ func get_offer(base_value: int) -> int:
return maxi(offer, 0)
+func get_quality_offer(base_value: int, quality: int) -> int:
+ if not FishQualityType.is_valid(quality):
+ return -1
+ var ordinary_offer: int = get_offer(base_value)
+ var adjusted_offer: int = get_offer(
+ FishQualityType.apply_sale_value(base_value, quality)
+ )
+ if ordinary_offer < 0 or adjusted_offer < 0:
+ return -1
+ return maxi(adjusted_offer, ordinary_offer + quality)
+
+
func get_sale_message(
fish_name: String,
payout: int,
@@ -39,8 +53,7 @@ func get_sale_message(
var buyer_name: String = animal_name_plural
if buyer_name.is_empty():
buyer_name = display_name
- return "you sold your %s to the %s for $%d." % [
+ return "you sold your %s to the %s." % [
fish_name,
buyer_name,
- payout,
]
diff --git a/economy/fish_sale_result.gd b/economy/fish_sale_result.gd
index e133245..74aa72b 100644
--- a/economy/fish_sale_result.gd
+++ b/economy/fish_sale_result.gd
@@ -9,6 +9,7 @@ enum Status {
INVALID_BUYER,
INVALID_OFFER,
INVALID_SELECTION,
+ RESERVED,
TRANSACTION_FAILED,
}
@@ -46,5 +47,7 @@ func get_message() -> String:
return "invalid buyer offer."
Status.INVALID_SELECTION:
return "the fish selection is invalid."
+ Status.RESERVED:
+ return "reserved fish cannot be sold."
_:
return "transaction failed."
diff --git a/economy/fish_sale_service.gd b/economy/fish_sale_service.gd
index b5b81b2..6aca45d 100644
--- a/economy/fish_sale_service.gd
+++ b/economy/fish_sale_service.gd
@@ -11,14 +11,17 @@ signal sale_completed(result: FishSaleResultType)
var _inventory: FishInventoryType
var _wallet: PlayerWalletType
+var _reservations: PlayerAssetReservationService
func setup(
inventory: FishInventoryType,
wallet: PlayerWalletType,
+ reservations: PlayerAssetReservationService = null,
) -> void:
_inventory = inventory
_wallet = wallet
+ _reservations = reservations
func can_sell(
@@ -114,12 +117,26 @@ func _validate_batch(
if fish_catch == null:
result.status = FishSaleResultType.Status.NOT_FOUND
return result
+ if (
+ _reservations != null
+ and _reservations.is_fish_reserved(catch_id)
+ ):
+ result.status = FishSaleResultType.Status.RESERVED
+ return result
if fish_catch.is_favorited:
contains_favorite = true
if fish_catch.sale_value < 0:
result.status = FishSaleResultType.Status.INVALID_VALUE
return result
- var offer: int = buyer.get_offer(fish_catch.sale_value)
+ var ordinary_value: int = (
+ fish_catch.fish.get_sale_value_for_weight(
+ fish_catch.weight_lb
+ )
+ )
+ var offer: int = buyer.get_quality_offer(
+ ordinary_value,
+ fish_catch.quality,
+ )
if offer < 0:
result.status = FishSaleResultType.Status.INVALID_OFFER
return result
@@ -148,9 +165,10 @@ func _validate_batch(
result.payout
)
else:
- result.sale_message = "you sold %d fish to the %s for $%d." % [
+ result.sale_message = (
+ "you sold %d fish to the %s." % [
result.fish_count,
result.buyer_animal_name,
- result.payout,
- ]
+ ]
+ )
return result
diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd
index 3d5e867..87795ad 100644
--- a/economy/fishing_shop_stock.gd
+++ b/economy/fishing_shop_stock.gd
@@ -5,18 +5,47 @@ const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
+const WORM_MAX_STACK: int = 10
+const FISH_FINDER_ID: StringName = &"fish_finder"
+const BATTERIES_ID: StringName = &"batteries"
const ITEM_PRICES: Dictionary[StringName, int] = {
+ &"worms": 1,
+ &"snails": 3,
+ &"shrimp": 7,
+ &"squid_chunks": 15,
+ &"whole_anchovy": 60,
+ &"whole_sardine": 30,
+ &"luminous_roe": 125,
+ &"the_standby": 750,
&"coffee": 20,
&"energy_drink": 35,
&"snack": 30,
- &"fish_finder": 60,
+ FISH_FINDER_ID: 500,
+ BATTERIES_ID: 15,
+}
+const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = {
+ &"snails": 400,
+ &"shrimp": 1200,
+ &"squid_chunks": 3500,
+ &"whole_anchovy": 22000,
+ &"whole_sardine": 9000,
+ &"luminous_roe": 50000,
}
const ITEM_ORDER: Array[StringName] = [
+ &"worms",
+ &"snails",
+ &"shrimp",
+ &"squid_chunks",
+ &"whole_sardine",
+ &"whole_anchovy",
+ &"luminous_roe",
+ &"the_standby",
+ FISH_FINDER_ID,
&"coffee",
&"energy_drink",
&"snack",
- &"fish_finder",
+ BATTERIES_ID,
]
@@ -28,6 +57,51 @@ static func get_stock_item_ids() -> Array[StringName]:
return ITEM_ORDER.duplicate()
+static func get_unlock_price(item_id: StringName) -> int:
+ return BAIT_UNLOCK_PRICES.get(item_id, -1)
+
+
+static func get_purchase_quantity(
+ item_id: StringName,
+ owned: int,
+ maximum_stack: int = WORM_MAX_STACK,
+) -> int:
+ if is_bait_topoff(item_id):
+ return maxi(maximum_stack - owned, 0)
+ return 1
+
+
+static func is_bait_topoff(item_id: StringName) -> bool:
+ return item_id == &"worms" or BAIT_UNLOCK_PRICES.has(item_id)
+
+
+static func is_permanent_unlock(
+ item_id: StringName,
+ item: ItemDataType,
+) -> bool:
+ return (
+ item != null
+ and (item.is_lure() or item_id == FISH_FINDER_ID)
+ )
+
+
+static func is_passive_supply(item_id: StringName) -> bool:
+ return item_id == BATTERIES_ID
+
+
+static func get_purchase_cost(
+ item_id: StringName,
+ quantity: int,
+ bait_unlocked: bool,
+) -> int:
+ if quantity <= 0:
+ return -1
+ if is_bait_topoff(item_id) and item_id != &"worms" and not bait_unlocked:
+ return get_unlock_price(item_id)
+ var unit_price: int = get_price(item_id)
+ return unit_price * quantity if unit_price >= 0 else -1
+
+
static func purchase_one(
item_id: StringName,
wallet: PlayerWalletType,
@@ -36,22 +110,42 @@ static func purchase_one(
) -> bool:
if wallet == null or bag == null or catalog == null:
return false
- var price: int = get_price(item_id)
var item: ItemDataType = catalog.get_item_by_id(item_id)
+ var bait_topoff: bool = is_bait_topoff(item_id)
+ var permanent_unlock: bool = is_permanent_unlock(item_id, item)
+ var quantity: int = get_purchase_quantity(
+ item_id,
+ bag.get_quantity(item_id),
+ item.max_stack if item != null else WORM_MAX_STACK,
+ )
+ var price: int = get_purchase_cost(
+ item_id,
+ quantity,
+ bag.is_bait_unlocked(item_id),
+ )
if (
price < 0
or item == null
or not item.is_valid()
- or item.category != ItemDataType.Category.CONSUMABLE
- or not item.stackable
- or not item.usable
- or not bag.can_add_item(item_id, 1)
+ or (
+ item.category != ItemDataType.Category.CONSUMABLE
+ and not bait_topoff
+ and not permanent_unlock
+ )
+ or (not item.stackable and not permanent_unlock)
+ or (
+ not item.usable
+ and not bait_topoff
+ and not permanent_unlock
+ and not is_passive_supply(item_id)
+ )
+ or not bag.can_add_item(item_id, quantity)
or not wallet.can_afford(price)
):
return false
if not wallet.debit(price):
return false
- if bag.add_item(item_id, 1):
+ if bag.add_item(item_id, quantity):
return true
if not wallet.credit(price):
push_error("Fishing Shop failed to roll back an item purchase.")
diff --git a/export_presets.cfg b/export_presets.cfg
index 6386c22..bbcdec2 100644
--- a/export_presets.cfg
+++ b/export_presets.cfg
@@ -7,9 +7,9 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
-include_filter=""
-exclude_filter="builds/*,playtest/*,scripts/*"
-export_path="builds/playtest-0.1/windows-x86_64/netfishing.exe"
+include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
+exclude_filter="builds/*,scripts/*"
+export_path="builds/v0.7.1-alpha/windows-x86_64/NETfishing.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@@ -32,16 +32,16 @@ codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
-application/icon=""
+application/icon="res://art/exported/system_icons/netfishing.ico"
application/console_wrapper_icon=""
application/icon_interpolation=4
-application/file_version="0.1.0.0"
-application/product_version="0.1.0.0"
-application/company_name=""
-application/product_name="NETFISHING"
-application/file_description="NETFISHING Pre-Alpha Playtest 0.1"
-application/copyright=""
-application/trademarks=""
+application/file_version="0.7.1.0"
+application/product_version="0.7.1.0"
+application/company_name="Woofmeow"
+application/product_name="NETfishing"
+application/file_description="NETfishing v0.7.1-alpha"
+application/copyright="Copyright © 2026 Woofmeow"
+application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md"
application/export_angle=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
@@ -51,6 +51,158 @@ ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="#!/usr/bin/env bash\nexport DISPLAY=:0\nunzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"\n\"{temp_dir}/{exe_name}\" {cmd_args}"
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash\nkill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\")\nrm -rf \"{temp_dir}\""
+[preset.3]
+
+name="Linux ARM64"
+platform="Linux"
+runnable=false
+advanced_options=false
+dedicated_server=false
+custom_features=""
+export_filter="all_resources"
+include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
+exclude_filter="builds/*,scripts/*"
+export_path="builds/v0.7.1-alpha/linux-arm64/NETfishing.arm64"
+patches=PackedStringArray()
+encryption_include_filters=""
+encryption_exclude_filters=""
+seed=0
+encrypt_pck=false
+encrypt_directory=false
+script_export_mode=2
+
+[preset.3.options]
+
+custom_template/debug=""
+custom_template/release=""
+debug/export_console_wrapper=0
+binary_format/embed_pck=false
+binary_format/architecture="arm64"
+texture_format/s3tc_bptc=true
+texture_format/etc2_astc=false
+
+[preset.4]
+
+name="macOS"
+platform="macOS"
+runnable=false
+advanced_options=false
+dedicated_server=false
+custom_features=""
+export_filter="all_resources"
+include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
+exclude_filter="builds/*,scripts/*"
+export_path="builds/v0.7.1-alpha/macos/NETfishing.zip"
+patches=PackedStringArray()
+encryption_include_filters=""
+encryption_exclude_filters=""
+seed=0
+encrypt_pck=false
+encrypt_directory=false
+script_export_mode=2
+
+[preset.4.options]
+
+custom_template/debug=""
+custom_template/release=""
+application/bundle_identifier="io.woofmeow.netfishing"
+application/icon="res://art/exported/system_icons/netfishing_1024.png"
+application/icon_interpolation=0
+application/short_version="0.7.1"
+application/version="0.7.1"
+application/architecture="universal"
+codesign/enable=false
+notarization/enable=false
+
+[preset.5]
+
+name="Linux Dedicated Server"
+platform="Linux"
+runnable=false
+advanced_options=false
+dedicated_server=true
+custom_features=""
+export_filter="all_resources"
+include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
+exclude_filter="builds/*,scripts/*,tests/*"
+export_path="builds/v0.7.1-alpha/server-linux-x86_64/NETfishingServer.x86_64"
+patches=PackedStringArray()
+encryption_include_filters=""
+encryption_exclude_filters=""
+seed=0
+encrypt_pck=false
+encrypt_directory=false
+script_export_mode=2
+
+[preset.5.options]
+
+custom_template/debug=""
+custom_template/release=""
+debug/export_console_wrapper=0
+binary_format/embed_pck=false
+binary_format/architecture="x86_64"
+texture_format/s3tc_bptc=true
+texture_format/etc2_astc=false
+
+[preset.2]
+
+name="Android"
+platform="Android"
+runnable=true
+advanced_options=false
+dedicated_server=false
+custom_features=""
+export_filter="all_resources"
+include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
+exclude_filter="builds/*,scripts/*"
+export_path="builds/v0.7.1-alpha/android/NETfishing.apk"
+patches=PackedStringArray()
+encryption_include_filters=""
+encryption_exclude_filters=""
+seed=0
+encrypt_pck=false
+encrypt_directory=false
+script_export_mode=2
+
+[preset.2.options]
+
+custom_template/debug=""
+custom_template/release=""
+gradle_build/use_gradle_build=false
+gradle_build/export_format=0
+gradle_build/min_sdk=""
+gradle_build/target_sdk=""
+architectures/armeabi-v7a=false
+architectures/arm64-v8a=true
+architectures/x86=false
+architectures/x86_64=false
+version/code=70100
+version/name="v0.7.1-alpha"
+package/unique_name="io.woofmeow.netfishing"
+package/name="NETfishing"
+package/signed=true
+package/app_category=0
+package/retain_data_on_uninstall=false
+package/exclude_from_recents=false
+package/show_in_android_tv=false
+package/show_in_app_library=true
+package/show_as_launcher_app=true
+launcher_icons/main_192x192="res://art/exported/system_icons/netfishing_256.png"
+graphics/opengl_debug=false
+screen/immersive_mode=true
+screen/edge_to_edge=false
+screen/support_small=false
+screen/support_normal=true
+screen/support_large=true
+screen/support_xlarge=true
+user_data_backup/allow=false
+gesture/swipe_to_dismiss=false
+permissions/internet=true
+permissions/access_network_state=true
+permissions/custom_permissions=PackedStringArray()
+texture_format/s3tc_bptc=false
+texture_format/etc2_astc=true
+
[preset.1]
name="Linux Desktop"
@@ -60,9 +212,9 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
-include_filter=""
-exclude_filter="builds/*,playtest/*,scripts/*"
-export_path="builds/playtest-0.1/linux-x86_64/netfishing"
+include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
+exclude_filter="builds/*,scripts/*"
+export_path="builds/v0.7.1-alpha/linux-x86_64/NETfishing.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
diff --git a/fish/day_availability.tres b/fish/day_availability.tres
new file mode 100644
index 0000000..6cf8205
--- /dev/null
+++ b/fish/day_availability.tres
@@ -0,0 +1,10 @@
+[gd_resource type="Resource" load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://fish/fish_availability.gd" id="1_availability"]
+
+[resource]
+script = ExtResource("1_availability")
+allow_day = true
+allow_night = false
+preferred_bait_tags = Array[StringName]([&"worm"])
+preferred_bait_weight_multiplier = 1.35
diff --git a/fish/fish_availability.gd b/fish/fish_availability.gd
index f593d75..f46a973 100644
--- a/fish/fish_availability.gd
+++ b/fish/fish_availability.gd
@@ -10,22 +10,35 @@ const FishingContextType = preload("res://fishing/fishing_context.gd")
@export var allow_night: bool = true
@export var require_rain: bool = false
@export var forbid_rain: bool = false
+@export var require_fog: bool = false
+@export var forbid_fog: bool = false
@export var required_bait_tags: Array[StringName] = []
@export var preferred_bait_tags: Array[StringName] = []
@export_range(0.01, 10.0, 0.01) var preferred_bait_weight_multiplier: float = 1.25
func is_available(context: FishingContextType) -> bool:
- if context == null or (require_rain and forbid_rain):
+ if (
+ context == null
+ or (require_rain and forbid_rain)
+ or (require_fog and forbid_fog)
+ ):
return false
- if context.is_night and not allow_night:
+ if context.is_day_night_transition:
+ if not allow_day and not allow_night:
+ return false
+ elif context.is_night and not allow_night:
return false
- if not context.is_night and not allow_day:
+ elif not context.is_night and not allow_day:
return false
if require_rain and not context.is_raining:
return false
if forbid_rain and context.is_raining:
return false
+ if require_fog and not context.is_foggy:
+ return false
+ if forbid_fog and context.is_foggy:
+ return false
if (
not allowed_location_tags.is_empty()
and not _has_any_match(allowed_location_tags, context.location_tags)
diff --git a/fish/fish_catch.gd b/fish/fish_catch.gd
index 318e2bb..a0d3738 100644
--- a/fish/fish_catch.gd
+++ b/fish/fish_catch.gd
@@ -2,6 +2,7 @@ class_name FishCatch
extends Resource
const FishDataType = preload("res://fish/fish_data.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
const MAX_SAFE_WEIGHT_LB: float = 1000000.0
const MAX_SAFE_DISPLAY_SCALE: float = 10000.0
@@ -14,6 +15,7 @@ const MAX_SAFE_SEQUENCE: int = 2147483647
@export var catch_sequence: int = 0
@export var weight_lb: float = 0.0
@export var display_scale: float = 1.0
+@export_range(0, 4, 1) var quality: int = FishQualityType.Tier.BORING
@export var sale_value: int = 0
@export var is_favorited: bool = false
@@ -48,6 +50,7 @@ func is_valid() -> bool:
and is_finite(display_scale)
and display_scale > 0.0
and display_scale <= MAX_SAFE_DISPLAY_SCALE
+ and FishQualityType.is_valid(quality)
and sale_value >= 0
and sale_value <= MAX_SAFE_SALE_VALUE
)
@@ -60,6 +63,19 @@ func to_save_dict() -> Dictionary:
"fish_id": String(fish_id),
"weight_lb": weight_lb,
"display_scale": display_scale,
+ "quality": quality,
+ "sale_value": sale_value,
+ "is_favorited": is_favorited,
+ }
+
+
+func to_network_dict() -> Dictionary:
+ return {
+ "catch_id": String(catch_id),
+ "fish_id": String(fish_id),
+ "weight_lb": weight_lb,
+ "display_scale": display_scale,
+ "quality": quality,
"sale_value": sale_value,
"is_favorited": is_favorited,
}
@@ -77,6 +93,7 @@ static func from_save_dict(
"fish_id",
"weight_lb",
"display_scale",
+ "quality",
"sale_value",
"is_favorited",
]:
@@ -100,12 +117,17 @@ static func from_save_dict(
-1,
MAX_SAFE_SALE_VALUE
)
+ var loaded_quality: int = _read_safe_integer(
+ data["quality"],
+ -1,
+ FishQualityType.TIER_COUNT - 1,
+ )
var loaded_weight: float = _read_safe_float(
data["weight_lb"],
0.0,
MAX_SAFE_WEIGHT_LB
)
- var loaded_scale: float = _read_safe_float(
+ var _loaded_scale: float = _read_safe_float(
data["display_scale"],
0.0,
MAX_SAFE_DISPLAY_SCALE
@@ -117,8 +139,9 @@ static func from_save_dict(
or loaded_sequence <= 0
or loaded_sequence >= MAX_SAFE_SEQUENCE
or loaded_sale_value < 0
+ or not FishQualityType.is_valid(loaded_quality)
or loaded_weight <= 0.0
- or loaded_scale <= 0.0
+ or _loaded_scale <= 0.0
or typeof(loaded_favorite) != TYPE_BOOL
):
return null
@@ -129,12 +152,75 @@ static func from_save_dict(
fish_catch.catch_id = loaded_catch_id
fish_catch.catch_sequence = loaded_sequence
fish_catch.weight_lb = loaded_weight
- fish_catch.display_scale = loaded_scale
+ # Recompute presentation size from authoritative weight. This keeps saves
+ # written before the weight-based sizing rule from retaining oversized
+ # per-species display values.
+ fish_catch.display_scale = resolved_fish.get_display_scale_for_weight(
+ loaded_weight
+ )
+ fish_catch.quality = loaded_quality
fish_catch.sale_value = loaded_sale_value
fish_catch.is_favorited = bool(loaded_favorite)
return fish_catch if fish_catch.is_valid() else null
+static func from_network_dict(
+ data: Dictionary,
+ resolved_fish: FishDataType,
+) -> FishCatch:
+ if resolved_fish == null:
+ return null
+ for required_key: String in [
+ "catch_id",
+ "fish_id",
+ "weight_lb",
+ "display_scale",
+ "quality",
+ "sale_value",
+ ]:
+ if not data.has(required_key):
+ return null
+ var loaded_id := StringName(str(data.get("catch_id", "")))
+ var loaded_fish_id := StringName(str(data.get("fish_id", "")))
+ var loaded_weight: float = _read_safe_float(
+ data.get("weight_lb"), 0.0, MAX_SAFE_WEIGHT_LB
+ )
+ var _loaded_scale: float = _read_safe_float(
+ data.get("display_scale"), 0.0, MAX_SAFE_DISPLAY_SCALE
+ )
+ var loaded_value: int = _read_safe_integer(
+ data.get("sale_value"), -1, MAX_SAFE_SALE_VALUE
+ )
+ var loaded_quality: int = _read_safe_integer(
+ data.get("quality"),
+ -1,
+ FishQualityType.TIER_COUNT - 1,
+ )
+ if (
+ loaded_id.is_empty()
+ or loaded_id.length() > 160
+ or loaded_fish_id != resolved_fish.id
+ or loaded_weight <= 0.0
+ or _loaded_scale <= 0.0
+ or loaded_value < 0
+ or not FishQualityType.is_valid(loaded_quality)
+ ):
+ return null
+ var fish_catch := FishCatch.new()
+ fish_catch.fish = resolved_fish
+ fish_catch.fish_id = loaded_fish_id
+ fish_catch.catch_id = loaded_id
+ fish_catch.catch_sequence = 0
+ fish_catch.weight_lb = loaded_weight
+ fish_catch.display_scale = resolved_fish.get_display_scale_for_weight(
+ loaded_weight
+ )
+ fish_catch.quality = loaded_quality
+ fish_catch.sale_value = loaded_value
+ fish_catch.is_favorited = false
+ return fish_catch if fish_catch.is_valid() else null
+
+
static func _read_safe_integer(
value: Variant,
invalid_value: int,
diff --git a/fish/fish_data.gd b/fish/fish_data.gd
index aa73d2a..2bb99af 100644
--- a/fish/fish_data.gd
+++ b/fish/fish_data.gd
@@ -1,6 +1,16 @@
class_name FishData
extends Resource
+# Fish are displayed by their physical weight, rather than by per-species
+# texture or resource dimensions. A cube-root curve approximates the way a
+# fish's linear dimensions grow with mass while keeping large catches readable
+# beside the player.
+const DISPLAY_REFERENCE_WEIGHT_LB: float = 1.0
+const DISPLAY_REFERENCE_SCALE: float = 0.85
+const DISPLAY_WEIGHT_EXPONENT: float = 0.33333334
+const DISPLAY_MIN_SCALE: float = 0.45
+const DISPLAY_MAX_SCALE: float = 3.0
+
const CatchDifficultyProfileType = preload(
"res://fishing/catch_difficulty_profile.gd"
)
@@ -16,12 +26,16 @@ enum Rarity {
@export var id: StringName
@export var display_name: String
+@export_flags("Fresh Water", "Salt Water")
+var allowed_water_types: int = WaterType.ALL_FISHABLE_MASK
@export var rarity: Rarity = Rarity.COMMON
@export_range(0.0, 1000.0, 0.01) var base_catch_weight: float = 1.0
@export var catch_profile: CatchDifficultyProfileType
@export var availability: FishAvailabilityType
@export_range(0.01, 1000.0, 0.01) var weight_min_lb: float = 0.5
@export_range(0.01, 1000.0, 0.01) var weight_max_lb: float = 1.0
+# Retained in resource files for compatibility with existing authored data;
+# runtime presentation is derived from absolute weight below.
@export_range(0.01, 20.0, 0.01) var display_scale_min: float = 0.8
@export_range(0.01, 20.0, 0.01) var display_scale_max: float = 1.2
@export_range(0.01, 10.0, 0.01) var display_scale_curve: float = 1.0
@@ -47,6 +61,18 @@ func is_selectable() -> bool:
)
+func is_allowed_in_water(type: WaterType.Type) -> bool:
+ return (allowed_water_types & WaterType.mask_for(type)) != 0
+
+
+func get_primary_water_type() -> WaterType.Type:
+ if allowed_water_types & WaterType.FRESH_WATER_MASK:
+ return WaterType.Type.FRESH_WATER
+ if allowed_water_types & WaterType.SALT_WATER_MASK:
+ return WaterType.Type.SALT_WATER
+ return WaterType.Type.OTHER
+
+
func get_minimum_weight() -> float:
return maxf(weight_min_lb, 0.01)
@@ -55,23 +81,26 @@ func get_maximum_weight() -> float:
return maxf(weight_max_lb, get_minimum_weight())
-func get_display_scale_for_weight(weight_lb: float) -> float:
+func get_weight_percentile(weight_lb: float) -> float:
var minimum_weight: float = get_minimum_weight()
var maximum_weight: float = get_maximum_weight()
- var normalized_weight: float = 0.0
- if maximum_weight > minimum_weight:
- normalized_weight = inverse_lerp(
- minimum_weight,
- maximum_weight,
- weight_lb
- )
- normalized_weight = pow(
- clampf(normalized_weight, 0.0, 1.0),
- maxf(display_scale_curve, 0.01)
+ if maximum_weight <= minimum_weight:
+ return 0.0
+ return clampf(
+ inverse_lerp(minimum_weight, maximum_weight, weight_lb),
+ 0.0,
+ 1.0,
)
- var minimum_scale: float = maxf(display_scale_min, 0.01)
- var maximum_scale: float = maxf(display_scale_max, minimum_scale)
- return lerpf(minimum_scale, maximum_scale, normalized_weight)
+
+
+func get_display_scale_for_weight(weight_lb: float) -> float:
+ var safe_weight_lb: float = maxf(weight_lb, 0.01)
+ var weight_ratio: float = safe_weight_lb / DISPLAY_REFERENCE_WEIGHT_LB
+ var weight_scale: float = DISPLAY_REFERENCE_SCALE * pow(
+ weight_ratio,
+ DISPLAY_WEIGHT_EXPONENT,
+ )
+ return clampf(weight_scale, DISPLAY_MIN_SCALE, DISPLAY_MAX_SCALE)
func get_sale_value_for_weight(weight_lb: float) -> int:
diff --git a/fish/fish_experience.gd b/fish/fish_experience.gd
new file mode 100644
index 0000000..592042f
--- /dev/null
+++ b/fish/fish_experience.gd
@@ -0,0 +1,73 @@
+class_name FishExperience
+extends RefCounted
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const CollectionLogType = preload("res://collection/collection_log.gd")
+
+const RARITY_BASE_EXPERIENCE: Array[int] = [10, 18, 32, 55, 90]
+const QUALITY_MULTIPLIERS: Array[float] = [1.0, 1.15, 1.4, 1.8, 2.5]
+const MAXIMUM_WEIGHT_BONUS: float = 0.25
+const FIRST_SPECIES_BONUS: int = 25
+const FIRST_QUALITY_BONUS: int = 15
+const SPECIES_MASTERY_BONUS: int = 100
+
+
+static func calculate_for_collection(
+ fish_catch: FishCatchType,
+ collection_log: CollectionLogType,
+) -> int:
+ if fish_catch == null or collection_log == null:
+ return 0
+ return calculate_catch_experience(
+ fish_catch,
+ collection_log.has_discovered(fish_catch.fish_id),
+ collection_log.get_quality_mask(fish_catch.fish_id),
+ )
+
+
+static func calculate_catch_experience(
+ fish_catch: FishCatchType,
+ was_species_discovered: bool,
+ previous_quality_mask: int,
+) -> int:
+ if fish_catch == null or not fish_catch.is_valid():
+ return 0
+ var rarity: int = int(fish_catch.fish.rarity)
+ if rarity < 0 or rarity >= RARITY_BASE_EXPERIENCE.size():
+ return 0
+ if not FishQualityType.is_valid(fish_catch.quality):
+ return 0
+ var weight_percentile: float = _get_weight_percentile(fish_catch)
+ var weight_multiplier: float = (
+ 1.0 + MAXIMUM_WEIGHT_BONUS * weight_percentile * weight_percentile
+ )
+ var catch_experience: int = roundi(
+ float(RARITY_BASE_EXPERIENCE[rarity])
+ * QUALITY_MULTIPLIERS[fish_catch.quality]
+ * weight_multiplier
+ )
+ var quality_bit: int = FishQualityType.bit_for(fish_catch.quality)
+ if not was_species_discovered:
+ catch_experience += FIRST_SPECIES_BONUS
+ if (previous_quality_mask & quality_bit) == 0:
+ catch_experience += FIRST_QUALITY_BONUS
+ var next_quality_mask: int = previous_quality_mask | quality_bit
+ if (
+ previous_quality_mask != FishQualityType.ALL_TIERS_MASK
+ and next_quality_mask == FishQualityType.ALL_TIERS_MASK
+ ):
+ catch_experience += SPECIES_MASTERY_BONUS
+ return maxi(catch_experience, 0)
+
+
+static func _get_weight_percentile(fish_catch: FishCatchType) -> float:
+ var minimum_weight: float = fish_catch.fish.get_minimum_weight()
+ var maximum_weight: float = fish_catch.fish.get_maximum_weight()
+ if maximum_weight <= minimum_weight:
+ return 0.0
+ return clampf(
+ inverse_lerp(minimum_weight, maximum_weight, fish_catch.weight_lb),
+ 0.0,
+ 1.0,
+ )
diff --git a/fish/fish_experience.gd.uid b/fish/fish_experience.gd.uid
new file mode 100644
index 0000000..aafa145
--- /dev/null
+++ b/fish/fish_experience.gd.uid
@@ -0,0 +1 @@
+uid://d05mwat7tju1b
diff --git a/fish/fish_quality.gd b/fish/fish_quality.gd
new file mode 100644
index 0000000..61534e0
--- /dev/null
+++ b/fish/fish_quality.gd
@@ -0,0 +1,175 @@
+class_name FishQuality
+extends RefCounted
+
+enum Tier {
+ BORING,
+ AVERAGE,
+ IMPRESSIVE,
+ EXCEPTIONAL,
+ SHINY,
+}
+
+const TIER_COUNT: int = 5
+const ALL_TIERS_MASK: int = (1 << TIER_COUNT) - 1
+
+# Starting distribution. Equipment and tackle can supply per-tier
+# multipliers later without changing catch serialization or tier identity.
+const BASE_ROLL_WEIGHTS: Array[float] = [40.0, 32.0, 18.0, 8.0, 2.0]
+const WORM_ROLL_WEIGHTS: Array[float] = [80.0, 10.0, 5.0, 4.0, 1.0]
+const SNAIL_ROLL_WEIGHTS: Array[float] = [68.0, 16.0, 8.0, 6.0, 2.0]
+const SHRIMP_ROLL_WEIGHTS: Array[float] = [56.0, 21.0, 12.0, 8.0, 3.0]
+const SQUID_ROLL_WEIGHTS: Array[float] = [44.0, 25.0, 16.0, 11.0, 4.0]
+const MINNOW_ROLL_WEIGHTS: Array[float] = [34.0, 27.0, 20.0, 13.0, 6.0]
+const SARDINE_ROLL_WEIGHTS: Array[float] = [25.0, 28.0, 23.0, 16.0, 8.0]
+const LUMINOUS_ROE_ROLL_WEIGHTS: Array[float] = [16.0, 26.0, 26.0, 20.0, 12.0]
+const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0]
+# Legacy multiplier helpers remain available for compatibility. New encounters
+# roll directly within the authored quality bands below.
+const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25]
+# Inclusive per-barrier health bands authored for each quality tier. Encounter
+# generation rolls uniformly across the selected tier's complete band.
+const BARRIER_HEALTH_MINIMUMS: Array[int] = [1, 10, 50, 100, 200]
+const BARRIER_HEALTH_MAXIMUMS: Array[int] = [9, 50, 100, 200, 400]
+const DISPLAY_NAMES: PackedStringArray = [
+ "boring",
+ "average",
+ "impressive",
+ "exceptional",
+ "shiny",
+]
+
+
+static func is_valid(quality: int) -> bool:
+ return quality >= 0 and quality < TIER_COUNT
+
+
+static func display_name(quality: int) -> String:
+ return DISPLAY_NAMES[quality] if is_valid(quality) else "unknown"
+
+
+static func bit_for(quality: int) -> int:
+ return 1 << quality if is_valid(quality) else 0
+
+
+static func sale_multiplier(quality: int) -> float:
+ return SALE_MULTIPLIERS[quality] if is_valid(quality) else 1.0
+
+
+static func apply_sale_value(base_value: int, quality: int) -> int:
+ if base_value <= 0 or not is_valid(quality):
+ return maxi(base_value, 0)
+ if quality == Tier.BORING:
+ return base_value
+ return maxi(
+ base_value + quality,
+ ceili(float(base_value) * sale_multiplier(quality)),
+ )
+
+
+static func barrier_health_multiplier(quality: int) -> float:
+ return (
+ BARRIER_HEALTH_MULTIPLIERS[quality]
+ if is_valid(quality)
+ else BARRIER_HEALTH_MULTIPLIERS[Tier.BORING]
+ )
+
+
+static func apply_barrier_health(base_health: int, quality: int) -> int:
+ return maxi(
+ ceili(
+ float(maxi(base_health, 1))
+ * barrier_health_multiplier(quality)
+ ),
+ 1,
+ )
+
+
+static func barrier_health_range(quality: int) -> Vector2i:
+ var safe_quality: int = (
+ quality if is_valid(quality) else Tier.BORING
+ )
+ return Vector2i(
+ BARRIER_HEALTH_MINIMUMS[safe_quality],
+ BARRIER_HEALTH_MAXIMUMS[safe_quality],
+ )
+
+
+static func roll_barrier_health(
+ rng: RandomNumberGenerator,
+ quality: int,
+) -> int:
+ var health_range: Vector2i = barrier_health_range(quality)
+ if rng == null:
+ return health_range.x
+ return rng.randi_range(health_range.x, health_range.y)
+
+
+static func roll(
+ rng: RandomNumberGenerator,
+ weight_multipliers: Array[float] = [],
+) -> int:
+ if rng == null:
+ return Tier.BORING
+ var weights: Array[float] = []
+ var total_weight: float = 0.0
+ for quality: int in TIER_COUNT:
+ var multiplier: float = 1.0
+ if quality < weight_multipliers.size():
+ multiplier = maxf(weight_multipliers[quality], 0.0)
+ var weight: float = BASE_ROLL_WEIGHTS[quality] * multiplier
+ weights.append(weight)
+ total_weight += weight
+ if total_weight <= 0.0:
+ return Tier.BORING
+ var rolled_weight: float = rng.randf() * total_weight
+ var accumulated_weight: float = 0.0
+ for quality: int in TIER_COUNT:
+ accumulated_weight += weights[quality]
+ if rolled_weight <= accumulated_weight:
+ return quality
+ return Tier.SHINY
+
+
+static func roll_weights_for_bait(active_bait_tags: Array[StringName]) -> Array[float]:
+ var bait_weights: Array[float] = rarity_weights_for_bait(active_bait_tags)
+ var weights: Array[float] = []
+ for quality: int in TIER_COUNT:
+ weights.append(bait_weights[quality] / BASE_ROLL_WEIGHTS[quality])
+ return weights
+
+
+static func rarity_weights_for_bait(
+ active_bait_tags: Array[StringName],
+) -> Array[float]:
+ var weights: Array[float] = []
+ if active_bait_tags.is_empty():
+ weights.assign([100.0, 0.0, 0.0, 0.0, 0.0])
+ elif active_bait_tags.has(&"bait_rarity_6"):
+ weights.assign(LUMINOUS_ROE_ROLL_WEIGHTS)
+ elif active_bait_tags.has(&"bait_rarity_5"):
+ weights.assign(SARDINE_ROLL_WEIGHTS)
+ elif active_bait_tags.has(&"bait_rarity_4"):
+ weights.assign(MINNOW_ROLL_WEIGHTS)
+ elif active_bait_tags.has(&"bait_rarity_3"):
+ weights.assign(SQUID_ROLL_WEIGHTS)
+ elif active_bait_tags.has(&"bait_rarity_2"):
+ weights.assign(SHRIMP_ROLL_WEIGHTS)
+ elif active_bait_tags.has(&"bait_rarity_1"):
+ weights.assign(SNAIL_ROLL_WEIGHTS)
+ else:
+ # Unknown and legacy bait tags retain the original worms behavior.
+ weights.assign(WORM_ROLL_WEIGHTS)
+ return weights
+
+
+static func qualified_name(fish_name: String, quality: int) -> String:
+ return "%s %s" % [display_name(quality), fish_name]
+
+
+static func qualified_name_with_article(
+ fish_name: String,
+ quality: int,
+) -> String:
+ var quality_name: String = display_name(quality)
+ var article: String = "an" if quality_name[0] in ["a", "e", "i", "o", "u"] else "a"
+ return "%s %s %s" % [article, quality_name, fish_name]
diff --git a/fish/fish_quality.gd.uid b/fish/fish_quality.gd.uid
new file mode 100644
index 0000000..0875763
--- /dev/null
+++ b/fish/fish_quality.gd.uid
@@ -0,0 +1 @@
+uid://csmvl1ciuux6a
diff --git a/fish/fish_selector.gd b/fish/fish_selector.gd
index 3c1656b..506a906 100644
--- a/fish/fish_selector.gd
+++ b/fish/fish_selector.gd
@@ -5,13 +5,16 @@ const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
const FishingContextType = preload("res://fishing/fishing_context.gd")
var undiscovered_weight_multiplier: float = 1.5
var rarity_weight_multipliers: Array[float] = []
+var quality_weight_multipliers: Array[float] = []
var use_deterministic_test_seed: bool = false
var deterministic_test_seed: int = 24680
var selection_seed: int = 0
+var _active_bait_tags: Array[StringName] = []
var _rng := RandomNumberGenerator.new()
@@ -32,27 +35,39 @@ func select_fish(
) -> FishDataType:
if pool == null or context == null or collection_log == null:
return null
-
- var eligible_fish: Array[FishDataType] = []
+ _active_bait_tags = context.active_bait_tags.duplicate()
+ var all_fish: Array[FishDataType] = []
+ var all_weights: Array[float] = []
+ var rarity_fish: Array[FishDataType] = []
+ var rarity_weights: Array[float] = []
+ var selected_rarity: int = _roll_rarity(context)
var weights: Array[float] = []
var total_weight: float = 0.0
for fish: FishDataType in pool.candidates:
if fish == null or not fish.is_selectable():
continue
+ if not fish.is_allowed_in_water(context.water_type):
+ continue
if fish.availability != null and not fish.availability.is_available(context):
continue
var final_weight: float = fish.base_catch_weight
- if fish.availability != null:
- final_weight *= fish.availability.get_bait_weight_multiplier(context)
+ # Species-specific preferred bait is deprecated. Bait now controls
+ # quality and rarity bands globally; required tags remain authoritative.
if not collection_log.has_discovered(fish.id):
final_weight *= maxf(undiscovered_weight_multiplier, 0.0)
if fish.rarity >= 0 and fish.rarity < rarity_weight_multipliers.size():
final_weight *= maxf(rarity_weight_multipliers[fish.rarity], 0.0)
if final_weight <= 0.0:
continue
- eligible_fish.append(fish)
- weights.append(final_weight)
- total_weight += final_weight
+ all_fish.append(fish)
+ all_weights.append(final_weight)
+ if int(fish.rarity) == selected_rarity:
+ rarity_fish.append(fish)
+ rarity_weights.append(final_weight)
+ var eligible_fish: Array[FishDataType] = rarity_fish if not rarity_fish.is_empty() else all_fish
+ weights = rarity_weights if not rarity_weights.is_empty() else all_weights
+ for weight: float in weights:
+ total_weight += weight
if eligible_fish.is_empty() or total_weight <= 0.0:
return null
@@ -65,7 +80,27 @@ func select_fish(
return eligible_fish.back()
-func create_catch(fish: FishDataType) -> FishCatchType:
+func _roll_rarity(context: FishingContextType) -> int:
+ var weights: Array[float] = FishQualityType.rarity_weights_for_bait(
+ context.active_bait_tags
+ )
+ for index: int in range(weights.size()):
+ if index < rarity_weight_multipliers.size():
+ weights[index] *= maxf(rarity_weight_multipliers[index], 0.0)
+ var total: float = 0.0
+ for weight: float in weights:
+ total += weight
+ if total <= 0.0:
+ return FishDataType.Rarity.COMMON
+ var roll: float = _rng.randf() * total
+ for index: int in range(weights.size()):
+ roll -= weights[index]
+ if roll <= 0.0:
+ return index
+ return FishDataType.Rarity.LEGENDARY
+
+
+func create_catch(fish: FishDataType, bait_tags: Array[StringName] = []) -> FishCatchType:
if fish == null or not fish.is_selectable():
return null
var caught_fish := FishCatchType.new()
@@ -79,7 +114,16 @@ func create_catch(fish: FishDataType) -> FishCatchType:
caught_fish.display_scale = fish.get_display_scale_for_weight(
caught_fish.weight_lb
)
- caught_fish.sale_value = fish.get_sale_value_for_weight(
- caught_fish.weight_lb
+ var effective_bait: Array[StringName] = bait_tags if not bait_tags.is_empty() else _active_bait_tags
+ var quality_multipliers: Array[float] = FishQualityType.roll_weights_for_bait(effective_bait)
+ for quality: int in range(mini(quality_multipliers.size(), quality_weight_multipliers.size())):
+ quality_multipliers[quality] *= maxf(quality_weight_multipliers[quality], 0.0)
+ caught_fish.quality = FishQualityType.roll(
+ _rng,
+ quality_multipliers,
+ )
+ caught_fish.sale_value = FishQualityType.apply_sale_value(
+ fish.get_sale_value_for_weight(caught_fish.weight_lb),
+ caught_fish.quality,
)
return caught_fish
diff --git a/fish/pools/fish_catalog.tres b/fish/pools/fish_catalog.tres
new file mode 100644
index 0000000..73609c8
--- /dev/null
+++ b/fish/pools/fish_catalog.tres
@@ -0,0 +1,60 @@
+[gd_resource type="Resource" load_steps=55 format=3]
+
+[ext_resource type="Script" path="res://fish/fish_pool.gd" id="1_pool"]
+[ext_resource type="Resource" path="res://fish/species/bluegill/bluegill.tres" id="2_bluegill"]
+[ext_resource type="Resource" path="res://fish/species/bass/bass.tres" id="3_bass"]
+[ext_resource type="Resource" path="res://fish/species/carp/carp.tres" id="4_carp"]
+[ext_resource type="Resource" path="res://fish/species/sunfish/sunfish.tres" id="5_sunfish"]
+[ext_resource type="Resource" path="res://fish/species/catfish_blue/catfish_blue.tres" id="6_catfish_blue"]
+[ext_resource type="Resource" path="res://fish/species/catfish_channel/catfish_channel.tres" id="7_catfish_channel"]
+[ext_resource type="Resource" path="res://fish/species/catfish_flathead/catfish_flathead.tres" id="8_catfish_flathead"]
+[ext_resource type="Resource" path="res://fish/species/catfish_white/catfish_white.tres" id="9_catfish_white"]
+[ext_resource type="Resource" path="res://fish/species/tuna_albacore/tuna_albacore.tres" id="10_tuna_albacore"]
+[ext_resource type="Resource" path="res://fish/species/tuna_bigeye/tuna_bigeye.tres" id="11_tuna_bigeye"]
+[ext_resource type="Resource" path="res://fish/species/tuna_bluefin/tuna_bluefin.tres" id="12_tuna_bluefin"]
+[ext_resource type="Resource" path="res://fish/species/tuna_skipjack/tuna_skipjack.tres" id="13_tuna_skipjack"]
+[ext_resource type="Resource" path="res://fish/species/tuna_yellowfin/tuna_yellowfin.tres" id="14_tuna_yellowfin"]
+[ext_resource type="Resource" path="res://fish/species/goby_round/goby_round.tres" id="15_goby_round"]
+[ext_resource type="Resource" path="res://fish/species/salmon_atlantic/salmon_atlantic.tres" id="16_salmon_atlantic"]
+[ext_resource type="Resource" path="res://fish/species/salmon_chum/salmon_chum.tres" id="17_salmon_chum"]
+[ext_resource type="Resource" path="res://fish/species/salmon_coho/salmon_coho.tres" id="18_salmon_coho"]
+[ext_resource type="Resource" path="res://fish/species/salmon_pink/salmon_pink.tres" id="19_salmon_pink"]
+[ext_resource type="Resource" path="res://fish/species/salmon_sockeye/salmon_sockeye.tres" id="20_salmon_sockeye"]
+[ext_resource type="Resource" path="res://fish/species/anchovy_european/anchovy_european.tres" id="21_anchovy_european"]
+[ext_resource type="Resource" path="res://fish/species/anchovy_northern/anchovy_northern.tres" id="22_anchovy_northern"]
+[ext_resource type="Resource" path="res://fish/species/chub_european/chub_european.tres" id="23_chub_european"]
+[ext_resource type="Resource" path="res://fish/species/chub_flame/chub_flame.tres" id="24_chub_flame"]
+[ext_resource type="Resource" path="res://fish/species/chub_lake/chub_lake.tres" id="25_chub_lake"]
+[ext_resource type="Resource" path="res://fish/species/goldfish/goldfish.tres" id="26_goldfish"]
+[ext_resource type="Resource" path="res://fish/species/goldfish_bubbleeye/goldfish_bubbleeye.tres" id="27_goldfish_bubbleeye"]
+[ext_resource type="Resource" path="res://fish/species/grouper_gulf/grouper_gulf.tres" id="28_grouper_gulf"]
+[ext_resource type="Resource" path="res://fish/species/grouper_red/grouper_red.tres" id="29_grouper_red"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_atlantic/mackerel_atlantic.tres" id="30_mackerel_atlantic"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_cero/mackerel_cero.tres" id="31_mackerel_cero"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_chub/mackerel_chub.tres" id="32_mackerel_chub"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_king/mackerel_king.tres" id="33_mackerel_king"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_spanish/mackerel_spanish.tres" id="34_mackerel_spanish"]
+[ext_resource type="Resource" path="res://fish/species/marlin_black/marlin_black.tres" id="35_marlin_black"]
+[ext_resource type="Resource" path="res://fish/species/marlin_blue/marlin_blue.tres" id="36_marlin_blue"]
+[ext_resource type="Resource" path="res://fish/species/marlin_white/marlin_white.tres" id="37_marlin_white"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_black/pomfret_black.tres" id="38_pomfret_black"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_chinese/pomfret_chinese.tres" id="39_pomfret_chinese"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_golden/pomfret_golden.tres" id="40_pomfret_golden"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_white/pomfret_white.tres" id="41_pomfret_white"]
+[ext_resource type="Resource" path="res://fish/species/sailfish/sailfish.tres" id="42_sailfish"]
+[ext_resource type="Resource" path="res://fish/species/sauger/sauger.tres" id="43_sauger"]
+[ext_resource type="Resource" path="res://fish/species/saugeye/saugeye.tres" id="44_saugeye"]
+[ext_resource type="Resource" path="res://fish/species/snapper_lane/snapper_lane.tres" id="45_snapper_lane"]
+[ext_resource type="Resource" path="res://fish/species/snapper_mangrove/snapper_mangrove.tres" id="46_snapper_mangrove"]
+[ext_resource type="Resource" path="res://fish/species/snapper_mutton/snapper_mutton.tres" id="47_snapper_mutton"]
+[ext_resource type="Resource" path="res://fish/species/snapper_red/snapper_red.tres" id="48_snapper_red"]
+[ext_resource type="Resource" path="res://fish/species/swordfish/swordfish.tres" id="49_swordfish"]
+[ext_resource type="Resource" path="res://fish/species/trout_cutthroat/trout_cutthroat.tres" id="50_trout_cutthroat"]
+[ext_resource type="Resource" path="res://fish/species/trout_golden/trout_golden.tres" id="51_trout_golden"]
+[ext_resource type="Resource" path="res://fish/species/trout_rainbow/trout_rainbow.tres" id="52_trout_rainbow"]
+[ext_resource type="Resource" path="res://fish/species/trout_steelhead/trout_steelhead.tres" id="53_trout_steelhead"]
+[ext_resource type="Resource" path="res://fish/species/walleye/walleye.tres" id="54_walleye"]
+
+[resource]
+script = ExtResource("1_pool")
+candidates = [ExtResource("2_bluegill"), ExtResource("3_bass"), ExtResource("4_carp"), ExtResource("5_sunfish"), ExtResource("6_catfish_blue"), ExtResource("7_catfish_channel"), ExtResource("8_catfish_flathead"), ExtResource("9_catfish_white"), ExtResource("10_tuna_albacore"), ExtResource("11_tuna_bigeye"), ExtResource("12_tuna_bluefin"), ExtResource("13_tuna_skipjack"), ExtResource("14_tuna_yellowfin"), ExtResource("15_goby_round"), ExtResource("16_salmon_atlantic"), ExtResource("17_salmon_chum"), ExtResource("18_salmon_coho"), ExtResource("19_salmon_pink"), ExtResource("20_salmon_sockeye"), ExtResource("21_anchovy_european"), ExtResource("22_anchovy_northern"), ExtResource("23_chub_european"), ExtResource("24_chub_flame"), ExtResource("25_chub_lake"), ExtResource("26_goldfish"), ExtResource("27_goldfish_bubbleeye"), ExtResource("28_grouper_gulf"), ExtResource("29_grouper_red"), ExtResource("30_mackerel_atlantic"), ExtResource("31_mackerel_cero"), ExtResource("32_mackerel_chub"), ExtResource("33_mackerel_king"), ExtResource("34_mackerel_spanish"), ExtResource("35_marlin_black"), ExtResource("36_marlin_blue"), ExtResource("37_marlin_white"), ExtResource("38_pomfret_black"), ExtResource("39_pomfret_chinese"), ExtResource("40_pomfret_golden"), ExtResource("41_pomfret_white"), ExtResource("42_sailfish"), ExtResource("43_sauger"), ExtResource("44_saugeye"), ExtResource("45_snapper_lane"), ExtResource("46_snapper_mangrove"), ExtResource("47_snapper_mutton"), ExtResource("48_snapper_red"), ExtResource("49_swordfish"), ExtResource("50_trout_cutthroat"), ExtResource("51_trout_golden"), ExtResource("52_trout_rainbow"), ExtResource("53_trout_steelhead"), ExtResource("54_walleye")]
diff --git a/fish/pools/starter_ocean_pool.tres b/fish/pools/starter_ocean_pool.tres
new file mode 100644
index 0000000..1e47a2b
--- /dev/null
+++ b/fish/pools/starter_ocean_pool.tres
@@ -0,0 +1,41 @@
+[gd_resource type="Resource" load_steps=36 format=3]
+
+[ext_resource type="Script" path="res://fish/fish_pool.gd" id="1_pool"]
+[ext_resource type="Resource" path="res://fish/species/bass/bass.tres" id="2_bass"]
+[ext_resource type="Resource" path="res://fish/species/sunfish/sunfish.tres" id="3_sunfish"]
+[ext_resource type="Resource" path="res://fish/species/tuna_albacore/tuna_albacore.tres" id="4_tuna_albacore"]
+[ext_resource type="Resource" path="res://fish/species/tuna_bigeye/tuna_bigeye.tres" id="5_tuna_bigeye"]
+[ext_resource type="Resource" path="res://fish/species/tuna_bluefin/tuna_bluefin.tres" id="6_tuna_bluefin"]
+[ext_resource type="Resource" path="res://fish/species/tuna_skipjack/tuna_skipjack.tres" id="7_tuna_skipjack"]
+[ext_resource type="Resource" path="res://fish/species/tuna_yellowfin/tuna_yellowfin.tres" id="8_tuna_yellowfin"]
+[ext_resource type="Resource" path="res://fish/species/salmon_atlantic/salmon_atlantic.tres" id="9_salmon_atlantic"]
+[ext_resource type="Resource" path="res://fish/species/salmon_chum/salmon_chum.tres" id="10_salmon_chum"]
+[ext_resource type="Resource" path="res://fish/species/salmon_coho/salmon_coho.tres" id="11_salmon_coho"]
+[ext_resource type="Resource" path="res://fish/species/salmon_pink/salmon_pink.tres" id="12_salmon_pink"]
+[ext_resource type="Resource" path="res://fish/species/salmon_sockeye/salmon_sockeye.tres" id="13_salmon_sockeye"]
+[ext_resource type="Resource" path="res://fish/species/anchovy_european/anchovy_european.tres" id="14_anchovy_european"]
+[ext_resource type="Resource" path="res://fish/species/anchovy_northern/anchovy_northern.tres" id="15_anchovy_northern"]
+[ext_resource type="Resource" path="res://fish/species/grouper_gulf/grouper_gulf.tres" id="16_grouper_gulf"]
+[ext_resource type="Resource" path="res://fish/species/grouper_red/grouper_red.tres" id="17_grouper_red"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_atlantic/mackerel_atlantic.tres" id="18_mackerel_atlantic"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_cero/mackerel_cero.tres" id="19_mackerel_cero"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_chub/mackerel_chub.tres" id="20_mackerel_chub"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_king/mackerel_king.tres" id="21_mackerel_king"]
+[ext_resource type="Resource" path="res://fish/species/mackerel_spanish/mackerel_spanish.tres" id="22_mackerel_spanish"]
+[ext_resource type="Resource" path="res://fish/species/marlin_black/marlin_black.tres" id="23_marlin_black"]
+[ext_resource type="Resource" path="res://fish/species/marlin_blue/marlin_blue.tres" id="24_marlin_blue"]
+[ext_resource type="Resource" path="res://fish/species/marlin_white/marlin_white.tres" id="25_marlin_white"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_black/pomfret_black.tres" id="26_pomfret_black"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_chinese/pomfret_chinese.tres" id="27_pomfret_chinese"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_golden/pomfret_golden.tres" id="28_pomfret_golden"]
+[ext_resource type="Resource" path="res://fish/species/pomfret_white/pomfret_white.tres" id="29_pomfret_white"]
+[ext_resource type="Resource" path="res://fish/species/sailfish/sailfish.tres" id="30_sailfish"]
+[ext_resource type="Resource" path="res://fish/species/snapper_lane/snapper_lane.tres" id="31_snapper_lane"]
+[ext_resource type="Resource" path="res://fish/species/snapper_mangrove/snapper_mangrove.tres" id="32_snapper_mangrove"]
+[ext_resource type="Resource" path="res://fish/species/snapper_mutton/snapper_mutton.tres" id="33_snapper_mutton"]
+[ext_resource type="Resource" path="res://fish/species/snapper_red/snapper_red.tres" id="34_snapper_red"]
+[ext_resource type="Resource" path="res://fish/species/swordfish/swordfish.tres" id="35_swordfish"]
+
+[resource]
+script = ExtResource("1_pool")
+candidates = [ExtResource("2_bass"), ExtResource("3_sunfish"), ExtResource("4_tuna_albacore"), ExtResource("5_tuna_bigeye"), ExtResource("6_tuna_bluefin"), ExtResource("7_tuna_skipjack"), ExtResource("8_tuna_yellowfin"), ExtResource("9_salmon_atlantic"), ExtResource("10_salmon_chum"), ExtResource("11_salmon_coho"), ExtResource("12_salmon_pink"), ExtResource("13_salmon_sockeye"), ExtResource("14_anchovy_european"), ExtResource("15_anchovy_northern"), ExtResource("16_grouper_gulf"), ExtResource("17_grouper_red"), ExtResource("18_mackerel_atlantic"), ExtResource("19_mackerel_cero"), ExtResource("20_mackerel_chub"), ExtResource("21_mackerel_king"), ExtResource("22_mackerel_spanish"), ExtResource("23_marlin_black"), ExtResource("24_marlin_blue"), ExtResource("25_marlin_white"), ExtResource("26_pomfret_black"), ExtResource("27_pomfret_chinese"), ExtResource("28_pomfret_golden"), ExtResource("29_pomfret_white"), ExtResource("30_sailfish"), ExtResource("31_snapper_lane"), ExtResource("32_snapper_mangrove"), ExtResource("33_snapper_mutton"), ExtResource("34_snapper_red"), ExtResource("35_swordfish")]
diff --git a/fish/pools/starter_pond_pool.tres b/fish/pools/starter_pond_pool.tres
new file mode 100644
index 0000000..de1a327
--- /dev/null
+++ b/fish/pools/starter_pond_pool.tres
@@ -0,0 +1,26 @@
+[gd_resource type="Resource" load_steps=21 format=3]
+
+[ext_resource type="Script" path="res://fish/fish_pool.gd" id="1_pool"]
+[ext_resource type="Resource" path="res://fish/species/bluegill/bluegill.tres" id="2_bluegill"]
+[ext_resource type="Resource" path="res://fish/species/carp/carp.tres" id="3_carp"]
+[ext_resource type="Resource" path="res://fish/species/catfish_blue/catfish_blue.tres" id="4_catfish_blue"]
+[ext_resource type="Resource" path="res://fish/species/catfish_channel/catfish_channel.tres" id="5_catfish_channel"]
+[ext_resource type="Resource" path="res://fish/species/catfish_flathead/catfish_flathead.tres" id="6_catfish_flathead"]
+[ext_resource type="Resource" path="res://fish/species/catfish_white/catfish_white.tres" id="7_catfish_white"]
+[ext_resource type="Resource" path="res://fish/species/goby_round/goby_round.tres" id="8_goby_round"]
+[ext_resource type="Resource" path="res://fish/species/chub_european/chub_european.tres" id="9_chub_european"]
+[ext_resource type="Resource" path="res://fish/species/chub_flame/chub_flame.tres" id="10_chub_flame"]
+[ext_resource type="Resource" path="res://fish/species/chub_lake/chub_lake.tres" id="11_chub_lake"]
+[ext_resource type="Resource" path="res://fish/species/goldfish/goldfish.tres" id="12_goldfish"]
+[ext_resource type="Resource" path="res://fish/species/goldfish_bubbleeye/goldfish_bubbleeye.tres" id="13_goldfish_bubbleeye"]
+[ext_resource type="Resource" path="res://fish/species/sauger/sauger.tres" id="14_sauger"]
+[ext_resource type="Resource" path="res://fish/species/saugeye/saugeye.tres" id="15_saugeye"]
+[ext_resource type="Resource" path="res://fish/species/trout_cutthroat/trout_cutthroat.tres" id="16_trout_cutthroat"]
+[ext_resource type="Resource" path="res://fish/species/trout_golden/trout_golden.tres" id="17_trout_golden"]
+[ext_resource type="Resource" path="res://fish/species/trout_rainbow/trout_rainbow.tres" id="18_trout_rainbow"]
+[ext_resource type="Resource" path="res://fish/species/trout_steelhead/trout_steelhead.tres" id="19_trout_steelhead"]
+[ext_resource type="Resource" path="res://fish/species/walleye/walleye.tres" id="20_walleye"]
+
+[resource]
+script = ExtResource("1_pool")
+candidates = [ExtResource("2_bluegill"), ExtResource("3_carp"), ExtResource("4_catfish_blue"), ExtResource("5_catfish_channel"), ExtResource("6_catfish_flathead"), ExtResource("7_catfish_white"), ExtResource("8_goby_round"), ExtResource("9_chub_european"), ExtResource("10_chub_flame"), ExtResource("11_chub_lake"), ExtResource("12_goldfish"), ExtResource("13_goldfish_bubbleeye"), ExtResource("14_sauger"), ExtResource("15_saugeye"), ExtResource("16_trout_cutthroat"), ExtResource("17_trout_golden"), ExtResource("18_trout_rainbow"), ExtResource("19_trout_steelhead"), ExtResource("20_walleye")]
diff --git a/fish/species/anchovy_european/anchovy_european.tres b/fish/species/anchovy_european/anchovy_european.tres
new file mode 100644
index 0000000..fea09ed
--- /dev/null
+++ b/fish/species/anchovy_european/anchovy_european.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://6a063go0dcqk"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://debgohfsg4ec7" path="res://fish/species/anchovy_european/fish_anchovy_european.png" id="4_texture"]
+
+[sub_resource type="Resource" id="AnchovyEuropeanAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"anchovy_european"
+display_name = "european anchovy"
+allowed_water_types = 2
+base_catch_weight = 0.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("AnchovyEuropeanAvailability")
+weight_min_lb = 0.05
+weight_max_lb = 0.25
+display_scale_min = 0.85
+display_scale_max = 1.0
+display_scale_curve = 0.9
+sell_value_min = 2
+sell_value_max = 4
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/anchovy_european/fish_anchovy_european.png b/fish/species/anchovy_european/fish_anchovy_european.png
new file mode 100644
index 0000000..44742f8
Binary files /dev/null and b/fish/species/anchovy_european/fish_anchovy_european.png differ
diff --git a/fish/species/anchovy_european/fish_anchovy_european.png.import b/fish/species/anchovy_european/fish_anchovy_european.png.import
new file mode 100644
index 0000000..c4fd63a
--- /dev/null
+++ b/fish/species/anchovy_european/fish_anchovy_european.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://debgohfsg4ec7"
+path="res://.godot/imported/fish_anchovy_european.png-67d070da34e29a3123fed0030af0d694.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/anchovy_european/fish_anchovy_european.png"
+dest_files=["res://.godot/imported/fish_anchovy_european.png-67d070da34e29a3123fed0030af0d694.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/anchovy_northern/anchovy_northern.tres b/fish/species/anchovy_northern/anchovy_northern.tres
new file mode 100644
index 0000000..ad53d6e
--- /dev/null
+++ b/fish/species/anchovy_northern/anchovy_northern.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://c30colwbajqf8"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://6sikqc8eivta" path="res://fish/species/anchovy_northern/fish_anchovy_northern.png" id="4_texture"]
+
+[sub_resource type="Resource" id="AnchovyNorthernAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"anchovy_northern"
+display_name = "northern anchovy"
+allowed_water_types = 2
+base_catch_weight = 0.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("AnchovyNorthernAvailability")
+weight_min_lb = 0.05
+weight_max_lb = 0.35
+display_scale_min = 0.85
+display_scale_max = 1.0
+display_scale_curve = 0.9
+sell_value_min = 2
+sell_value_max = 4
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/anchovy_northern/fish_anchovy_northern.png b/fish/species/anchovy_northern/fish_anchovy_northern.png
new file mode 100644
index 0000000..20bacd8
Binary files /dev/null and b/fish/species/anchovy_northern/fish_anchovy_northern.png differ
diff --git a/fish/species/anchovy_northern/fish_anchovy_northern.png.import b/fish/species/anchovy_northern/fish_anchovy_northern.png.import
new file mode 100644
index 0000000..68ae1ea
--- /dev/null
+++ b/fish/species/anchovy_northern/fish_anchovy_northern.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6sikqc8eivta"
+path="res://.godot/imported/fish_anchovy_northern.png-b1d3cbc1c8208168c212589a431f1227.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/anchovy_northern/fish_anchovy_northern.png"
+dest_files=["res://.godot/imported/fish_anchovy_northern.png-b1d3cbc1c8208168c212589a431f1227.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/bass/bass.tres b/fish/species/bass/bass.tres
index bf7d261..bc2b026 100644
--- a/fish/species/bass/bass.tres
+++ b/fish/species/bass/bass.tres
@@ -1,14 +1,12 @@
-[gd_resource type="Resource" load_steps=5 format=3]
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://f1cibmajx3lo"]
-[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
-[ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability_script"]
-[ext_resource type="Texture2D" path="res://fish/species/bass/fish_bass_striped.png" id="4_texture"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://dvh6s86asl4ix" path="res://fish/species/bass/fish_bass_striped.png" id="4_texture"]
[sub_resource type="Resource" id="BassAvailability"]
script = ExtResource("3_availability_script")
-allow_day = true
-allow_night = true
preferred_bait_tags = Array[StringName]([&"worm", &"minnow"])
preferred_bait_weight_multiplier = 1.4
@@ -16,6 +14,7 @@ preferred_bait_weight_multiplier = 1.4
script = ExtResource("1_fish_data")
id = &"bass"
display_name = "striped bass"
+allowed_water_types = 2
rarity = 1
base_catch_weight = 4.0
catch_profile = ExtResource("2_profile")
diff --git a/fish/species/bass/fish_bass_striped.png b/fish/species/bass/fish_bass_striped.png
index 89b5b5e..efbf835 100644
Binary files a/fish/species/bass/fish_bass_striped.png and b/fish/species/bass/fish_bass_striped.png differ
diff --git a/fish/species/bluegill/bluegill.tres b/fish/species/bluegill/bluegill.tres
index e766b5f..9e98a5b 100644
--- a/fish/species/bluegill/bluegill.tres
+++ b/fish/species/bluegill/bluegill.tres
@@ -2,13 +2,14 @@
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
-[ext_resource type="Resource" path="res://fish/default_availability.tres" id="3_availability"]
+[ext_resource type="Resource" path="res://fish/day_availability.tres" id="3_availability"]
[ext_resource type="Texture2D" path="res://fish/species/bluegill/fish_bluegill.png" id="4_texture"]
[resource]
script = ExtResource("1_fish_data")
id = &"bluegill"
display_name = "bluegill"
+allowed_water_types = 1
rarity = 0
base_catch_weight = 10.0
catch_profile = ExtResource("2_profile")
diff --git a/fish/species/bluegill/fish_bluegill.png b/fish/species/bluegill/fish_bluegill.png
index 8569e3c..013db04 100644
Binary files a/fish/species/bluegill/fish_bluegill.png and b/fish/species/bluegill/fish_bluegill.png differ
diff --git a/fish/species/carp/carp.tres b/fish/species/carp/carp.tres
index bb4642d..5673834 100644
--- a/fish/species/carp/carp.tres
+++ b/fish/species/carp/carp.tres
@@ -1,14 +1,12 @@
-[gd_resource type="Resource" load_steps=5 format=3]
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cdkdwpwu384ck"]
-[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/carp_catch_profile.tres" id="2_profile"]
-[ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability_script"]
-[ext_resource type="Texture2D" path="res://fish/species/carp/fish_carp_common.png" id="4_texture"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://c6pa1gqwv1a5s" path="res://fish/species/carp/fish_carp_common.png" id="4_texture"]
[sub_resource type="Resource" id="CarpAvailability"]
script = ExtResource("3_availability_script")
-allow_day = true
-allow_night = true
preferred_bait_tags = Array[StringName]([&"corn", &"bread"])
preferred_bait_weight_multiplier = 1.5
@@ -16,6 +14,7 @@ preferred_bait_weight_multiplier = 1.5
script = ExtResource("1_fish_data")
id = &"carp"
display_name = "common carp"
+allowed_water_types = 1
rarity = 2
base_catch_weight = 1.5
catch_profile = ExtResource("2_profile")
diff --git a/fish/species/carp/fish_carp_common.png b/fish/species/carp/fish_carp_common.png
index 50d0b31..709f4d4 100644
Binary files a/fish/species/carp/fish_carp_common.png and b/fish/species/carp/fish_carp_common.png differ
diff --git a/fish/species/catfish_blue/catfish_blue.tres b/fish/species/catfish_blue/catfish_blue.tres
new file mode 100644
index 0000000..482520a
--- /dev/null
+++ b/fish/species/catfish_blue/catfish_blue.tres
@@ -0,0 +1,32 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://v54vjd6k2mdb"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://7f1dtcou8moe" path="res://fish/species/catfish_blue/fish_catfish_blue.png" id="4_texture"]
+
+[sub_resource type="Resource" id="BlueCatfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+allow_day = false
+preferred_bait_tags = Array[StringName]([&"worm", &"minnow"])
+preferred_bait_weight_multiplier = 1.35
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"catfish_blue"
+display_name = "blue catfish"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 1.25
+catch_profile = ExtResource("2_profile")
+availability = SubResource("BlueCatfishAvailability")
+weight_min_lb = 3.0
+weight_max_lb = 12.0
+display_scale_min = 0.95
+display_scale_max = 1.55
+display_scale_curve = 0.9
+sell_value_min = 6
+sell_value_max = 9
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/catfish_blue/fish_catfish_blue.png b/fish/species/catfish_blue/fish_catfish_blue.png
new file mode 100644
index 0000000..6482e15
Binary files /dev/null and b/fish/species/catfish_blue/fish_catfish_blue.png differ
diff --git a/fish/species/catfish_blue/fish_catfish_blue.png.import b/fish/species/catfish_blue/fish_catfish_blue.png.import
new file mode 100644
index 0000000..60d3606
--- /dev/null
+++ b/fish/species/catfish_blue/fish_catfish_blue.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://7f1dtcou8moe"
+path="res://.godot/imported/fish_catfish_blue.png-03480cfe99c4fdece0a97273445594bd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/catfish_blue/fish_catfish_blue.png"
+dest_files=["res://.godot/imported/fish_catfish_blue.png-03480cfe99c4fdece0a97273445594bd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/catfish_channel/catfish_channel.tres b/fish/species/catfish_channel/catfish_channel.tres
new file mode 100644
index 0000000..265daad
--- /dev/null
+++ b/fish/species/catfish_channel/catfish_channel.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b7kpxcciokl4"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://dvxpk2ltt3blb" path="res://fish/species/catfish_channel/fish_catfish_channel.png" id="4_texture"]
+
+[sub_resource type="Resource" id="ChannelCatfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+allow_day = false
+preferred_bait_tags = Array[StringName]([&"worm"])
+preferred_bait_weight_multiplier = 1.35
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"catfish_channel"
+display_name = "channel catfish"
+allowed_water_types = 1
+base_catch_weight = 2.0
+catch_profile = ExtResource("2_profile")
+availability = SubResource("ChannelCatfishAvailability")
+weight_min_lb = 1.5
+weight_max_lb = 7.0
+display_scale_min = 0.85
+display_scale_max = 1.35
+sell_value_min = 4
+sell_value_max = 7
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/catfish_channel/fish_catfish_channel.png b/fish/species/catfish_channel/fish_catfish_channel.png
new file mode 100644
index 0000000..b858806
Binary files /dev/null and b/fish/species/catfish_channel/fish_catfish_channel.png differ
diff --git a/fish/species/catfish_channel/fish_catfish_channel.png.import b/fish/species/catfish_channel/fish_catfish_channel.png.import
new file mode 100644
index 0000000..4a37a2d
--- /dev/null
+++ b/fish/species/catfish_channel/fish_catfish_channel.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dvxpk2ltt3blb"
+path="res://.godot/imported/fish_catfish_channel.png-5078da9a00f276d273bfcd2b50b98937.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/catfish_channel/fish_catfish_channel.png"
+dest_files=["res://.godot/imported/fish_catfish_channel.png-5078da9a00f276d273bfcd2b50b98937.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/catfish_flathead/catfish_flathead.tres b/fish/species/catfish_flathead/catfish_flathead.tres
new file mode 100644
index 0000000..651359a
--- /dev/null
+++ b/fish/species/catfish_flathead/catfish_flathead.tres
@@ -0,0 +1,31 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://c4r5ccnpw5nrq"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bacx63lp0yh7t" path="res://fish/species/catfish_flathead/fish_catfish_flathead.png" id="4_texture"]
+
+[sub_resource type="Resource" id="FlatheadCatfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+allow_day = false
+preferred_bait_tags = Array[StringName]([&"worm", &"minnow"])
+preferred_bait_weight_multiplier = 1.35
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"catfish_flathead"
+display_name = "flathead catfish"
+allowed_water_types = 1
+rarity = 1
+catch_profile = ExtResource("2_profile")
+availability = SubResource("FlatheadCatfishAvailability")
+weight_min_lb = 3.0
+weight_max_lb = 14.0
+display_scale_min = 1.0
+display_scale_max = 1.6
+display_scale_curve = 0.9
+sell_value_min = 7
+sell_value_max = 10
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/catfish_flathead/fish_catfish_flathead.png b/fish/species/catfish_flathead/fish_catfish_flathead.png
new file mode 100644
index 0000000..e248c02
Binary files /dev/null and b/fish/species/catfish_flathead/fish_catfish_flathead.png differ
diff --git a/fish/species/catfish_flathead/fish_catfish_flathead.png.import b/fish/species/catfish_flathead/fish_catfish_flathead.png.import
new file mode 100644
index 0000000..0d7eb41
--- /dev/null
+++ b/fish/species/catfish_flathead/fish_catfish_flathead.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bacx63lp0yh7t"
+path="res://.godot/imported/fish_catfish_flathead.png-31b800b34c27c75b84a2ee98a13ff954.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/catfish_flathead/fish_catfish_flathead.png"
+dest_files=["res://.godot/imported/fish_catfish_flathead.png-31b800b34c27c75b84a2ee98a13ff954.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/catfish_white/catfish_white.tres b/fish/species/catfish_white/catfish_white.tres
new file mode 100644
index 0000000..6ee72ea
--- /dev/null
+++ b/fish/species/catfish_white/catfish_white.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cr3kar4oioloh"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bf6kiawwekv7g" path="res://fish/species/catfish_white/fish_catfish_white.png" id="4_texture"]
+
+[sub_resource type="Resource" id="WhiteCatfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+allow_day = false
+preferred_bait_tags = Array[StringName]([&"worm"])
+preferred_bait_weight_multiplier = 1.35
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"catfish_white"
+display_name = "white catfish"
+allowed_water_types = 1
+base_catch_weight = 2.0
+catch_profile = ExtResource("2_profile")
+availability = SubResource("WhiteCatfishAvailability")
+weight_min_lb = 1.0
+weight_max_lb = 5.0
+display_scale_max = 1.25
+sell_value_min = 4
+sell_value_max = 6
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/catfish_white/fish_catfish_white.png b/fish/species/catfish_white/fish_catfish_white.png
new file mode 100644
index 0000000..499be44
Binary files /dev/null and b/fish/species/catfish_white/fish_catfish_white.png differ
diff --git a/fish/species/catfish_white/fish_catfish_white.png.import b/fish/species/catfish_white/fish_catfish_white.png.import
new file mode 100644
index 0000000..1bdd1d6
--- /dev/null
+++ b/fish/species/catfish_white/fish_catfish_white.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bf6kiawwekv7g"
+path="res://.godot/imported/fish_catfish_white.png-13b8ec9cfaabf9930cf00da49c81a3bd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/catfish_white/fish_catfish_white.png"
+dest_files=["res://.godot/imported/fish_catfish_white.png-13b8ec9cfaabf9930cf00da49c81a3bd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/chub_european/chub_european.tres b/fish/species/chub_european/chub_european.tres
new file mode 100644
index 0000000..e282b90
--- /dev/null
+++ b/fish/species/chub_european/chub_european.tres
@@ -0,0 +1,27 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cmyhlwqrx36wg"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://c04wtbyslmpll" path="res://fish/species/chub_european/fish_chub_european.png" id="4_texture"]
+
+[sub_resource type="Resource" id="ChubEuropeanAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"chub_european"
+display_name = "european chub"
+allowed_water_types = 1
+base_catch_weight = 0.55
+catch_profile = ExtResource("2_profile")
+availability = SubResource("ChubEuropeanAvailability")
+weight_min_lb = 0.2
+weight_max_lb = 6.0
+display_scale_min = 0.85
+display_scale_curve = 0.9
+sell_value_min = 2
+sell_value_max = 4
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/chub_european/fish_chub_european.png b/fish/species/chub_european/fish_chub_european.png
new file mode 100644
index 0000000..03ed536
Binary files /dev/null and b/fish/species/chub_european/fish_chub_european.png differ
diff --git a/fish/species/chub_european/fish_chub_european.png.import b/fish/species/chub_european/fish_chub_european.png.import
new file mode 100644
index 0000000..0c241b0
--- /dev/null
+++ b/fish/species/chub_european/fish_chub_european.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c04wtbyslmpll"
+path="res://.godot/imported/fish_chub_european.png-da2784298a62b1d90038dacb4cf25e35.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/chub_european/fish_chub_european.png"
+dest_files=["res://.godot/imported/fish_chub_european.png-da2784298a62b1d90038dacb4cf25e35.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/chub_flame/chub_flame.tres b/fish/species/chub_flame/chub_flame.tres
new file mode 100644
index 0000000..d631ab0
--- /dev/null
+++ b/fish/species/chub_flame/chub_flame.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://d25q23kfdhm3o"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bla6pc21imxsi" path="res://fish/species/chub_flame/fish_chub_flame.png" id="4_texture"]
+
+[sub_resource type="Resource" id="ChubFlameAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"chub_flame"
+display_name = "flame chub"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 0.4
+catch_profile = ExtResource("2_profile")
+availability = SubResource("ChubFlameAvailability")
+weight_min_lb = 0.05
+weight_max_lb = 0.15
+display_scale_min = 0.85
+display_scale_max = 1.1
+display_scale_curve = 0.9
+sell_value_min = 3
+sell_value_max = 6
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/chub_flame/fish_chub_flame.png b/fish/species/chub_flame/fish_chub_flame.png
new file mode 100644
index 0000000..98c67e3
Binary files /dev/null and b/fish/species/chub_flame/fish_chub_flame.png differ
diff --git a/fish/species/chub_flame/fish_chub_flame.png.import b/fish/species/chub_flame/fish_chub_flame.png.import
new file mode 100644
index 0000000..8cad0d9
--- /dev/null
+++ b/fish/species/chub_flame/fish_chub_flame.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bla6pc21imxsi"
+path="res://.godot/imported/fish_chub_flame.png-9c7780910f16d333d5109ee4e80aa8fb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/chub_flame/fish_chub_flame.png"
+dest_files=["res://.godot/imported/fish_chub_flame.png-9c7780910f16d333d5109ee4e80aa8fb.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/chub_lake/chub_lake.tres b/fish/species/chub_lake/chub_lake.tres
new file mode 100644
index 0000000..c04c5b3
--- /dev/null
+++ b/fish/species/chub_lake/chub_lake.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://n7u2krjf4ril"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://w8cjrpnv350h" path="res://fish/species/chub_lake/fish_chub_lake.png" id="4_texture"]
+
+[sub_resource type="Resource" id="ChubLakeAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"chub_lake"
+display_name = "lake chub"
+allowed_water_types = 1
+base_catch_weight = 0.55
+catch_profile = ExtResource("2_profile")
+availability = SubResource("ChubLakeAvailability")
+weight_min_lb = 0.05
+weight_max_lb = 0.5
+display_scale_min = 0.85
+display_scale_max = 1.0
+display_scale_curve = 0.9
+sell_value_min = 2
+sell_value_max = 4
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/chub_lake/fish_chub_lake.png b/fish/species/chub_lake/fish_chub_lake.png
new file mode 100644
index 0000000..73101f6
Binary files /dev/null and b/fish/species/chub_lake/fish_chub_lake.png differ
diff --git a/fish/species/chub_lake/fish_chub_lake.png.import b/fish/species/chub_lake/fish_chub_lake.png.import
new file mode 100644
index 0000000..1be73f0
--- /dev/null
+++ b/fish/species/chub_lake/fish_chub_lake.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://w8cjrpnv350h"
+path="res://.godot/imported/fish_chub_lake.png-cbb7f27fe4a21820691bbb9c3b9bd3e9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/chub_lake/fish_chub_lake.png"
+dest_files=["res://.godot/imported/fish_chub_lake.png-cbb7f27fe4a21820691bbb9c3b9bd3e9.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/goby_round/fish_goby_round.png b/fish/species/goby_round/fish_goby_round.png
new file mode 100644
index 0000000..89e3f78
Binary files /dev/null and b/fish/species/goby_round/fish_goby_round.png differ
diff --git a/fish/species/goby_round/fish_goby_round.png.import b/fish/species/goby_round/fish_goby_round.png.import
new file mode 100644
index 0000000..6fb96e8
--- /dev/null
+++ b/fish/species/goby_round/fish_goby_round.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdhywe0r1xcio"
+path="res://.godot/imported/fish_goby_round.png-3c3fd3dd06c7b5dd4539ab186bf0d865.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/goby_round/fish_goby_round.png"
+dest_files=["res://.godot/imported/fish_goby_round.png-3c3fd3dd06c7b5dd4539ab186bf0d865.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/goby_round/goby_round.tres b/fish/species/goby_round/goby_round.tres
new file mode 100644
index 0000000..4c8872e
--- /dev/null
+++ b/fish/species/goby_round/goby_round.tres
@@ -0,0 +1,27 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://o1pojc8xthiw"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bdhywe0r1xcio" path="res://fish/species/goby_round/fish_goby_round.png" id="4_texture"]
+
+[sub_resource type="Resource" id="RoundGobyAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"worm", &"minnow"])
+preferred_bait_weight_multiplier = 1.35
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"goby_round"
+display_name = "round goby"
+allowed_water_types = 1
+base_catch_weight = 8.0
+catch_profile = ExtResource("2_profile")
+availability = SubResource("RoundGobyAvailability")
+weight_min_lb = 0.1
+weight_max_lb = 0.6
+display_scale_min = 0.55
+display_scale_max = 0.9
+sell_value_min = 2
+sell_value_max = 3
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/goldfish/fish_goldfish.png b/fish/species/goldfish/fish_goldfish.png
new file mode 100644
index 0000000..7ca035b
Binary files /dev/null and b/fish/species/goldfish/fish_goldfish.png differ
diff --git a/fish/species/goldfish/fish_goldfish.png.import b/fish/species/goldfish/fish_goldfish.png.import
new file mode 100644
index 0000000..4d9a960
--- /dev/null
+++ b/fish/species/goldfish/fish_goldfish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c6uth7aob03kg"
+path="res://.godot/imported/fish_goldfish.png-567cddb5ae1fc7839cc6dbc80a9a23f3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/goldfish/fish_goldfish.png"
+dest_files=["res://.godot/imported/fish_goldfish.png-567cddb5ae1fc7839cc6dbc80a9a23f3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/goldfish/goldfish.tres b/fish/species/goldfish/goldfish.tres
new file mode 100644
index 0000000..153365d
--- /dev/null
+++ b/fish/species/goldfish/goldfish.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cw7vdjhhfgtax"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://c6uth7aob03kg" path="res://fish/species/goldfish/fish_goldfish.png" id="4_texture"]
+
+[sub_resource type="Resource" id="GoldfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"goldfish"
+display_name = "goldfish"
+allowed_water_types = 1
+base_catch_weight = 0.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("GoldfishAvailability")
+weight_min_lb = 0.1
+weight_max_lb = 4.0
+display_scale_min = 0.85
+display_scale_max = 1.1
+display_scale_curve = 0.9
+sell_value_min = 1
+sell_value_max = 3
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png b/fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png
new file mode 100644
index 0000000..b621e16
Binary files /dev/null and b/fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png differ
diff --git a/fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png.import b/fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png.import
new file mode 100644
index 0000000..1344959
--- /dev/null
+++ b/fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://brjjnxr6n7olr"
+path="res://.godot/imported/fish_goldfish_bubbleeye.png-a8dcb7e4fb9c7a1beebe2852d2d38d80.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png"
+dest_files=["res://.godot/imported/fish_goldfish_bubbleeye.png-a8dcb7e4fb9c7a1beebe2852d2d38d80.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/goldfish_bubbleeye/goldfish_bubbleeye.tres b/fish/species/goldfish_bubbleeye/goldfish_bubbleeye.tres
new file mode 100644
index 0000000..d6a288a
--- /dev/null
+++ b/fish/species/goldfish_bubbleeye/goldfish_bubbleeye.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://beiutt7wq87rp"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://brjjnxr6n7olr" path="res://fish/species/goldfish_bubbleeye/fish_goldfish_bubbleeye.png" id="4_texture"]
+
+[sub_resource type="Resource" id="GoldfishBubbleeyeAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"goldfish_bubbleeye"
+display_name = "bubble-eye goldfish"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 0.35
+catch_profile = ExtResource("2_profile")
+availability = SubResource("GoldfishBubbleeyeAvailability")
+weight_min_lb = 0.1
+weight_max_lb = 0.35
+display_scale_min = 0.85
+display_scale_max = 1.0
+display_scale_curve = 0.9
+sell_value_min = 3
+sell_value_max = 7
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/grouper_gulf/fish_grouper_gulf.png b/fish/species/grouper_gulf/fish_grouper_gulf.png
new file mode 100644
index 0000000..e74ea10
Binary files /dev/null and b/fish/species/grouper_gulf/fish_grouper_gulf.png differ
diff --git a/fish/species/grouper_gulf/fish_grouper_gulf.png.import b/fish/species/grouper_gulf/fish_grouper_gulf.png.import
new file mode 100644
index 0000000..c14f066
--- /dev/null
+++ b/fish/species/grouper_gulf/fish_grouper_gulf.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ctu8xd5or7v3h"
+path="res://.godot/imported/fish_grouper_gulf.png-8313134ee6830c8e9208b9e3cf5c5333.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/grouper_gulf/fish_grouper_gulf.png"
+dest_files=["res://.godot/imported/fish_grouper_gulf.png-8313134ee6830c8e9208b9e3cf5c5333.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/grouper_gulf/grouper_gulf.tres b/fish/species/grouper_gulf/grouper_gulf.tres
new file mode 100644
index 0000000..1e0446e
--- /dev/null
+++ b/fish/species/grouper_gulf/grouper_gulf.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://c5w5e2rkqiaep"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://ctu8xd5or7v3h" path="res://fish/species/grouper_gulf/fish_grouper_gulf.png" id="4_texture"]
+
+[sub_resource type="Resource" id="GrouperGulfAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"grouper_gulf"
+display_name = "gulf grouper"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.3
+catch_profile = ExtResource("2_profile")
+availability = SubResource("GrouperGulfAvailability")
+weight_min_lb = 2
+weight_max_lb = 40
+display_scale_min = 0.85
+display_scale_max = 1.8
+display_scale_curve = 0.9
+sell_value_min = 10
+sell_value_max = 24
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/grouper_red/fish_grouper_red.png b/fish/species/grouper_red/fish_grouper_red.png
new file mode 100644
index 0000000..33b5481
Binary files /dev/null and b/fish/species/grouper_red/fish_grouper_red.png differ
diff --git a/fish/species/grouper_red/fish_grouper_red.png.import b/fish/species/grouper_red/fish_grouper_red.png.import
new file mode 100644
index 0000000..bd8eac9
--- /dev/null
+++ b/fish/species/grouper_red/fish_grouper_red.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://jjcc78tffxhb"
+path="res://.godot/imported/fish_grouper_red.png-6056013819bdaaeb55a98fa0f9d8ca75.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/grouper_red/fish_grouper_red.png"
+dest_files=["res://.godot/imported/fish_grouper_red.png-6056013819bdaaeb55a98fa0f9d8ca75.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/grouper_red/grouper_red.tres b/fish/species/grouper_red/grouper_red.tres
new file mode 100644
index 0000000..5594641
--- /dev/null
+++ b/fish/species/grouper_red/grouper_red.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://ckr1c747mvjl0"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://jjcc78tffxhb" path="res://fish/species/grouper_red/fish_grouper_red.png" id="4_texture"]
+
+[sub_resource type="Resource" id="GrouperRedAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"grouper_red"
+display_name = "red grouper"
+allowed_water_types = 2
+rarity = 2
+base_catch_weight = 0.2
+catch_profile = ExtResource("2_profile")
+availability = SubResource("GrouperRedAvailability")
+weight_min_lb = 2
+weight_max_lb = 30
+display_scale_min = 0.85
+display_scale_max = 2.0
+display_scale_curve = 0.9
+sell_value_min = 16
+sell_value_max = 36
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/mackerel_atlantic/fish_mackerel_atlantic.png b/fish/species/mackerel_atlantic/fish_mackerel_atlantic.png
new file mode 100644
index 0000000..67a2410
Binary files /dev/null and b/fish/species/mackerel_atlantic/fish_mackerel_atlantic.png differ
diff --git a/fish/species/mackerel_atlantic/fish_mackerel_atlantic.png.import b/fish/species/mackerel_atlantic/fish_mackerel_atlantic.png.import
new file mode 100644
index 0000000..424fcda
--- /dev/null
+++ b/fish/species/mackerel_atlantic/fish_mackerel_atlantic.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://breyc8fqq8lq3"
+path="res://.godot/imported/fish_mackerel_atlantic.png-51809cdfc48d2f0c69100567067a64e8.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/mackerel_atlantic/fish_mackerel_atlantic.png"
+dest_files=["res://.godot/imported/fish_mackerel_atlantic.png-51809cdfc48d2f0c69100567067a64e8.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/mackerel_atlantic/mackerel_atlantic.tres b/fish/species/mackerel_atlantic/mackerel_atlantic.tres
new file mode 100644
index 0000000..514ef07
--- /dev/null
+++ b/fish/species/mackerel_atlantic/mackerel_atlantic.tres
@@ -0,0 +1,27 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cm8hgt2qubpi8"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://breyc8fqq8lq3" path="res://fish/species/mackerel_atlantic/fish_mackerel_atlantic.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MackerelAtlanticAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"mackerel_atlantic"
+display_name = "atlantic mackerel"
+allowed_water_types = 2
+base_catch_weight = 0.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MackerelAtlanticAvailability")
+weight_max_lb = 5
+display_scale_min = 0.85
+display_scale_max = 1.4
+display_scale_curve = 0.9
+sell_value_min = 4
+sell_value_max = 9
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/mackerel_cero/fish_mackerel_cero.png b/fish/species/mackerel_cero/fish_mackerel_cero.png
new file mode 100644
index 0000000..82cb340
Binary files /dev/null and b/fish/species/mackerel_cero/fish_mackerel_cero.png differ
diff --git a/fish/species/mackerel_cero/fish_mackerel_cero.png.import b/fish/species/mackerel_cero/fish_mackerel_cero.png.import
new file mode 100644
index 0000000..131c8fc
--- /dev/null
+++ b/fish/species/mackerel_cero/fish_mackerel_cero.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://g8qanq47g1b8"
+path="res://.godot/imported/fish_mackerel_cero.png-8da33920501446c8e4d87a07f02f0710.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/mackerel_cero/fish_mackerel_cero.png"
+dest_files=["res://.godot/imported/fish_mackerel_cero.png-8da33920501446c8e4d87a07f02f0710.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/mackerel_cero/mackerel_cero.tres b/fish/species/mackerel_cero/mackerel_cero.tres
new file mode 100644
index 0000000..fbbb612
--- /dev/null
+++ b/fish/species/mackerel_cero/mackerel_cero.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://du4ixk4sssvac"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://g8qanq47g1b8" path="res://fish/species/mackerel_cero/fish_mackerel_cero.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MackerelCeroAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"mackerel_cero"
+display_name = "cero mackerel"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.35
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MackerelCeroAvailability")
+weight_min_lb = 1
+weight_max_lb = 15
+display_scale_min = 0.85
+display_scale_max = 1.5
+display_scale_curve = 0.9
+sell_value_min = 7
+sell_value_max = 15
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/mackerel_chub/fish_mackerel_chub.png b/fish/species/mackerel_chub/fish_mackerel_chub.png
new file mode 100644
index 0000000..c0687ff
Binary files /dev/null and b/fish/species/mackerel_chub/fish_mackerel_chub.png differ
diff --git a/fish/species/mackerel_chub/fish_mackerel_chub.png.import b/fish/species/mackerel_chub/fish_mackerel_chub.png.import
new file mode 100644
index 0000000..d6be52b
--- /dev/null
+++ b/fish/species/mackerel_chub/fish_mackerel_chub.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b7q2t02oo2bcp"
+path="res://.godot/imported/fish_mackerel_chub.png-16a10a6e686dbb920ba5ae98c9dd10a8.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/mackerel_chub/fish_mackerel_chub.png"
+dest_files=["res://.godot/imported/fish_mackerel_chub.png-16a10a6e686dbb920ba5ae98c9dd10a8.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/mackerel_chub/mackerel_chub.tres b/fish/species/mackerel_chub/mackerel_chub.tres
new file mode 100644
index 0000000..f4ceca1
--- /dev/null
+++ b/fish/species/mackerel_chub/mackerel_chub.tres
@@ -0,0 +1,27 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b6lga2c60qkc4"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://b7q2t02oo2bcp" path="res://fish/species/mackerel_chub/fish_mackerel_chub.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MackerelChubAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"mackerel_chub"
+display_name = "chub mackerel"
+allowed_water_types = 2
+base_catch_weight = 0.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MackerelChubAvailability")
+weight_max_lb = 4
+display_scale_min = 0.85
+display_scale_max = 1.4
+display_scale_curve = 0.9
+sell_value_min = 4
+sell_value_max = 9
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/mackerel_king/fish_mackerel_king.png b/fish/species/mackerel_king/fish_mackerel_king.png
new file mode 100644
index 0000000..01fd603
Binary files /dev/null and b/fish/species/mackerel_king/fish_mackerel_king.png differ
diff --git a/fish/species/mackerel_king/fish_mackerel_king.png.import b/fish/species/mackerel_king/fish_mackerel_king.png.import
new file mode 100644
index 0000000..213307a
--- /dev/null
+++ b/fish/species/mackerel_king/fish_mackerel_king.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b2fag3xemyx5s"
+path="res://.godot/imported/fish_mackerel_king.png-6cd4b6aef031a72436383f7e75f54cc9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/mackerel_king/fish_mackerel_king.png"
+dest_files=["res://.godot/imported/fish_mackerel_king.png-6cd4b6aef031a72436383f7e75f54cc9.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/mackerel_king/mackerel_king.tres b/fish/species/mackerel_king/mackerel_king.tres
new file mode 100644
index 0000000..2c28071
--- /dev/null
+++ b/fish/species/mackerel_king/mackerel_king.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://bacndgfbn8oj7"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://b2fag3xemyx5s" path="res://fish/species/mackerel_king/fish_mackerel_king.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MackerelKingAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"mackerel_king"
+display_name = "king mackerel"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.3
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MackerelKingAvailability")
+weight_min_lb = 3
+weight_max_lb = 40
+display_scale_min = 0.85
+display_scale_max = 1.8
+display_scale_curve = 0.9
+sell_value_min = 10
+sell_value_max = 24
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/mackerel_spanish/fish_mackerel_spanish.png b/fish/species/mackerel_spanish/fish_mackerel_spanish.png
new file mode 100644
index 0000000..2a97bed
Binary files /dev/null and b/fish/species/mackerel_spanish/fish_mackerel_spanish.png differ
diff --git a/fish/species/mackerel_spanish/fish_mackerel_spanish.png.import b/fish/species/mackerel_spanish/fish_mackerel_spanish.png.import
new file mode 100644
index 0000000..00d8314
--- /dev/null
+++ b/fish/species/mackerel_spanish/fish_mackerel_spanish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cy2hmpenpkee2"
+path="res://.godot/imported/fish_mackerel_spanish.png-14336b47fbd7fc1f1bcf0ce301b2b130.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/mackerel_spanish/fish_mackerel_spanish.png"
+dest_files=["res://.godot/imported/fish_mackerel_spanish.png-14336b47fbd7fc1f1bcf0ce301b2b130.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/mackerel_spanish/mackerel_spanish.tres b/fish/species/mackerel_spanish/mackerel_spanish.tres
new file mode 100644
index 0000000..0319917
--- /dev/null
+++ b/fish/species/mackerel_spanish/mackerel_spanish.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cg7y0numxuy3e"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://cy2hmpenpkee2" path="res://fish/species/mackerel_spanish/fish_mackerel_spanish.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MackerelSpanishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"mackerel_spanish"
+display_name = "spanish mackerel"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.35
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MackerelSpanishAvailability")
+weight_min_lb = 1
+weight_max_lb = 15
+display_scale_min = 0.85
+display_scale_max = 1.6
+display_scale_curve = 0.9
+sell_value_min = 8
+sell_value_max = 18
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/marlin_black/fish_marlin_black.png b/fish/species/marlin_black/fish_marlin_black.png
new file mode 100644
index 0000000..6cbcccd
Binary files /dev/null and b/fish/species/marlin_black/fish_marlin_black.png differ
diff --git a/fish/species/marlin_black/fish_marlin_black.png.import b/fish/species/marlin_black/fish_marlin_black.png.import
new file mode 100644
index 0000000..429803e
--- /dev/null
+++ b/fish/species/marlin_black/fish_marlin_black.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dkdctr2o52vrb"
+path="res://.godot/imported/fish_marlin_black.png-8e8b83d60a3f56d5d14f9e7c3ce2a9f9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/marlin_black/fish_marlin_black.png"
+dest_files=["res://.godot/imported/fish_marlin_black.png-8e8b83d60a3f56d5d14f9e7c3ce2a9f9.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/marlin_black/marlin_black.tres b/fish/species/marlin_black/marlin_black.tres
new file mode 100644
index 0000000..39110c5
--- /dev/null
+++ b/fish/species/marlin_black/marlin_black.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://dlm608g365aa3"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://dkdctr2o52vrb" path="res://fish/species/marlin_black/fish_marlin_black.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MarlinBlackAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"marlin_black"
+display_name = "black marlin"
+allowed_water_types = 2
+rarity = 3
+base_catch_weight = 0.08
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MarlinBlackAvailability")
+weight_min_lb = 80
+weight_max_lb = 700
+display_scale_min = 0.85
+display_scale_max = 2.8
+display_scale_curve = 0.9
+sell_value_min = 45
+sell_value_max = 90
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/marlin_blue/fish_marlin_blue.png b/fish/species/marlin_blue/fish_marlin_blue.png
new file mode 100644
index 0000000..7df059f
Binary files /dev/null and b/fish/species/marlin_blue/fish_marlin_blue.png differ
diff --git a/fish/species/marlin_blue/fish_marlin_blue.png.import b/fish/species/marlin_blue/fish_marlin_blue.png.import
new file mode 100644
index 0000000..73053d8
--- /dev/null
+++ b/fish/species/marlin_blue/fish_marlin_blue.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://de35fjoytbomu"
+path="res://.godot/imported/fish_marlin_blue.png-268aff4aa2760732f42f94b881681364.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/marlin_blue/fish_marlin_blue.png"
+dest_files=["res://.godot/imported/fish_marlin_blue.png-268aff4aa2760732f42f94b881681364.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/marlin_blue/marlin_blue.tres b/fish/species/marlin_blue/marlin_blue.tres
new file mode 100644
index 0000000..b3328ba
--- /dev/null
+++ b/fish/species/marlin_blue/marlin_blue.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b15syao6ighl4"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://de35fjoytbomu" path="res://fish/species/marlin_blue/fish_marlin_blue.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MarlinBlueAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"marlin_blue"
+display_name = "blue marlin"
+allowed_water_types = 2
+rarity = 4
+base_catch_weight = 0.05
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MarlinBlueAvailability")
+weight_min_lb = 100
+weight_max_lb = 1000
+display_scale_min = 0.85
+display_scale_max = 3.0
+display_scale_curve = 0.9
+sell_value_min = 60
+sell_value_max = 120
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/marlin_white/fish_marlin_white.png b/fish/species/marlin_white/fish_marlin_white.png
new file mode 100644
index 0000000..76fb780
Binary files /dev/null and b/fish/species/marlin_white/fish_marlin_white.png differ
diff --git a/fish/species/marlin_white/fish_marlin_white.png.import b/fish/species/marlin_white/fish_marlin_white.png.import
new file mode 100644
index 0000000..1ec83d0
--- /dev/null
+++ b/fish/species/marlin_white/fish_marlin_white.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdvy4ujx2htcy"
+path="res://.godot/imported/fish_marlin_white.png-a5c5fec8891d945d6565d5097e312c31.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/marlin_white/fish_marlin_white.png"
+dest_files=["res://.godot/imported/fish_marlin_white.png-a5c5fec8891d945d6565d5097e312c31.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/marlin_white/marlin_white.tres b/fish/species/marlin_white/marlin_white.tres
new file mode 100644
index 0000000..e90651c
--- /dev/null
+++ b/fish/species/marlin_white/marlin_white.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://jjxr31djus2h"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bdvy4ujx2htcy" path="res://fish/species/marlin_white/fish_marlin_white.png" id="4_texture"]
+
+[sub_resource type="Resource" id="MarlinWhiteAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"marlin_white"
+display_name = "white marlin"
+allowed_water_types = 2
+rarity = 3
+base_catch_weight = 0.1
+catch_profile = ExtResource("2_profile")
+availability = SubResource("MarlinWhiteAvailability")
+weight_min_lb = 30
+weight_max_lb = 180
+display_scale_min = 0.85
+display_scale_max = 2.5
+display_scale_curve = 0.9
+sell_value_min = 35
+sell_value_max = 70
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/pomfret_black/fish_pomfret_black.png b/fish/species/pomfret_black/fish_pomfret_black.png
new file mode 100644
index 0000000..14ae878
Binary files /dev/null and b/fish/species/pomfret_black/fish_pomfret_black.png differ
diff --git a/fish/species/pomfret_black/fish_pomfret_black.png.import b/fish/species/pomfret_black/fish_pomfret_black.png.import
new file mode 100644
index 0000000..c8748a8
--- /dev/null
+++ b/fish/species/pomfret_black/fish_pomfret_black.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ch3y6xdo22j1o"
+path="res://.godot/imported/fish_pomfret_black.png-f757c46fb76cb4f9eee68c3143813d3f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/pomfret_black/fish_pomfret_black.png"
+dest_files=["res://.godot/imported/fish_pomfret_black.png-f757c46fb76cb4f9eee68c3143813d3f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/pomfret_black/pomfret_black.tres b/fish/species/pomfret_black/pomfret_black.tres
new file mode 100644
index 0000000..8020100
--- /dev/null
+++ b/fish/species/pomfret_black/pomfret_black.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://bsm1itwx50p1q"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://ch3y6xdo22j1o" path="res://fish/species/pomfret_black/fish_pomfret_black.png" id="4_texture"]
+
+[sub_resource type="Resource" id="PomfretBlackAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"pomfret_black"
+display_name = "black pomfret"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.3
+catch_profile = ExtResource("2_profile")
+availability = SubResource("PomfretBlackAvailability")
+weight_min_lb = 1
+weight_max_lb = 15
+display_scale_min = 0.85
+display_scale_max = 1.8
+display_scale_curve = 0.9
+sell_value_min = 8
+sell_value_max = 18
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/pomfret_chinese/fish_pomfret_chinese.png b/fish/species/pomfret_chinese/fish_pomfret_chinese.png
new file mode 100644
index 0000000..a7bb55e
Binary files /dev/null and b/fish/species/pomfret_chinese/fish_pomfret_chinese.png differ
diff --git a/fish/species/pomfret_chinese/fish_pomfret_chinese.png.import b/fish/species/pomfret_chinese/fish_pomfret_chinese.png.import
new file mode 100644
index 0000000..cdf2c5b
--- /dev/null
+++ b/fish/species/pomfret_chinese/fish_pomfret_chinese.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cijr0gseldncc"
+path="res://.godot/imported/fish_pomfret_chinese.png-a68ccb2e407266ab98980bb3087760ad.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/pomfret_chinese/fish_pomfret_chinese.png"
+dest_files=["res://.godot/imported/fish_pomfret_chinese.png-a68ccb2e407266ab98980bb3087760ad.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/pomfret_chinese/pomfret_chinese.tres b/fish/species/pomfret_chinese/pomfret_chinese.tres
new file mode 100644
index 0000000..2eaf34b
--- /dev/null
+++ b/fish/species/pomfret_chinese/pomfret_chinese.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://di8qq1hek8cpo"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://cijr0gseldncc" path="res://fish/species/pomfret_chinese/fish_pomfret_chinese.png" id="4_texture"]
+
+[sub_resource type="Resource" id="PomfretChineseAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"pomfret_chinese"
+display_name = "chinese pomfret"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.3
+catch_profile = ExtResource("2_profile")
+availability = SubResource("PomfretChineseAvailability")
+weight_min_lb = 1
+weight_max_lb = 20
+display_scale_min = 0.85
+display_scale_max = 1.9
+display_scale_curve = 0.9
+sell_value_min = 9
+sell_value_max = 20
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/pomfret_golden/fish_pomfret_golden.png b/fish/species/pomfret_golden/fish_pomfret_golden.png
new file mode 100644
index 0000000..a844b87
Binary files /dev/null and b/fish/species/pomfret_golden/fish_pomfret_golden.png differ
diff --git a/fish/species/pomfret_golden/fish_pomfret_golden.png.import b/fish/species/pomfret_golden/fish_pomfret_golden.png.import
new file mode 100644
index 0000000..bd77922
--- /dev/null
+++ b/fish/species/pomfret_golden/fish_pomfret_golden.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://7rjv7k60bv7q"
+path="res://.godot/imported/fish_pomfret_golden.png-932aebb2b7e0fef891368bcd8e2ca1c7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/pomfret_golden/fish_pomfret_golden.png"
+dest_files=["res://.godot/imported/fish_pomfret_golden.png-932aebb2b7e0fef891368bcd8e2ca1c7.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/pomfret_golden/pomfret_golden.tres b/fish/species/pomfret_golden/pomfret_golden.tres
new file mode 100644
index 0000000..da1f561
--- /dev/null
+++ b/fish/species/pomfret_golden/pomfret_golden.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b5ivfnfidlu81"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://7rjv7k60bv7q" path="res://fish/species/pomfret_golden/fish_pomfret_golden.png" id="4_texture"]
+
+[sub_resource type="Resource" id="PomfretGoldenAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"pomfret_golden"
+display_name = "golden pomfret"
+allowed_water_types = 2
+rarity = 2
+base_catch_weight = 0.18
+catch_profile = ExtResource("2_profile")
+availability = SubResource("PomfretGoldenAvailability")
+weight_min_lb = 2
+weight_max_lb = 25
+display_scale_min = 0.85
+display_scale_max = 2.1
+display_scale_curve = 0.9
+sell_value_min = 14
+sell_value_max = 30
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/pomfret_white/fish_pomfret_white.png b/fish/species/pomfret_white/fish_pomfret_white.png
new file mode 100644
index 0000000..0f2d426
Binary files /dev/null and b/fish/species/pomfret_white/fish_pomfret_white.png differ
diff --git a/fish/species/pomfret_white/fish_pomfret_white.png.import b/fish/species/pomfret_white/fish_pomfret_white.png.import
new file mode 100644
index 0000000..b3da3f2
--- /dev/null
+++ b/fish/species/pomfret_white/fish_pomfret_white.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c3fr0x4e1nfe0"
+path="res://.godot/imported/fish_pomfret_white.png-10c529e20d581d581fc12328c7d152fe.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/pomfret_white/fish_pomfret_white.png"
+dest_files=["res://.godot/imported/fish_pomfret_white.png-10c529e20d581d581fc12328c7d152fe.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/pomfret_white/pomfret_white.tres b/fish/species/pomfret_white/pomfret_white.tres
new file mode 100644
index 0000000..fa317c7
--- /dev/null
+++ b/fish/species/pomfret_white/pomfret_white.tres
@@ -0,0 +1,27 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://6rfka15wc2iy"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://c3fr0x4e1nfe0" path="res://fish/species/pomfret_white/fish_pomfret_white.png" id="4_texture"]
+
+[sub_resource type="Resource" id="PomfretWhiteAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"pomfret_white"
+display_name = "white pomfret"
+allowed_water_types = 2
+base_catch_weight = 0.45
+catch_profile = ExtResource("2_profile")
+availability = SubResource("PomfretWhiteAvailability")
+weight_max_lb = 10
+display_scale_min = 0.85
+display_scale_max = 1.6
+display_scale_curve = 0.9
+sell_value_min = 5
+sell_value_max = 12
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/sailfish/fish_sailfish.png b/fish/species/sailfish/fish_sailfish.png
new file mode 100644
index 0000000..e359a3e
Binary files /dev/null and b/fish/species/sailfish/fish_sailfish.png differ
diff --git a/fish/species/sailfish/fish_sailfish.png.import b/fish/species/sailfish/fish_sailfish.png.import
new file mode 100644
index 0000000..ee25011
--- /dev/null
+++ b/fish/species/sailfish/fish_sailfish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8rlcnfqx8or0"
+path="res://.godot/imported/fish_sailfish.png-cc20ed43042eb2d6867954aa1a1772da.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/sailfish/fish_sailfish.png"
+dest_files=["res://.godot/imported/fish_sailfish.png-cc20ed43042eb2d6867954aa1a1772da.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/sailfish/sailfish.tres b/fish/species/sailfish/sailfish.tres
new file mode 100644
index 0000000..7f6bea9
--- /dev/null
+++ b/fish/species/sailfish/sailfish.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b3auce81mglkx"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://8rlcnfqx8or0" path="res://fish/species/sailfish/fish_sailfish.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SailfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"sailfish"
+display_name = "sailfish"
+allowed_water_types = 2
+rarity = 3
+base_catch_weight = 0.1
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SailfishAvailability")
+weight_min_lb = 30
+weight_max_lb = 200
+display_scale_min = 0.85
+display_scale_max = 2.6
+display_scale_curve = 0.9
+sell_value_min = 38
+sell_value_max = 78
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/salmon_atlantic/fish_salmon_atlantic.png b/fish/species/salmon_atlantic/fish_salmon_atlantic.png
new file mode 100644
index 0000000..ea6164b
Binary files /dev/null and b/fish/species/salmon_atlantic/fish_salmon_atlantic.png differ
diff --git a/fish/species/salmon_atlantic/fish_salmon_atlantic.png.import b/fish/species/salmon_atlantic/fish_salmon_atlantic.png.import
new file mode 100644
index 0000000..36fb242
--- /dev/null
+++ b/fish/species/salmon_atlantic/fish_salmon_atlantic.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ddbclfu5wtd5r"
+path="res://.godot/imported/fish_salmon_atlantic.png-e11e70a37a78573bfb86a21b48ae319b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/salmon_atlantic/fish_salmon_atlantic.png"
+dest_files=["res://.godot/imported/fish_salmon_atlantic.png-e11e70a37a78573bfb86a21b48ae319b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/salmon_atlantic/salmon_atlantic.tres b/fish/species/salmon_atlantic/salmon_atlantic.tres
new file mode 100644
index 0000000..fa3f0d6
--- /dev/null
+++ b/fish/species/salmon_atlantic/salmon_atlantic.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b5ewuclck75yr"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://ddbclfu5wtd5r" path="res://fish/species/salmon_atlantic/fish_salmon_atlantic.png" id="4_texture"]
+
+[sub_resource type="Resource" id="AtlanticSalmonAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"salmon_atlantic"
+display_name = "atlantic salmon"
+allowed_water_types = 2
+rarity = 3
+base_catch_weight = 0.65
+catch_profile = ExtResource("2_profile")
+availability = SubResource("AtlanticSalmonAvailability")
+weight_min_lb = 8.0
+weight_max_lb = 40.0
+display_scale_min = 1.05
+display_scale_max = 1.9
+display_scale_curve = 0.85
+sell_value_min = 20
+sell_value_max = 42
+sell_value_curve = 0.85
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/salmon_chum/fish_salmon_chum.png b/fish/species/salmon_chum/fish_salmon_chum.png
new file mode 100644
index 0000000..920b39d
Binary files /dev/null and b/fish/species/salmon_chum/fish_salmon_chum.png differ
diff --git a/fish/species/salmon_chum/fish_salmon_chum.png.import b/fish/species/salmon_chum/fish_salmon_chum.png.import
new file mode 100644
index 0000000..587b83b
--- /dev/null
+++ b/fish/species/salmon_chum/fish_salmon_chum.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dqjkmd6l1a2i4"
+path="res://.godot/imported/fish_salmon_chum.png-78c86aca1218255db2c488a5c22e4409.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/salmon_chum/fish_salmon_chum.png"
+dest_files=["res://.godot/imported/fish_salmon_chum.png-78c86aca1218255db2c488a5c22e4409.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/salmon_chum/salmon_chum.tres b/fish/species/salmon_chum/salmon_chum.tres
new file mode 100644
index 0000000..748295b
--- /dev/null
+++ b/fish/species/salmon_chum/salmon_chum.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://dhddj6grq203c"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://dqjkmd6l1a2i4" path="res://fish/species/salmon_chum/fish_salmon_chum.png" id="4_texture"]
+
+[sub_resource type="Resource" id="ChumSalmonAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"salmon_chum"
+display_name = "chum salmon"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 2.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("ChumSalmonAvailability")
+weight_min_lb = 6.0
+weight_max_lb = 25.0
+display_scale_min = 1.0
+display_scale_max = 1.75
+display_scale_curve = 0.9
+sell_value_min = 10
+sell_value_max = 22
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/salmon_coho/fish_salmon_coho.png b/fish/species/salmon_coho/fish_salmon_coho.png
new file mode 100644
index 0000000..2caf696
Binary files /dev/null and b/fish/species/salmon_coho/fish_salmon_coho.png differ
diff --git a/fish/species/salmon_coho/fish_salmon_coho.png.import b/fish/species/salmon_coho/fish_salmon_coho.png.import
new file mode 100644
index 0000000..1cd3254
--- /dev/null
+++ b/fish/species/salmon_coho/fish_salmon_coho.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c4c4e43lbr88c"
+path="res://.godot/imported/fish_salmon_coho.png-fc57b7ff3c0ac63ccfec6db9cec1dedb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/salmon_coho/fish_salmon_coho.png"
+dest_files=["res://.godot/imported/fish_salmon_coho.png-fc57b7ff3c0ac63ccfec6db9cec1dedb.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/salmon_coho/salmon_coho.tres b/fish/species/salmon_coho/salmon_coho.tres
new file mode 100644
index 0000000..1c1a783
--- /dev/null
+++ b/fish/species/salmon_coho/salmon_coho.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://nw1k2c7vtqtt"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://c4c4e43lbr88c" path="res://fish/species/salmon_coho/fish_salmon_coho.png" id="4_texture"]
+
+[sub_resource type="Resource" id="CohoSalmonAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"salmon_coho"
+display_name = "coho salmon"
+allowed_water_types = 2
+rarity = 2
+base_catch_weight = 1.4
+catch_profile = ExtResource("2_profile")
+availability = SubResource("CohoSalmonAvailability")
+weight_min_lb = 5.0
+weight_max_lb = 30.0
+display_scale_min = 1.0
+display_scale_max = 1.8
+display_scale_curve = 0.88
+sell_value_min = 14
+sell_value_max = 28
+sell_value_curve = 0.88
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/salmon_pink/fish_salmon_pink.png b/fish/species/salmon_pink/fish_salmon_pink.png
new file mode 100644
index 0000000..85b1d88
Binary files /dev/null and b/fish/species/salmon_pink/fish_salmon_pink.png differ
diff --git a/fish/species/salmon_pink/fish_salmon_pink.png.import b/fish/species/salmon_pink/fish_salmon_pink.png.import
new file mode 100644
index 0000000..caf4c74
--- /dev/null
+++ b/fish/species/salmon_pink/fish_salmon_pink.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bk27bsdrtlqap"
+path="res://.godot/imported/fish_salmon_pink.png-0c9ca3bc540b868c12fe32b1cdc59249.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/salmon_pink/fish_salmon_pink.png"
+dest_files=["res://.godot/imported/fish_salmon_pink.png-0c9ca3bc540b868c12fe32b1cdc59249.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/salmon_pink/salmon_pink.tres b/fish/species/salmon_pink/salmon_pink.tres
new file mode 100644
index 0000000..d390cf1
--- /dev/null
+++ b/fish/species/salmon_pink/salmon_pink.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cq0ikmuba6j73"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bk27bsdrtlqap" path="res://fish/species/salmon_pink/fish_salmon_pink.png" id="4_texture"]
+
+[sub_resource type="Resource" id="PinkSalmonAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"salmon_pink"
+display_name = "pink salmon"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 3.0
+catch_profile = ExtResource("2_profile")
+availability = SubResource("PinkSalmonAvailability")
+weight_min_lb = 3.0
+weight_max_lb = 12.0
+display_scale_min = 0.9
+display_scale_max = 1.55
+display_scale_curve = 0.92
+sell_value_min = 8
+sell_value_max = 15
+sell_value_curve = 0.92
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/salmon_sockeye/fish_salmon_sockeye.png b/fish/species/salmon_sockeye/fish_salmon_sockeye.png
new file mode 100644
index 0000000..bb98433
Binary files /dev/null and b/fish/species/salmon_sockeye/fish_salmon_sockeye.png differ
diff --git a/fish/species/salmon_sockeye/fish_salmon_sockeye.png.import b/fish/species/salmon_sockeye/fish_salmon_sockeye.png.import
new file mode 100644
index 0000000..71a8856
--- /dev/null
+++ b/fish/species/salmon_sockeye/fish_salmon_sockeye.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmokm3sra4ai8"
+path="res://.godot/imported/fish_salmon_sockeye.png-99d566111b5cad1e6ae4702b6f9128fd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/salmon_sockeye/fish_salmon_sockeye.png"
+dest_files=["res://.godot/imported/fish_salmon_sockeye.png-99d566111b5cad1e6ae4702b6f9128fd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/salmon_sockeye/salmon_sockeye.tres b/fish/species/salmon_sockeye/salmon_sockeye.tres
new file mode 100644
index 0000000..deeda39
--- /dev/null
+++ b/fish/species/salmon_sockeye/salmon_sockeye.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://bf2ej6rgypsqx"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://cmokm3sra4ai8" path="res://fish/species/salmon_sockeye/fish_salmon_sockeye.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SockeyeSalmonAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"salmon_sockeye"
+display_name = "sockeye salmon"
+allowed_water_types = 2
+rarity = 2
+base_catch_weight = 1.6
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SockeyeSalmonAvailability")
+weight_min_lb = 4.0
+weight_max_lb = 15.0
+display_scale_min = 0.95
+display_scale_max = 1.6
+display_scale_curve = 0.9
+sell_value_min = 12
+sell_value_max = 24
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/sauger/fish_sauger.png b/fish/species/sauger/fish_sauger.png
new file mode 100644
index 0000000..c4e2763
Binary files /dev/null and b/fish/species/sauger/fish_sauger.png differ
diff --git a/fish/species/sauger/fish_sauger.png.import b/fish/species/sauger/fish_sauger.png.import
new file mode 100644
index 0000000..02110fe
--- /dev/null
+++ b/fish/species/sauger/fish_sauger.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8k7qkk4juciy"
+path="res://.godot/imported/fish_sauger.png-9b3abc6f8648ebbd51a56abeedbb4826.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/sauger/fish_sauger.png"
+dest_files=["res://.godot/imported/fish_sauger.png-9b3abc6f8648ebbd51a56abeedbb4826.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/sauger/sauger.tres b/fish/species/sauger/sauger.tres
new file mode 100644
index 0000000..49cf5ad
--- /dev/null
+++ b/fish/species/sauger/sauger.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://suhm4yemc0vf"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://8k7qkk4juciy" path="res://fish/species/sauger/fish_sauger.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SaugerAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"sauger"
+display_name = "sauger"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 0.35
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SaugerAvailability")
+weight_max_lb = 8
+display_scale_min = 0.85
+display_scale_max = 1.4
+display_scale_curve = 0.9
+sell_value_min = 6
+sell_value_max = 12
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/saugeye/fish_saugeye.png b/fish/species/saugeye/fish_saugeye.png
new file mode 100644
index 0000000..0674902
Binary files /dev/null and b/fish/species/saugeye/fish_saugeye.png differ
diff --git a/fish/species/saugeye/fish_saugeye.png.import b/fish/species/saugeye/fish_saugeye.png.import
new file mode 100644
index 0000000..6011e7b
--- /dev/null
+++ b/fish/species/saugeye/fish_saugeye.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://lom25y0eqcw3"
+path="res://.godot/imported/fish_saugeye.png-638130dc7e3f534cf48d3da80fc2c3df.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/saugeye/fish_saugeye.png"
+dest_files=["res://.godot/imported/fish_saugeye.png-638130dc7e3f534cf48d3da80fc2c3df.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/saugeye/saugeye.tres b/fish/species/saugeye/saugeye.tres
new file mode 100644
index 0000000..e834005
--- /dev/null
+++ b/fish/species/saugeye/saugeye.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://oecfrxf7vgh0"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://lom25y0eqcw3" path="res://fish/species/saugeye/fish_saugeye.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SaugeyeAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"saugeye"
+display_name = "saugeye"
+allowed_water_types = 1
+rarity = 2
+base_catch_weight = 0.2
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SaugeyeAvailability")
+weight_max_lb = 12
+display_scale_min = 0.85
+display_scale_max = 1.6
+display_scale_curve = 0.9
+sell_value_min = 9
+sell_value_max = 18
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/snapper_lane/fish_snapper_lane.png b/fish/species/snapper_lane/fish_snapper_lane.png
new file mode 100644
index 0000000..afedbc3
Binary files /dev/null and b/fish/species/snapper_lane/fish_snapper_lane.png differ
diff --git a/fish/species/snapper_lane/fish_snapper_lane.png.import b/fish/species/snapper_lane/fish_snapper_lane.png.import
new file mode 100644
index 0000000..323b6a0
--- /dev/null
+++ b/fish/species/snapper_lane/fish_snapper_lane.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d3d8s30bxq4tp"
+path="res://.godot/imported/fish_snapper_lane.png-e491c67d8214edb53b8a2005c274c743.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/snapper_lane/fish_snapper_lane.png"
+dest_files=["res://.godot/imported/fish_snapper_lane.png-e491c67d8214edb53b8a2005c274c743.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/snapper_lane/snapper_lane.tres b/fish/species/snapper_lane/snapper_lane.tres
new file mode 100644
index 0000000..e9e7422
--- /dev/null
+++ b/fish/species/snapper_lane/snapper_lane.tres
@@ -0,0 +1,27 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://b7t3fuykfa6ri"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://d3d8s30bxq4tp" path="res://fish/species/snapper_lane/fish_snapper_lane.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SnapperLaneAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"snapper_lane"
+display_name = "lane snapper"
+allowed_water_types = 2
+base_catch_weight = 0.45
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SnapperLaneAvailability")
+weight_max_lb = 5
+display_scale_min = 0.85
+display_scale_max = 1.5
+display_scale_curve = 0.9
+sell_value_min = 5
+sell_value_max = 11
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/snapper_mangrove/fish_snapper_mangrove.png b/fish/species/snapper_mangrove/fish_snapper_mangrove.png
new file mode 100644
index 0000000..758ee16
Binary files /dev/null and b/fish/species/snapper_mangrove/fish_snapper_mangrove.png differ
diff --git a/fish/species/snapper_mangrove/fish_snapper_mangrove.png.import b/fish/species/snapper_mangrove/fish_snapper_mangrove.png.import
new file mode 100644
index 0000000..7567a29
--- /dev/null
+++ b/fish/species/snapper_mangrove/fish_snapper_mangrove.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://v34sdv1v7c2x"
+path="res://.godot/imported/fish_snapper_mangrove.png-fb4a657d3596495207f499095e50e416.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/snapper_mangrove/fish_snapper_mangrove.png"
+dest_files=["res://.godot/imported/fish_snapper_mangrove.png-fb4a657d3596495207f499095e50e416.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/snapper_mangrove/snapper_mangrove.tres b/fish/species/snapper_mangrove/snapper_mangrove.tres
new file mode 100644
index 0000000..58226ad
--- /dev/null
+++ b/fish/species/snapper_mangrove/snapper_mangrove.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://d4eh51kwhsac0"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://v34sdv1v7c2x" path="res://fish/species/snapper_mangrove/fish_snapper_mangrove.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SnapperMangroveAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"snapper_mangrove"
+display_name = "mangrove snapper"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.35
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SnapperMangroveAvailability")
+weight_max_lb = 12
+display_scale_min = 0.85
+display_scale_max = 1.7
+display_scale_curve = 0.9
+sell_value_min = 7
+sell_value_max = 16
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/snapper_mutton/fish_snapper_mutton.png b/fish/species/snapper_mutton/fish_snapper_mutton.png
new file mode 100644
index 0000000..f8de3ff
Binary files /dev/null and b/fish/species/snapper_mutton/fish_snapper_mutton.png differ
diff --git a/fish/species/snapper_mutton/fish_snapper_mutton.png.import b/fish/species/snapper_mutton/fish_snapper_mutton.png.import
new file mode 100644
index 0000000..cc283b6
--- /dev/null
+++ b/fish/species/snapper_mutton/fish_snapper_mutton.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cawvpo8aysw5i"
+path="res://.godot/imported/fish_snapper_mutton.png-c83ebfec373a81cf6c3f34599406aad6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/snapper_mutton/fish_snapper_mutton.png"
+dest_files=["res://.godot/imported/fish_snapper_mutton.png-c83ebfec373a81cf6c3f34599406aad6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/snapper_mutton/snapper_mutton.tres b/fish/species/snapper_mutton/snapper_mutton.tres
new file mode 100644
index 0000000..6d4441a
--- /dev/null
+++ b/fish/species/snapper_mutton/snapper_mutton.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cfvwloicmu53b"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://cawvpo8aysw5i" path="res://fish/species/snapper_mutton/fish_snapper_mutton.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SnapperMuttonAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"snapper_mutton"
+display_name = "mutton snapper"
+allowed_water_types = 2
+rarity = 2
+base_catch_weight = 0.2
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SnapperMuttonAvailability")
+weight_min_lb = 2
+weight_max_lb = 30
+display_scale_min = 0.85
+display_scale_max = 1.9
+display_scale_curve = 0.9
+sell_value_min = 12
+sell_value_max = 28
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/snapper_red/fish_snapper_red.png b/fish/species/snapper_red/fish_snapper_red.png
new file mode 100644
index 0000000..435a95e
Binary files /dev/null and b/fish/species/snapper_red/fish_snapper_red.png differ
diff --git a/fish/species/snapper_red/fish_snapper_red.png.import b/fish/species/snapper_red/fish_snapper_red.png.import
new file mode 100644
index 0000000..6412dfc
--- /dev/null
+++ b/fish/species/snapper_red/fish_snapper_red.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ei5scljuqklk"
+path="res://.godot/imported/fish_snapper_red.png-b66be0003a9d62c443a185008180dd96.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/snapper_red/fish_snapper_red.png"
+dest_files=["res://.godot/imported/fish_snapper_red.png-b66be0003a9d62c443a185008180dd96.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/snapper_red/snapper_red.tres b/fish/species/snapper_red/snapper_red.tres
new file mode 100644
index 0000000..8778c98
--- /dev/null
+++ b/fish/species/snapper_red/snapper_red.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cpvi3raa6epib"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://ei5scljuqklk" path="res://fish/species/snapper_red/fish_snapper_red.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SnapperRedAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"snapper_red"
+display_name = "red snapper"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 0.35
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SnapperRedAvailability")
+weight_min_lb = 1
+weight_max_lb = 25
+display_scale_min = 0.85
+display_scale_max = 1.7
+display_scale_curve = 0.9
+sell_value_min = 8
+sell_value_max = 19
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/sunfish/fish_sunfish.png b/fish/species/sunfish/fish_sunfish.png
index 138699e..4b7b084 100644
Binary files a/fish/species/sunfish/fish_sunfish.png and b/fish/species/sunfish/fish_sunfish.png differ
diff --git a/fish/species/sunfish/sunfish.tres b/fish/species/sunfish/sunfish.tres
index 2346b71..14d2ced 100644
--- a/fish/species/sunfish/sunfish.tres
+++ b/fish/species/sunfish/sunfish.tres
@@ -2,13 +2,14 @@
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
-[ext_resource type="Resource" path="res://fish/default_availability.tres" id="3_availability"]
+[ext_resource type="Resource" path="res://fish/day_availability.tres" id="3_availability"]
[ext_resource type="Texture2D" path="res://fish/species/sunfish/fish_sunfish.png" id="4_texture"]
[resource]
script = ExtResource("1_fish_data")
id = &"sunfish"
display_name = "sunfish"
+allowed_water_types = 2
rarity = 0
base_catch_weight = 7.0
catch_profile = ExtResource("2_profile")
diff --git a/fish/species/swordfish/fish_swordfish.png b/fish/species/swordfish/fish_swordfish.png
new file mode 100644
index 0000000..a43d703
Binary files /dev/null and b/fish/species/swordfish/fish_swordfish.png differ
diff --git a/fish/species/swordfish/fish_swordfish.png.import b/fish/species/swordfish/fish_swordfish.png.import
new file mode 100644
index 0000000..4979773
--- /dev/null
+++ b/fish/species/swordfish/fish_swordfish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cs1qmm4galac4"
+path="res://.godot/imported/fish_swordfish.png-c82f3cdc508ed13c9ec1a5b28b6eb830.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/swordfish/fish_swordfish.png"
+dest_files=["res://.godot/imported/fish_swordfish.png-c82f3cdc508ed13c9ec1a5b28b6eb830.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/swordfish/swordfish.tres b/fish/species/swordfish/swordfish.tres
new file mode 100644
index 0000000..2184395
--- /dev/null
+++ b/fish/species/swordfish/swordfish.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://chqva2v2mtm8q"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://cs1qmm4galac4" path="res://fish/species/swordfish/fish_swordfish.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SwordfishAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"ocean"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"swordfish"
+display_name = "swordfish"
+allowed_water_types = 2
+rarity = 4
+base_catch_weight = 0.06
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SwordfishAvailability")
+weight_min_lb = 50
+weight_max_lb = 1000
+display_scale_min = 0.85
+display_scale_max = 3.0
+display_scale_curve = 0.9
+sell_value_min = 55
+sell_value_max = 110
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/trout_cutthroat/fish_trout_cutthroat.png b/fish/species/trout_cutthroat/fish_trout_cutthroat.png
new file mode 100644
index 0000000..9e45417
Binary files /dev/null and b/fish/species/trout_cutthroat/fish_trout_cutthroat.png differ
diff --git a/fish/species/trout_cutthroat/fish_trout_cutthroat.png.import b/fish/species/trout_cutthroat/fish_trout_cutthroat.png.import
new file mode 100644
index 0000000..90a255e
--- /dev/null
+++ b/fish/species/trout_cutthroat/fish_trout_cutthroat.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://nowggrl8wmr"
+path="res://.godot/imported/fish_trout_cutthroat.png-a27813936c24b235f8eea97a7a7dfdc0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/trout_cutthroat/fish_trout_cutthroat.png"
+dest_files=["res://.godot/imported/fish_trout_cutthroat.png-a27813936c24b235f8eea97a7a7dfdc0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/trout_cutthroat/trout_cutthroat.tres b/fish/species/trout_cutthroat/trout_cutthroat.tres
new file mode 100644
index 0000000..fa29c47
--- /dev/null
+++ b/fish/species/trout_cutthroat/trout_cutthroat.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://bd2t2ktvkkovd"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://nowggrl8wmr" path="res://fish/species/trout_cutthroat/fish_trout_cutthroat.png" id="4_texture"]
+
+[sub_resource type="Resource" id="TroutCutthroatAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"trout_cutthroat"
+display_name = "cutthroat trout"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 0.4
+catch_profile = ExtResource("2_profile")
+availability = SubResource("TroutCutthroatAvailability")
+weight_min_lb = 0.25
+weight_max_lb = 10
+display_scale_min = 0.85
+display_scale_max = 1.4
+display_scale_curve = 0.9
+sell_value_min = 5
+sell_value_max = 12
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/trout_golden/fish_trout_golden.png b/fish/species/trout_golden/fish_trout_golden.png
new file mode 100644
index 0000000..209b82d
Binary files /dev/null and b/fish/species/trout_golden/fish_trout_golden.png differ
diff --git a/fish/species/trout_golden/fish_trout_golden.png.import b/fish/species/trout_golden/fish_trout_golden.png.import
new file mode 100644
index 0000000..bdff181
--- /dev/null
+++ b/fish/species/trout_golden/fish_trout_golden.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b64w3kwf3wins"
+path="res://.godot/imported/fish_trout_golden.png-00b8999ee94694397caec57987d8241d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/trout_golden/fish_trout_golden.png"
+dest_files=["res://.godot/imported/fish_trout_golden.png-00b8999ee94694397caec57987d8241d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/trout_golden/trout_golden.tres b/fish/species/trout_golden/trout_golden.tres
new file mode 100644
index 0000000..8aa3201
--- /dev/null
+++ b/fish/species/trout_golden/trout_golden.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://n3ixatlsfp8y"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://b64w3kwf3wins" path="res://fish/species/trout_golden/fish_trout_golden.png" id="4_texture"]
+
+[sub_resource type="Resource" id="TroutGoldenAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"trout_golden"
+display_name = "golden trout"
+allowed_water_types = 1
+rarity = 2
+base_catch_weight = 0.2
+catch_profile = ExtResource("2_profile")
+availability = SubResource("TroutGoldenAvailability")
+weight_min_lb = 0.2
+weight_max_lb = 3
+display_scale_min = 0.85
+display_scale_curve = 0.9
+sell_value_min = 8
+sell_value_max = 16
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/trout_rainbow/fish_trout_rainbow.png b/fish/species/trout_rainbow/fish_trout_rainbow.png
new file mode 100644
index 0000000..ff6fd06
Binary files /dev/null and b/fish/species/trout_rainbow/fish_trout_rainbow.png differ
diff --git a/fish/species/trout_rainbow/fish_trout_rainbow.png.import b/fish/species/trout_rainbow/fish_trout_rainbow.png.import
new file mode 100644
index 0000000..fd8eafe
--- /dev/null
+++ b/fish/species/trout_rainbow/fish_trout_rainbow.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://mfxehkoxea21"
+path="res://.godot/imported/fish_trout_rainbow.png-e58603ff0df9519b81e6b0d0da55998c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/trout_rainbow/fish_trout_rainbow.png"
+dest_files=["res://.godot/imported/fish_trout_rainbow.png-e58603ff0df9519b81e6b0d0da55998c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/trout_rainbow/trout_rainbow.tres b/fish/species/trout_rainbow/trout_rainbow.tres
new file mode 100644
index 0000000..f9c74f6
--- /dev/null
+++ b/fish/species/trout_rainbow/trout_rainbow.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://c3r8v522etgaq"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://mfxehkoxea21" path="res://fish/species/trout_rainbow/fish_trout_rainbow.png" id="4_texture"]
+
+[sub_resource type="Resource" id="TroutRainbowAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"trout_rainbow"
+display_name = "rainbow trout"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 0.45
+catch_profile = ExtResource("2_profile")
+availability = SubResource("TroutRainbowAvailability")
+weight_max_lb = 20
+display_scale_min = 0.85
+display_scale_max = 1.4
+display_scale_curve = 0.9
+sell_value_min = 5
+sell_value_max = 11
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/trout_steelhead/fish_trout_steelhead.png b/fish/species/trout_steelhead/fish_trout_steelhead.png
new file mode 100644
index 0000000..b2600e0
Binary files /dev/null and b/fish/species/trout_steelhead/fish_trout_steelhead.png differ
diff --git a/fish/species/trout_steelhead/fish_trout_steelhead.png.import b/fish/species/trout_steelhead/fish_trout_steelhead.png.import
new file mode 100644
index 0000000..3b3200e
--- /dev/null
+++ b/fish/species/trout_steelhead/fish_trout_steelhead.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cabbpc55sf2gw"
+path="res://.godot/imported/fish_trout_steelhead.png-10d14e8a4754db88ccdaf7ffe4e4d9fe.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/trout_steelhead/fish_trout_steelhead.png"
+dest_files=["res://.godot/imported/fish_trout_steelhead.png-10d14e8a4754db88ccdaf7ffe4e4d9fe.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/trout_steelhead/trout_steelhead.tres b/fish/species/trout_steelhead/trout_steelhead.tres
new file mode 100644
index 0000000..57887f7
--- /dev/null
+++ b/fish/species/trout_steelhead/trout_steelhead.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://dv2hjtqd5xhfu"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://cabbpc55sf2gw" path="res://fish/species/trout_steelhead/fish_trout_steelhead.png" id="4_texture"]
+
+[sub_resource type="Resource" id="TroutSteelheadAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"trout_steelhead"
+display_name = "steelhead trout"
+allowed_water_types = 1
+rarity = 2
+base_catch_weight = 0.2
+catch_profile = ExtResource("2_profile")
+availability = SubResource("TroutSteelheadAvailability")
+weight_min_lb = 2
+weight_max_lb = 35
+display_scale_min = 0.85
+display_scale_max = 1.8
+display_scale_curve = 0.9
+sell_value_min = 10
+sell_value_max = 20
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/tuna_albacore/fish_tuna_albacore.png b/fish/species/tuna_albacore/fish_tuna_albacore.png
new file mode 100644
index 0000000..3769c08
Binary files /dev/null and b/fish/species/tuna_albacore/fish_tuna_albacore.png differ
diff --git a/fish/species/tuna_albacore/fish_tuna_albacore.png.import b/fish/species/tuna_albacore/fish_tuna_albacore.png.import
new file mode 100644
index 0000000..f2e6b6f
--- /dev/null
+++ b/fish/species/tuna_albacore/fish_tuna_albacore.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bxebn81i552fg"
+path="res://.godot/imported/fish_tuna_albacore.png-aaac5d00c8a250b19db99d7069f549e1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/tuna_albacore/fish_tuna_albacore.png"
+dest_files=["res://.godot/imported/fish_tuna_albacore.png-aaac5d00c8a250b19db99d7069f549e1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/tuna_albacore/tuna_albacore.tres b/fish/species/tuna_albacore/tuna_albacore.tres
new file mode 100644
index 0000000..21a9155
--- /dev/null
+++ b/fish/species/tuna_albacore/tuna_albacore.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://cjd2mibei3713"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bxebn81i552fg" path="res://fish/species/tuna_albacore/fish_tuna_albacore.png" id="4_texture"]
+
+[sub_resource type="Resource" id="AlbacoreAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"tuna_albacore"
+display_name = "albacore tuna"
+allowed_water_types = 2
+rarity = 2
+base_catch_weight = 1.5
+catch_profile = ExtResource("2_profile")
+availability = SubResource("AlbacoreAvailability")
+weight_min_lb = 10.0
+weight_max_lb = 45.0
+display_scale_min = 1.1
+display_scale_max = 1.9
+display_scale_curve = 0.85
+sell_value_min = 12
+sell_value_max = 24
+sell_value_curve = 0.85
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/tuna_bigeye/fish_tuna_bigeye.png b/fish/species/tuna_bigeye/fish_tuna_bigeye.png
new file mode 100644
index 0000000..b4a36c3
Binary files /dev/null and b/fish/species/tuna_bigeye/fish_tuna_bigeye.png differ
diff --git a/fish/species/tuna_bigeye/fish_tuna_bigeye.png.import b/fish/species/tuna_bigeye/fish_tuna_bigeye.png.import
new file mode 100644
index 0000000..3539d10
--- /dev/null
+++ b/fish/species/tuna_bigeye/fish_tuna_bigeye.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bypnhqmkhnm2c"
+path="res://.godot/imported/fish_tuna_bigeye.png-1c20be325f0bac055325a6fa0ad17dd1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/tuna_bigeye/fish_tuna_bigeye.png"
+dest_files=["res://.godot/imported/fish_tuna_bigeye.png-1c20be325f0bac055325a6fa0ad17dd1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/tuna_bigeye/tuna_bigeye.tres b/fish/species/tuna_bigeye/tuna_bigeye.tres
new file mode 100644
index 0000000..e5e4b7c
--- /dev/null
+++ b/fish/species/tuna_bigeye/tuna_bigeye.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://mxa5d130i5oh"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bypnhqmkhnm2c" path="res://fish/species/tuna_bigeye/fish_tuna_bigeye.png" id="4_texture"]
+
+[sub_resource type="Resource" id="BigeyeAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"tuna_bigeye"
+display_name = "bigeye tuna"
+allowed_water_types = 2
+rarity = 3
+base_catch_weight = 0.55
+catch_profile = ExtResource("2_profile")
+availability = SubResource("BigeyeAvailability")
+weight_min_lb = 20.0
+weight_max_lb = 120.0
+display_scale_min = 1.2
+display_scale_max = 2.2
+display_scale_curve = 0.8
+sell_value_min = 22
+sell_value_max = 48
+sell_value_curve = 0.8
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/tuna_bluefin/fish_tuna_bluefin.png b/fish/species/tuna_bluefin/fish_tuna_bluefin.png
new file mode 100644
index 0000000..a2b3cad
Binary files /dev/null and b/fish/species/tuna_bluefin/fish_tuna_bluefin.png differ
diff --git a/fish/species/tuna_bluefin/fish_tuna_bluefin.png.import b/fish/species/tuna_bluefin/fish_tuna_bluefin.png.import
new file mode 100644
index 0000000..674a0b7
--- /dev/null
+++ b/fish/species/tuna_bluefin/fish_tuna_bluefin.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dg7nafydp4flx"
+path="res://.godot/imported/fish_tuna_bluefin.png-04ec876b3a78ea716b7a0673e8497fab.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/tuna_bluefin/fish_tuna_bluefin.png"
+dest_files=["res://.godot/imported/fish_tuna_bluefin.png-04ec876b3a78ea716b7a0673e8497fab.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/tuna_bluefin/tuna_bluefin.tres b/fish/species/tuna_bluefin/tuna_bluefin.tres
new file mode 100644
index 0000000..a669b5d
--- /dev/null
+++ b/fish/species/tuna_bluefin/tuna_bluefin.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://bkck5gncu7tvc"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://dg7nafydp4flx" path="res://fish/species/tuna_bluefin/fish_tuna_bluefin.png" id="4_texture"]
+
+[sub_resource type="Resource" id="BluefinAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"tuna_bluefin"
+display_name = "bluefin tuna"
+allowed_water_types = 2
+rarity = 4
+base_catch_weight = 0.18
+catch_profile = ExtResource("2_profile")
+availability = SubResource("BluefinAvailability")
+weight_min_lb = 30.0
+weight_max_lb = 250.0
+display_scale_min = 1.3
+display_scale_max = 2.5
+display_scale_curve = 0.78
+sell_value_min = 35
+sell_value_max = 90
+sell_value_curve = 0.78
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/tuna_skipjack/fish_tuna_skipjack.png b/fish/species/tuna_skipjack/fish_tuna_skipjack.png
new file mode 100644
index 0000000..5a7f74d
Binary files /dev/null and b/fish/species/tuna_skipjack/fish_tuna_skipjack.png differ
diff --git a/fish/species/tuna_skipjack/fish_tuna_skipjack.png.import b/fish/species/tuna_skipjack/fish_tuna_skipjack.png.import
new file mode 100644
index 0000000..786e985
--- /dev/null
+++ b/fish/species/tuna_skipjack/fish_tuna_skipjack.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://vvtr81d2aojs"
+path="res://.godot/imported/fish_tuna_skipjack.png-98d7d6a76fc86bd76f861358be0635c7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/tuna_skipjack/fish_tuna_skipjack.png"
+dest_files=["res://.godot/imported/fish_tuna_skipjack.png-98d7d6a76fc86bd76f861358be0635c7.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/tuna_skipjack/tuna_skipjack.tres b/fish/species/tuna_skipjack/tuna_skipjack.tres
new file mode 100644
index 0000000..4d9e5a8
--- /dev/null
+++ b/fish/species/tuna_skipjack/tuna_skipjack.tres
@@ -0,0 +1,30 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://dli584n85vpfi"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://vvtr81d2aojs" path="res://fish/species/tuna_skipjack/fish_tuna_skipjack.png" id="4_texture"]
+
+[sub_resource type="Resource" id="SkipjackAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"tuna_skipjack"
+display_name = "skipjack tuna"
+allowed_water_types = 2
+rarity = 1
+base_catch_weight = 3.0
+catch_profile = ExtResource("2_profile")
+availability = SubResource("SkipjackAvailability")
+weight_min_lb = 5.0
+weight_max_lb = 25.0
+display_scale_min = 1.0
+display_scale_max = 1.7
+display_scale_curve = 0.9
+sell_value_min = 8
+sell_value_max = 16
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/tuna_yellowfin/fish_tuna_yellowfin.png b/fish/species/tuna_yellowfin/fish_tuna_yellowfin.png
new file mode 100644
index 0000000..a1afc81
Binary files /dev/null and b/fish/species/tuna_yellowfin/fish_tuna_yellowfin.png differ
diff --git a/fish/species/tuna_yellowfin/fish_tuna_yellowfin.png.import b/fish/species/tuna_yellowfin/fish_tuna_yellowfin.png.import
new file mode 100644
index 0000000..a8918aa
--- /dev/null
+++ b/fish/species/tuna_yellowfin/fish_tuna_yellowfin.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bwpdgagaysdsf"
+path="res://.godot/imported/fish_tuna_yellowfin.png-14cd53683bfec6af599f1e1dc5728b99.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/tuna_yellowfin/fish_tuna_yellowfin.png"
+dest_files=["res://.godot/imported/fish_tuna_yellowfin.png-14cd53683bfec6af599f1e1dc5728b99.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/tuna_yellowfin/tuna_yellowfin.tres b/fish/species/tuna_yellowfin/tuna_yellowfin.tres
new file mode 100644
index 0000000..cbd4950
--- /dev/null
+++ b/fish/species/tuna_yellowfin/tuna_yellowfin.tres
@@ -0,0 +1,29 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://yjeoqfht5ql1"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://bwpdgagaysdsf" path="res://fish/species/tuna_yellowfin/fish_tuna_yellowfin.png" id="4_texture"]
+
+[sub_resource type="Resource" id="YellowfinAvailability"]
+script = ExtResource("3_availability_script")
+preferred_bait_tags = Array[StringName]([&"minnow"])
+preferred_bait_weight_multiplier = 1.4
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"tuna_yellowfin"
+display_name = "yellowfin tuna"
+allowed_water_types = 2
+rarity = 2
+catch_profile = ExtResource("2_profile")
+availability = SubResource("YellowfinAvailability")
+weight_min_lb = 15.0
+weight_max_lb = 80.0
+display_scale_min = 1.15
+display_scale_max = 2.1
+display_scale_curve = 0.82
+sell_value_min = 16
+sell_value_max = 36
+sell_value_curve = 0.82
+display_texture = ExtResource("4_texture")
diff --git a/fish/species/walleye/fish_walleye.png b/fish/species/walleye/fish_walleye.png
new file mode 100644
index 0000000..ed3eb96
Binary files /dev/null and b/fish/species/walleye/fish_walleye.png differ
diff --git a/fish/species/walleye/fish_walleye.png.import b/fish/species/walleye/fish_walleye.png.import
new file mode 100644
index 0000000..21e025e
--- /dev/null
+++ b/fish/species/walleye/fish_walleye.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c10j6jv6fv32r"
+path="res://.godot/imported/fish_walleye.png-48ef5c2e01ff8de0726161700ea41c43.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://fish/species/walleye/fish_walleye.png"
+dest_files=["res://.godot/imported/fish_walleye.png-48ef5c2e01ff8de0726161700ea41c43.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/fish/species/walleye/walleye.tres b/fish/species/walleye/walleye.tres
new file mode 100644
index 0000000..ce833c9
--- /dev/null
+++ b/fish/species/walleye/walleye.tres
@@ -0,0 +1,28 @@
+[gd_resource type="Resource" script_class="FishData" format=3 uid="uid://8j0f5dchtajt"]
+
+[ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"]
+[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
+[ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"]
+[ext_resource type="Texture2D" uid="uid://c10j6jv6fv32r" path="res://fish/species/walleye/fish_walleye.png" id="4_texture"]
+
+[sub_resource type="Resource" id="WalleyeAvailability"]
+script = ExtResource("3_availability_script")
+allowed_location_tags = Array[StringName]([&"starter_pond"])
+
+[resource]
+script = ExtResource("1_fish_data")
+id = &"walleye"
+display_name = "walleye"
+allowed_water_types = 1
+rarity = 1
+base_catch_weight = 0.4
+catch_profile = ExtResource("2_profile")
+availability = SubResource("WalleyeAvailability")
+weight_max_lb = 15
+display_scale_min = 0.85
+display_scale_max = 1.7
+display_scale_curve = 0.9
+sell_value_min = 7
+sell_value_max = 15
+sell_value_curve = 0.9
+display_texture = ExtResource("4_texture")
diff --git a/fishing/bass_catch_profile.tres b/fishing/bass_catch_profile.tres
index 077b229..24024a7 100644
--- a/fishing/bass_catch_profile.tres
+++ b/fishing/bass_catch_profile.tres
@@ -6,11 +6,6 @@
script = ExtResource("1_profile")
barrier_count_min = 3
barrier_count_max = 4
-barrier_health_min = 4
-barrier_health_max = 7
first_barrier_margin = 0.14
final_barrier_margin = 0.1
minimum_barrier_spacing = 0.14
-chase_start_delay = 1.0
-chase_start_offset = 0.16
-chase_speed = 0.145
diff --git a/fishing/carp_catch_profile.tres b/fishing/carp_catch_profile.tres
index bc3f5dc..e1e0efe 100644
--- a/fishing/carp_catch_profile.tres
+++ b/fishing/carp_catch_profile.tres
@@ -6,11 +6,6 @@
script = ExtResource("1_profile")
barrier_count_min = 3
barrier_count_max = 4
-barrier_health_min = 5
-barrier_health_max = 8
first_barrier_margin = 0.13
final_barrier_margin = 0.09
minimum_barrier_spacing = 0.14
-chase_start_delay = 1.1
-chase_start_offset = 0.18
-chase_speed = 0.15
diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd
index 350bafa..7e75370 100644
--- a/fishing/catch_controller.gd
+++ b/fishing/catch_controller.gd
@@ -4,6 +4,7 @@ extends Node
const CatchDifficultyProfileType = preload(
"res://fishing/catch_difficulty_profile.gd"
)
+const FishQualityType = preload("res://fish/fish_quality.gd")
signal encounter_updated(
progress: float,
@@ -42,6 +43,13 @@ class Barrier:
maximum_health = barrier_health
+# Fight movement uses one shared baseline. Species and quality difficulty comes
+# from barrier placement and health, while reel upgrades affect only the
+# player's progress speed.
+const CHASE_START_DELAY: float = 1.0
+const CHASE_START_OFFSET: float = 0.04
+const CHASE_SPEED: float = 0.07
+
@export_category("Accessibility")
@export var auto_click_enabled: bool = false
@export_range(0.05, 2.0, 0.01) var auto_click_interval: float = 0.20
@@ -60,9 +68,8 @@ var _active_barrier_index: int = -1
var _reel_input_held: bool = false
var _reel_speed: float = 0.0
var _click_power: int = 1
-var _chase_start_delay: float = 0.0
+var _fish_quality: int = FishQualityType.Tier.BORING
var _chase_delay_remaining: float = 0.0
-var _chase_speed: float = 0.0
var _failure_epsilon: float = 0.0001
var _auto_click_accumulator: float = 0.0
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
@@ -103,6 +110,7 @@ func start_encounter(
profile: CatchDifficultyProfileType,
reel_speed: float,
click_power: int,
+ fish_quality: int = FishQualityType.Tier.BORING,
) -> void:
reset()
if profile == null:
@@ -111,10 +119,13 @@ func start_encounter(
_reel_speed = maxf(reel_speed, 0.0)
_click_power = maxi(click_power, 1)
- _chase_start_delay = maxf(profile.chase_start_delay, 0.0)
- _chase_delay_remaining = _chase_start_delay
- chase_progress = -maxf(profile.chase_start_offset, 0.01)
- _chase_speed = maxf(profile.chase_speed, 0.0)
+ _fish_quality = (
+ fish_quality
+ if FishQualityType.is_valid(fish_quality)
+ else FishQualityType.Tier.BORING
+ )
+ _chase_delay_remaining = CHASE_START_DELAY
+ chase_progress = -CHASE_START_OFFSET
_seed_encounter_rng()
_generate_barriers(profile)
progress = 0.0
@@ -122,6 +133,27 @@ func start_encounter(
_emit_encounter_update()
+func start_authoritative_encounter(
+ profile: CatchDifficultyProfileType,
+ reel_speed: float,
+ click_power: int,
+ encounter_seed_value: int,
+ fish_quality: int = FishQualityType.Tier.BORING,
+) -> void:
+ var previous_test_mode: bool = use_deterministic_test_seed
+ var previous_seed: int = deterministic_test_seed
+ use_deterministic_test_seed = true
+ deterministic_test_seed = encounter_seed_value
+ start_encounter(
+ profile,
+ reel_speed,
+ click_power,
+ fish_quality,
+ )
+ use_deterministic_test_seed = previous_test_mode
+ deterministic_test_seed = previous_seed
+
+
func set_reel_input(held: bool) -> void:
_reel_input_held = held
if not held:
@@ -153,9 +185,8 @@ func reset() -> void:
_reel_input_held = false
_reel_speed = 0.0
_click_power = 1
- _chase_start_delay = 0.0
+ _fish_quality = FishQualityType.Tier.BORING
_chase_delay_remaining = 0.0
- _chase_speed = 0.0
_auto_click_accumulator = 0.0
emit_signal(
"encounter_updated",
@@ -200,7 +231,7 @@ func _update_chase(delta: float) -> void:
if active_delta <= 0.0:
return
chase_progress = minf(
- chase_progress + _chase_speed * active_delta,
+ chase_progress + CHASE_SPEED * active_delta,
1.0
)
@@ -263,7 +294,6 @@ func _seed_encounter_rng() -> void:
func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
var count_range: Vector2i = profile.get_barrier_count_range()
- var health_range: Vector2i = profile.get_barrier_health_range()
var interval: Vector2 = profile.get_generation_interval()
var spacing: float = maxf(profile.minimum_barrier_spacing, 0.01)
var available_distance: float = maxf(interval.y - interval.x, 0.0)
@@ -287,7 +317,9 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
+ spacing * float(barrier_index)
+ random_offsets[barrier_index] * random_slack
)
- var health: int = _rng.randi_range(health_range.x, health_range.y)
+ var health: int = FishQualityType.roll_barrier_health(
+ _rng, _fish_quality
+ )
_barriers.append(Barrier.new(position, health))
diff --git a/fishing/catch_difficulty_profile.gd b/fishing/catch_difficulty_profile.gd
index 3ad20ad..60aa190 100644
--- a/fishing/catch_difficulty_profile.gd
+++ b/fishing/catch_difficulty_profile.gd
@@ -3,14 +3,9 @@ extends Resource
@export_range(0, 12, 1) var barrier_count_min: int = 2
@export_range(0, 12, 1) var barrier_count_max: int = 4
-@export_range(1, 100, 1) var barrier_health_min: int = 3
-@export_range(1, 100, 1) var barrier_health_max: int = 7
@export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15
@export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10
@export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15
-@export_range(0.0, 5.0, 0.05) var chase_start_delay: float = 1.0
-@export_range(0.01, 0.5, 0.01) var chase_start_offset: float = 0.15
-@export_range(0.0, 2.0, 0.01) var chase_speed: float = 0.14
func get_barrier_count_range() -> Vector2i:
@@ -18,11 +13,6 @@ func get_barrier_count_range() -> Vector2i:
return Vector2i(minimum, maxi(minimum, barrier_count_max))
-func get_barrier_health_range() -> Vector2i:
- var minimum: int = maxi(1, barrier_health_min)
- return Vector2i(minimum, maxi(minimum, barrier_health_max))
-
-
func get_generation_interval() -> Vector2:
var start: float = clampf(first_barrier_margin, 0.0, 0.95)
var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0)
diff --git a/fishing/common_catch_profile.tres b/fishing/common_catch_profile.tres
index 1169e2e..86c2b74 100644
--- a/fishing/common_catch_profile.tres
+++ b/fishing/common_catch_profile.tres
@@ -6,11 +6,6 @@
script = ExtResource("1_profile")
barrier_count_min = 2
barrier_count_max = 4
-barrier_health_min = 3
-barrier_health_max = 7
first_barrier_margin = 0.15
final_barrier_margin = 0.1
minimum_barrier_spacing = 0.15
-chase_start_delay = 1.0
-chase_start_offset = 0.15
-chase_speed = 0.14
diff --git a/fishing/fishing_context.gd b/fishing/fishing_context.gd
index 55b119f..b3f1acd 100644
--- a/fishing/fishing_context.gd
+++ b/fishing/fishing_context.gd
@@ -2,7 +2,10 @@ class_name FishingContext
extends RefCounted
var location_tags: Array[StringName] = []
+var water_type: WaterType.Type = WaterType.Type.FRESH_WATER
var active_event_tags: Array[StringName] = []
var active_bait_tags: Array[StringName] = []
var is_night: bool = false
+var is_day_night_transition: bool = false
var is_raining: bool = false
+var is_foggy: bool = false
diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd
index a44ed7f..71fedac 100644
--- a/fishing/fishing_presentation.gd
+++ b/fishing/fishing_presentation.gd
@@ -1,8 +1,13 @@
class_name FishingPresentation
extends Node3D
+const WaterSurfaceMotionType = preload(
+ "res://world/water_surface_motion.gd"
+)
+
signal cast_completed
signal outcome_completed(outcome: StringName)
+signal presentation_interrupted
enum VisualMode {
NONE,
@@ -18,100 +23,71 @@ enum LineMode {
SLACK,
}
-@export_category("Water")
-@export var water_surface_offset_y: float = -0.72
-@export_range(0.1, 10.0, 0.1) var pickup_distance_from_player: float = 5.5
-
@export_category("Fishing Line")
-@export_flags_3d_physics var line_obstacle_mask: int = 1
-@export_range(1.0, 30.0, 0.5) var routing_probe_height: float = 8.0
-@export_range(0.1, 2.0, 0.05) var obstruction_sample_spacing: float = 0.4
-@export_range(0.01, 1.0, 0.01) var terrain_clearance: float = 0.1
-@export_range(1, 3, 1) var maximum_obstruction_ranges: int = 1
-@export_range(2, 12, 1) var maximum_contact_anchors_per_range: int = 5
-@export_range(0.0, 3.0, 0.05) var route_lead_in_distance: float = 0.8
-@export_range(0.0, 3.0, 0.05) var route_lead_out_distance: float = 0.8
-@export_range(0.05, 2.0, 0.05) var fallback_arch_height: float = 0.35
@export_range(0.0, 0.5, 0.01) var slack_amount: float = 0.08
@export_range(0.0, 2.0, 0.01) var minimum_slack: float = 0.08
@export_range(0.0, 3.0, 0.01) var maximum_slack: float = 0.65
-@export_range(2, 20, 1) var clear_span_interpolation_count: int = 8
-@export_range(1, 8, 1) var contact_span_interpolation_count: int = 2
+@export_range(2, 20, 1) var sag_interpolation_count: int = 8
@export_range(0.1, 30.0, 0.1) var line_transition_speed: float = 7.0
+@export_range(0.002, 0.05, 0.001) var line_thickness: float = 0.016
@export_category("Cast")
-@export_range(0.1, 30.0, 0.1) var target_movement_smoothing: float = 14.0
@export_range(0.1, 3.0, 0.05) var cast_travel_duration: float = 0.65
@export_range(0.1, 5.0, 0.1) var cast_arc_height: float = 2.0
@export_range(0.0, 90.0, 1.0) var maximum_rod_cock_degrees: float = 50.0
@export_range(0.0, 60.0, 1.0) var rod_forward_swing_degrees: float = 22.0
@export_range(0.05, 1.0, 0.01) var rod_forward_swing_duration: float = 0.12
@export_range(0.05, 1.0, 0.01) var rod_recovery_duration: float = 0.22
-@export var valid_target_material: Material
-@export var invalid_target_material: Material
-@onready var _target_marker: MeshInstance3D = %CastTargetMarker
-@onready var _invalid_cross_a: MeshInstance3D = %InvalidCrossA
-@onready var _invalid_cross_b: MeshInstance3D = %InvalidCrossB
+@export_category("Bobber Idle")
+@export_range(0.0, 0.2, 0.005) var bobber_bob_amplitude: float = 0.035
+@export_range(0.1, 3.0, 0.05) var bobber_bob_cycles_per_second: float = 0.7
+@export_range(0.0, 12.0, 0.5) var bobber_tilt_degrees: float = 4.0
+@onready var _target_marker: Node3D = %CastTargetMarker
+@onready var _valid_target_marker: MeshInstance3D = %ValidTargetMarker
+@onready var _invalid_target_marker: Node3D = %InvalidTargetMarker
@onready var _bobber: MeshInstance3D = %Bobber
@onready var _line: MeshInstance3D = %FishingLine
var _mode: VisualMode = VisualMode.NONE
var _line_mode: LineMode = LineMode.HIDDEN
var _line_mesh: ImmediateMesh = ImmediateMesh.new()
-var _route_points: PackedVector3Array = PackedVector3Array()
-var _contact_start_anchor_index: int = -1
-var _contact_end_anchor_index: int = -1
var _current_sag: float = 0.0
var _rod: Node3D
var _rod_tip: Marker3D
var _rod_neutral_rotation: Vector3
-var _target_goal: Vector3
-var _cast_position: Vector3
-var _pickup_position: Vector3
+var _cast_arrival_position: Vector3
+var _active_cast_arc_height: float = 0.0
+var _cast_tracks_water_surface: bool = false
+var _bobber_surface_position: Vector3
+var _bobber_idle_elapsed: float = 0.0
+var _bobber_base_scale: Vector3 = Vector3.ONE
var _active_tween: Tween
var _rod_tween: Tween
var _bite_tween: Tween
-var _default_water_surface_offset_y: float
func _ready() -> void:
- _default_water_surface_offset_y = water_surface_offset_y
_line.mesh = _line_mesh
cleanup()
func _process(delta: float) -> void:
- if _mode == VisualMode.AIMING:
- var weight: float = 1.0 - exp(-target_movement_smoothing * delta)
- _target_marker.global_position = _target_marker.global_position.lerp(
- _target_goal,
- weight
- )
+ if _mode == VisualMode.FISHING and _bobber.visible:
+ _bobber_idle_elapsed += delta
+ _apply_bobber_idle_motion()
+ var bobber_position := _bobber.global_position
+ bobber_position.y += WaterSurfaceMotionType.get_default_height_offset()
+ _bobber.global_position = bobber_position
if _line_mode != LineMode.HIDDEN:
_update_line(delta)
-func get_water_surface_height() -> float:
- return global_position.y + water_surface_offset_y
-
-
-func set_water_surface_height(surface_height: float) -> void:
- water_surface_offset_y = surface_height - global_position.y
-
-
-func reset_water_surface_height() -> void:
- water_surface_offset_y = _default_water_surface_offset_y
-
-
func set_line_mode(mode: LineMode) -> void:
_line_mode = mode
_line.visible = mode != LineMode.HIDDEN
if mode == LineMode.HIDDEN:
_current_sag = 0.0
- _route_points.clear()
- _contact_start_anchor_index = -1
- _contact_end_anchor_index = -1
_line_mesh.clear_surfaces()
@@ -119,18 +95,20 @@ func begin_aim(
rod_tip: Marker3D,
rod: Node3D,
minimum_target: Vector3,
+ target_normal: Vector3,
target_is_fishable: bool,
+ character_scale: float = 1.0,
) -> void:
cleanup()
if rod_tip == null or rod == null:
return
+ _bobber_base_scale = Vector3.ONE * maxf(character_scale, 0.01)
_mode = VisualMode.AIMING
_rod = rod
_rod_tip = rod_tip
_rod_neutral_rotation = _rod.rotation
- _target_goal = minimum_target
- _target_marker.global_position = minimum_target
+ _set_target_surface(minimum_target, target_normal)
_target_marker.scale = Vector3.ONE
_target_marker.visible = true
_set_target_validity(target_is_fishable)
@@ -147,19 +125,38 @@ func update_rod_charge(charge: float) -> void:
func update_aim_target(
target: Vector3,
+ target_normal: Vector3,
charge: float,
target_is_fishable: bool,
) -> void:
if _mode != VisualMode.AIMING:
return
- _target_goal = target
+ # The gameplay resolver already advances the horizontal target smoothly.
+ # Snap each sample to its resolved surface so interpolation can never draw
+ # the marker through a vertical shoreline between two valid endpoints.
+ _set_target_surface(target, target_normal)
var maximum_response: float = smoothstep(0.9, 1.0, clampf(charge, 0.0, 1.0))
_target_marker.scale = Vector3.ONE * lerpf(1.0, 1.15, maximum_response)
_set_target_validity(target_is_fishable)
-func begin_cast(target: Vector3) -> void:
+func _set_target_surface(target: Vector3, surface_normal: Vector3) -> void:
+ _target_marker.global_position = target
+ var resolved_normal: Vector3 = surface_normal.normalized()
+ if resolved_normal.is_zero_approx():
+ resolved_normal = Vector3.UP
+ _target_marker.global_basis = Basis(
+ Quaternion(Vector3.UP, resolved_normal)
+ )
+
+
+func begin_cast(
+ target: Vector3,
+ blocked_landing_position: Vector3 = Vector3.ZERO,
+ cast_is_blocked: bool = false,
+ resolved_arc_height: float = -1.0,
+) -> void:
if _mode != VisualMode.AIMING or _rod_tip == null:
return
@@ -167,9 +164,16 @@ func begin_cast(target: Vector3) -> void:
_mode = VisualMode.CASTING
_target_marker.visible = false
_bobber.visible = true
- _bobber.scale = Vector3.ONE
- _cast_position = _with_water_height(target)
- _pickup_position = _calculate_pickup_position(_cast_position)
+ _bobber.scale = _bobber_base_scale
+ _cast_arrival_position = (
+ blocked_landing_position if cast_is_blocked else target
+ )
+ _active_cast_arc_height = (
+ maxf(resolved_arc_height, 0.0)
+ if resolved_arc_height >= 0.0
+ else cast_arc_height
+ )
+ _cast_tracks_water_surface = not cast_is_blocked
var cast_start: Vector3 = _rod_tip.global_position
_bobber.global_position = cast_start
@@ -178,11 +182,15 @@ func begin_cast(target: Vector3) -> void:
_active_tween.set_trans(Tween.TRANS_SINE)
_active_tween.set_ease(Tween.EASE_IN_OUT)
_active_tween.tween_method(
- _set_cast_sample.bind(cast_start, _cast_position),
+ _set_cast_sample.bind(cast_start, _cast_arrival_position),
0.0,
1.0,
cast_travel_duration
)
+ if cast_is_blocked:
+ # Let the bobber visibly settle on the blocking surface before the
+ # normal invalid-cast return animation begins.
+ _active_tween.tween_interval(0.16)
_active_tween.finished.connect(_on_cast_tween_finished)
@@ -191,58 +199,53 @@ func show_bite() -> void:
return
_kill_bite_tween()
- _bobber.scale = Vector3.ONE
+ _bobber.scale = _bobber_base_scale
_bite_tween = create_tween()
_bite_tween.set_trans(Tween.TRANS_QUAD)
_bite_tween.set_ease(Tween.EASE_IN_OUT)
_bite_tween.tween_property(
_bobber,
"scale",
- Vector3.ONE * 0.68,
+ _bobber_base_scale * 0.68,
0.08
)
_bite_tween.tween_property(
_bobber,
"scale",
- Vector3.ONE * 1.2,
+ _bobber_base_scale * 1.2,
0.1
)
_bite_tween.tween_property(
_bobber,
"scale",
- Vector3.ONE,
+ _bobber_base_scale,
0.1
)
_bite_tween.finished.connect(_on_bite_tween_finished)
-func show_withdrawal_position(position: Vector3) -> void:
+func show_withdrawal_position(world_position: Vector3) -> void:
if _mode != VisualMode.FISHING:
return
_kill_active_tween()
- _bobber.global_position = _with_water_height(position)
+ _bobber_surface_position = world_position
+ _apply_bobber_idle_motion()
-func begin_reeling() -> void:
+func begin_fight() -> void:
if _mode != VisualMode.FISHING:
return
_kill_active_tween()
- _cast_position = _with_water_height(_bobber.global_position)
-
-
-func show_reel_position(position: Vector3, _input_held: bool) -> void:
- if _mode != VisualMode.FISHING:
- return
-
- _kill_active_tween()
- _bobber.global_position = _with_water_height(position)
func play_outcome(outcome: StringName) -> void:
if _mode == VisualMode.NONE:
cleanup()
+ # Gameplay completion must not depend on a presentation tween still
+ # being active. The caller owns idempotency for the outcome.
+ outcome_completed.emit(outcome)
return
_kill_active_tween()
@@ -250,6 +253,12 @@ func play_outcome(outcome: StringName) -> void:
_kill_rod_tween()
_restore_rod_neutral()
_mode = VisualMode.OUTCOME
+ _bobber.rotation = Vector3.ZERO
+ if outcome == &"withdrawal":
+ # Withdrawal is a visible return, not an instant cancellation. Keep the
+ # bobber present even if the preceding reel update hid or reset it.
+ _bobber.visible = true
+ _bobber.scale = _bobber_base_scale
_active_tween = create_tween()
_active_tween.set_trans(Tween.TRANS_QUAD)
_active_tween.set_ease(Tween.EASE_IN)
@@ -260,12 +269,7 @@ func play_outcome(outcome: StringName) -> void:
_target_marker.visible = false
match outcome:
&"catch":
- var catch_target: Vector3 = _bobber.global_position
- if _rod_tip != null:
- catch_target = _rod_tip.global_position
- _active_tween.set_parallel(true)
- _active_tween.tween_property(_bobber, "global_position", catch_target, 0.35)
- _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.35)
+ _queue_bobber_return(0.35, 0.45)
&"escape":
var start_x: float = _bobber.global_position.x
_active_tween.tween_property(
@@ -280,31 +284,9 @@ func play_outcome(outcome: StringName) -> void:
start_x + 0.18,
0.08
)
- _active_tween.tween_property(
- _bobber,
- "global_position:y",
- get_water_surface_height() - 0.4,
- 0.2
- )
- &"invalid":
- var return_target: Vector3 = _bobber.global_position
- if _rod_tip != null:
- return_target = _rod_tip.global_position
- _active_tween.set_parallel(true)
- _active_tween.tween_property(_bobber, "global_position", return_target, 0.28)
- _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.28)
- &"withdrawal":
- var withdrawal_target: Vector3 = _bobber.global_position
- if _rod_tip != null:
- withdrawal_target = _rod_tip.global_position
- _active_tween.set_parallel(true)
- _active_tween.tween_property(
- _bobber,
- "global_position",
- withdrawal_target,
- 0.28
- )
- _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.28)
+ _queue_bobber_return(0.42, 0.65)
+ &"invalid", &"withdrawal":
+ _queue_bobber_return(0.42, 0.65)
_:
_active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.18)
@@ -321,10 +303,17 @@ func cleanup() -> void:
_rod_tip = null
_target_marker.visible = false
_target_marker.scale = Vector3.ONE
- _invalid_cross_a.visible = false
- _invalid_cross_b.visible = false
+ _valid_target_marker.visible = true
+ _invalid_target_marker.visible = false
_bobber.visible = false
_bobber.scale = Vector3.ONE
+ _bobber_base_scale = Vector3.ONE
+ _bobber.rotation = Vector3.ZERO
+ _cast_arrival_position = Vector3.ZERO
+ _active_cast_arc_height = 0.0
+ _cast_tracks_water_surface = false
+ _bobber_surface_position = Vector3.ZERO
+ _bobber_idle_elapsed = 0.0
set_line_mode(LineMode.HIDDEN)
@@ -339,7 +328,41 @@ func _set_cast_sample(
target: Vector3,
) -> void:
var sample: Vector3 = start.lerp(target, progress)
- sample.y += sin(progress * PI) * cast_arc_height
+ sample.y += sin(progress * PI) * _active_cast_arc_height
+ if _cast_tracks_water_surface:
+ sample.y += (
+ WaterSurfaceMotionType.get_default_height_offset()
+ * smoothstep(0.72, 1.0, progress)
+ )
+ _bobber.global_position = sample
+
+
+func _queue_bobber_return(duration: float, arc_scale: float) -> void:
+ var return_start: Vector3 = _bobber.global_position
+ var return_target: Vector3 = return_start
+ if _rod_tip != null:
+ return_target = _rod_tip.global_position
+ _active_tween.tween_method(
+ _set_return_sample.bind(return_start, return_target, arc_scale),
+ 0.0,
+ 1.0,
+ duration,
+ )
+ _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.1)
+
+
+func _set_return_sample(
+ progress: float,
+ start: Vector3,
+ target: Vector3,
+ arc_scale: float,
+) -> void:
+ var eased_progress: float = 1.0 - pow(1.0 - progress, 3.0)
+ var sample: Vector3 = start.lerp(target, eased_progress)
+ sample.y += sin(eased_progress * PI) * maxf(
+ cast_arc_height * arc_scale,
+ 0.75,
+ )
_bobber.global_position = sample
@@ -348,36 +371,30 @@ func _on_cast_tween_finished() -> void:
return
_active_tween = null
- _bobber.global_position = _cast_position
+ _bobber.global_position = _cast_arrival_position
+ _bobber_surface_position = _cast_arrival_position
+ _bobber_idle_elapsed = 0.0
_mode = VisualMode.FISHING
cast_completed.emit()
-func _calculate_pickup_position(target: Vector3) -> Vector3:
- var player_on_water: Vector3 = target
- if _rod_tip != null:
- player_on_water = _with_water_height(_rod_tip.global_position)
- var outward: Vector3 = target - player_on_water
- outward.y = 0.0
- if outward.is_zero_approx():
- outward = Vector3.FORWARD
- else:
- outward = outward.normalized()
- var target_distance: float = player_on_water.distance_to(target)
- var pickup_distance: float = minf(
- pickup_distance_from_player,
- target_distance * 0.8
+func _apply_bobber_idle_motion() -> void:
+ var phase: float = _bobber_idle_elapsed * bobber_bob_cycles_per_second * TAU
+ _bobber.global_position = (
+ _bobber_surface_position
+ + Vector3.UP * sin(phase) * bobber_bob_amplitude
+ )
+ _bobber.rotation.z = (
+ deg_to_rad(bobber_tilt_degrees) * sin(phase * 0.73)
)
- return player_on_water + outward * pickup_distance
-
-
-func _with_water_height(position: Vector3) -> Vector3:
- return Vector3(position.x, get_water_surface_height(), position.z)
func _update_line(delta: float) -> void:
if _rod_tip == null or not is_instance_valid(_rod_tip):
+ var was_active: bool = _mode != VisualMode.NONE
cleanup()
+ if was_active:
+ presentation_interrupted.emit()
return
var rod_tip_position: Vector3 = _rod_tip.global_position
@@ -392,323 +409,78 @@ func _update_line(delta: float) -> void:
)
var sag_weight: float = 1.0 - exp(-line_transition_speed * delta)
_current_sag = lerpf(_current_sag, target_sag, sag_weight)
- _route_points = _build_line_route(rod_tip_position, bobber_position)
- var rendered_points: PackedVector3Array = _build_rendered_line_points(
- _route_points,
- _current_sag
+ var rendered_points: PackedVector3Array = _build_sagged_line_points(
+ rod_tip_position,
+ bobber_position,
+ _current_sag,
)
_line_mesh.clear_surfaces()
if rendered_points.size() < 2:
return
- _line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
- for point_index: int in range(1, rendered_points.size()):
- _line_mesh.surface_add_vertex(to_local(rendered_points[point_index - 1]))
- _line_mesh.surface_add_vertex(to_local(rendered_points[point_index]))
+ _draw_line_ribbon(rendered_points)
+
+
+func _draw_line_ribbon(points: PackedVector3Array) -> void:
+ var camera: Camera3D = get_viewport().get_camera_3d()
+ if camera == null:
+ _line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
+ for point: Vector3 in points:
+ _line_mesh.surface_add_vertex(to_local(point))
+ _line_mesh.surface_end()
+ return
+
+ var half_thickness: float = line_thickness * 0.5
+ var ribbon_vertices := PackedVector3Array()
+ for point_index: int in range(1, points.size()):
+ var start: Vector3 = points[point_index - 1]
+ var end: Vector3 = points[point_index]
+ var segment: Vector3 = end - start
+ if segment.length_squared() <= 0.000001:
+ continue
+ var to_camera: Vector3 = (
+ camera.global_position - start.lerp(end, 0.5)
+ )
+ var side: Vector3 = segment.normalized().cross(to_camera.normalized())
+ if side.length_squared() <= 0.000001:
+ side = segment.normalized().cross(Vector3.UP)
+ if side.length_squared() <= 0.000001:
+ side = segment.normalized().cross(Vector3.RIGHT)
+ side = side.normalized() * half_thickness
+
+ var start_left: Vector3 = to_local(start - side)
+ var start_right: Vector3 = to_local(start + side)
+ var end_right: Vector3 = to_local(end + side)
+ var end_left: Vector3 = to_local(end - side)
+ ribbon_vertices.append(start_left)
+ ribbon_vertices.append(start_right)
+ ribbon_vertices.append(end_right)
+ ribbon_vertices.append(start_left)
+ ribbon_vertices.append(end_right)
+ ribbon_vertices.append(end_left)
+ if ribbon_vertices.is_empty():
+ return
+ _line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
+ for vertex: Vector3 in ribbon_vertices:
+ _line_mesh.surface_add_vertex(vertex)
_line_mesh.surface_end()
-func _build_line_route(start: Vector3, end: Vector3) -> PackedVector3Array:
- _contact_start_anchor_index = -1
- _contact_end_anchor_index = -1
- if _raycast_obstacle(start, end).is_empty():
- return PackedVector3Array([start, end])
-
- var horizontal_delta := Vector2(end.x - start.x, end.z - start.z)
- var horizontal_length: float = horizontal_delta.length()
- if horizontal_length <= 0.001:
- return _build_fallback_arch(start, end)
-
- var sample_count: int = clampi(
- ceili(horizontal_length / obstruction_sample_spacing),
- 2,
- 64
- )
- var obstructed_samples := PackedByteArray()
- for sample_index: int in range(sample_count + 1):
- var progress: float = float(sample_index) / float(sample_count)
- var base_point: Vector3 = start.lerp(end, progress)
- var surface_hit: Dictionary = _probe_surface(base_point)
- var is_obstructed: bool = false
- if not surface_hit.is_empty():
- var surface_position: Vector3 = surface_hit["position"]
- var required_height: float = surface_position.y + terrain_clearance
- if required_height > base_point.y:
- is_obstructed = true
- obstructed_samples.append(1 if is_obstructed else 0)
-
- var obstruction_ranges: Array[Vector2i] = _find_obstruction_ranges(
- obstructed_samples
- )
- if obstruction_ranges.is_empty():
- return _build_fallback_arch(start, end)
- if obstruction_ranges.size() > maximum_obstruction_ranges:
- return _build_fallback_arch(start, end)
-
- var first_sample: int = obstruction_ranges[0].x
- var last_sample: int = obstruction_ranges[obstruction_ranges.size() - 1].y
- var first_progress: float = float(first_sample) / float(sample_count)
- var last_progress: float = float(last_sample) / float(sample_count)
- var lead_in_progress: float = maxf(
- 0.0,
- first_progress - route_lead_in_distance / horizontal_length
- )
- var lead_out_progress: float = minf(
- 1.0,
- last_progress + route_lead_out_distance / horizontal_length
- )
-
- var route := PackedVector3Array([start])
- _append_unique_route_point(route, start.lerp(end, lead_in_progress))
- _contact_start_anchor_index = route.size() - 1
- _append_contact_anchors(
- route,
- start,
- end,
- first_sample,
- last_sample,
- sample_count
- )
- _append_unique_route_point(route, start.lerp(end, lead_out_progress))
- _contact_end_anchor_index = route.size() - 1
- _append_unique_route_point(route, end)
- route = _remove_near_duplicate_route_points(route)
- _update_contact_indices_after_reduction(route)
- return _validate_reduced_route(route, start, end)
-
-
-func _build_rendered_line_points(
- route: PackedVector3Array,
+func _build_sagged_line_points(
+ start: Vector3,
+ end: Vector3,
sag: float,
) -> PackedVector3Array:
var points := PackedVector3Array()
- if route.is_empty():
- return points
- if (
- _contact_start_anchor_index < 0
- or _contact_end_anchor_index < _contact_start_anchor_index
- or _contact_end_anchor_index >= route.size()
- ):
- _append_clear_span(points, route[0], route[route.size() - 1], sag)
- return points
-
- _append_clear_span(
- points,
- route[0],
- route[_contact_start_anchor_index],
- sag
- )
- for anchor_index: int in range(
- _contact_start_anchor_index,
- _contact_end_anchor_index
- ):
- _append_contact_span(
- points,
- route[anchor_index],
- route[anchor_index + 1]
- )
- _append_clear_span(
- points,
- route[_contact_end_anchor_index],
- route[route.size() - 1],
- sag
- )
- return points
-
-
-func _raycast_obstacle(from: Vector3, to: Vector3) -> Dictionary:
- if from.is_equal_approx(to):
- return {}
- var query := PhysicsRayQueryParameters3D.create(
- from,
- to,
- line_obstacle_mask
- )
- query.collide_with_areas = false
- query.collide_with_bodies = true
- return get_world_3d().direct_space_state.intersect_ray(query)
-
-
-func _find_obstruction_ranges(
- obstructed_samples: PackedByteArray,
-) -> Array[Vector2i]:
- var ranges: Array[Vector2i] = []
- var range_start: int = -1
- for sample_index: int in range(obstructed_samples.size()):
- var is_obstructed: bool = obstructed_samples[sample_index] != 0
- if is_obstructed and range_start < 0:
- range_start = sample_index
- elif not is_obstructed and range_start >= 0:
- ranges.append(Vector2i(range_start, sample_index - 1))
- range_start = -1
- if range_start >= 0:
- ranges.append(Vector2i(range_start, obstructed_samples.size() - 1))
- return ranges
-
-
-func _append_contact_anchors(
- route: PackedVector3Array,
- start: Vector3,
- end: Vector3,
- first_sample: int,
- last_sample: int,
- sample_count: int,
-) -> void:
- var available_samples: int = last_sample - first_sample + 1
- var anchor_count: int = mini(
- available_samples,
- maximum_contact_anchors_per_range
- )
- for anchor_offset: int in range(anchor_count):
- var weight: float = 0.0
- if anchor_count > 1:
- weight = float(anchor_offset) / float(anchor_count - 1)
- var sample_index: int = roundi(
- lerpf(float(first_sample), float(last_sample), weight)
- )
- var progress: float = float(sample_index) / float(sample_count)
- var contact: Vector3 = _raise_point_above_obstacle(
- start.lerp(end, progress)
- )
- _append_unique_route_point(route, contact)
-
-
-func _append_unique_route_point(
- route: PackedVector3Array,
- point: Vector3,
-) -> void:
- if route.is_empty() or point.distance_to(route[route.size() - 1]) > 0.03:
- route.append(point)
-
-
-func _remove_near_duplicate_route_points(
- route: PackedVector3Array,
-) -> PackedVector3Array:
- var cleaned := PackedVector3Array()
- for point: Vector3 in route:
- _append_unique_route_point(cleaned, point)
- return cleaned
-
-
-func _update_contact_indices_after_reduction(route: PackedVector3Array) -> void:
- if route.size() <= 2:
- _contact_start_anchor_index = -1
- _contact_end_anchor_index = -1
- return
- _contact_start_anchor_index = 1
- _contact_end_anchor_index = route.size() - 2
-
-
-func _validate_reduced_route(
- route: PackedVector3Array,
- start: Vector3,
- end: Vector3,
-) -> PackedVector3Array:
- var correction_index: int = -1
- var correction_hit: Dictionary
- for segment_index: int in range(route.size() - 1):
- var hit: Dictionary = _raycast_obstacle(
- route[segment_index],
- route[segment_index + 1]
- )
- if not hit.is_empty():
- correction_index = segment_index + 1
- correction_hit = hit
- break
- if correction_index < 0:
- return route
-
- var hit_position: Vector3 = correction_hit["position"]
- var correction: Vector3 = _raise_point_above_obstacle(hit_position)
- route.insert(correction_index, correction)
- _contact_start_anchor_index = mini(
- _contact_start_anchor_index,
- correction_index
- )
- _contact_end_anchor_index = maxi(
- _contact_end_anchor_index + 1,
- correction_index
- )
- for segment_index: int in range(route.size() - 1):
- if not _raycast_obstacle(
- route[segment_index],
- route[segment_index + 1]
- ).is_empty():
- return _build_fallback_arch(start, end)
- return route
-
-
-func _append_clear_span(
- points: PackedVector3Array,
- start: Vector3,
- end: Vector3,
- sag: float,
-) -> void:
- if points.is_empty():
- points.append(start)
- elif not points[points.size() - 1].is_equal_approx(start):
- points.append(start)
- var subdivisions: int = 1 if sag <= 0.001 else clear_span_interpolation_count
+ var subdivisions: int = 1 if sag <= 0.001 else sag_interpolation_count
+ points.append(start)
for step: int in range(1, subdivisions + 1):
var progress: float = float(step) / float(subdivisions)
var point: Vector3 = start.lerp(end, progress)
if step < subdivisions and sag > 0.0:
point.y -= 4.0 * progress * (1.0 - progress) * sag
- point.y = maxf(
- point.y,
- get_water_surface_height() + terrain_clearance * 0.25
- )
- point = _raise_point_above_obstacle(point)
points.append(point)
-
-
-func _append_contact_span(
- points: PackedVector3Array,
- start: Vector3,
- end: Vector3,
-) -> void:
- if points.is_empty():
- points.append(start)
- elif not points[points.size() - 1].is_equal_approx(start):
- points.append(start)
- for step: int in range(1, contact_span_interpolation_count + 1):
- var progress: float = (
- float(step) / float(contact_span_interpolation_count)
- )
- var point: Vector3 = start.lerp(end, progress)
- if step < contact_span_interpolation_count:
- point = _raise_point_above_obstacle(point)
- points.append(point)
-
-
-func _build_fallback_arch(
- start: Vector3,
- end: Vector3,
-) -> PackedVector3Array:
- var route := PackedVector3Array([start])
- for progress: float in [0.25, 0.5, 0.75]:
- var point: Vector3 = start.lerp(end, progress)
- point.y += sin(progress * PI) * fallback_arch_height
- route.append(_raise_point_above_obstacle(point))
- route.append(end)
- _contact_start_anchor_index = 1
- _contact_end_anchor_index = route.size() - 2
- return route
-
-
-func _probe_surface(point: Vector3) -> Dictionary:
- var probe_start: Vector3 = point + Vector3.UP * routing_probe_height
- var probe_end: Vector3 = point - Vector3.UP * routing_probe_height
- return _raycast_obstacle(probe_start, probe_end)
-
-
-func _raise_point_above_obstacle(point: Vector3) -> Vector3:
- var hit: Dictionary = _probe_surface(point)
- if hit.is_empty():
- return point
- var surface_position: Vector3 = hit["position"]
- point.y = maxf(point.y, surface_position.y + terrain_clearance)
- return point
+ return points
func _begin_rod_release() -> void:
@@ -742,12 +514,8 @@ func _restore_rod_neutral() -> void:
func _set_target_validity(target_is_fishable: bool) -> void:
- if target_is_fishable:
- _target_marker.material_override = valid_target_material
- else:
- _target_marker.material_override = invalid_target_material
- _invalid_cross_a.visible = not target_is_fishable
- _invalid_cross_b.visible = not target_is_fishable
+ _valid_target_marker.visible = target_is_fishable
+ _invalid_target_marker.visible = not target_is_fishable
func _kill_active_tween() -> void:
@@ -761,7 +529,7 @@ func _kill_bite_tween() -> void:
_bite_tween.kill()
_bite_tween = null
if is_instance_valid(_bobber):
- _bobber.scale = Vector3.ONE
+ _bobber.scale = _bobber_base_scale
func _on_bite_tween_finished() -> void:
diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd
index 64d0f36..167a08b 100644
--- a/fishing/fishing_spot.gd
+++ b/fishing/fishing_spot.gd
@@ -9,6 +9,10 @@ const CatchControllerType = preload("res://fishing/catch_controller.gd")
const FishingContextType = preload("res://fishing/fishing_context.gd")
const FishingPresentationType = preload("res://fishing/fishing_presentation.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const FishExperienceType = preload("res://fish/fish_experience.gd")
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
@@ -26,8 +30,24 @@ const PlayerType = preload("res://player/player.gd")
const FishableWaterRegionType = preload(
"res://world/fishable_water_region.gd"
)
+const NetworkSessionType = preload("res://network/network_session.gd")
+const NetworkFishingServiceType = preload(
+ "res://network/network_fishing_service.gd"
+)
+const FishingSurfaceSampleType = preload(
+ "res://fishing/fishing_surface_sample.gd"
+)
+const FishingSurfaceResolverType = preload(
+ "res://fishing/fishing_surface_resolver.gd"
+)
+const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldWeatherServiceType = preload(
+ "res://world/world_weather_service.gd"
+)
signal status_changed(status: String)
+signal local_speech_requested(message: String)
signal catch_display_changed(
progress: float,
chase_progress: float,
@@ -41,9 +61,11 @@ signal showcase_changed(
fish_name: String,
rarity_name: String,
weight_lb: float,
+ quality: int,
visible: bool,
)
signal bite_activated
+signal bite_prompt_changed(is_visible: bool)
signal ready_for_equipment_refresh
enum FishingState {
@@ -53,6 +75,7 @@ enum FishingState {
WAITING_FOR_BITE,
FIGHTING,
SHOWING_CATCH,
+ RETURNING,
COOLDOWN,
}
@@ -65,16 +88,27 @@ enum FishingState {
@export_category("Target Preview")
@export_flags_3d_physics var preview_surface_mask: int = 5
-@export_range(1.0, 100.0, 1.0) var preview_ray_start_height: float = 30.0
-@export_range(1.0, 200.0, 1.0) var preview_ray_length: float = 60.0
+@export_range(1.0, 200.0, 1.0) var preview_ray_start_height: float = 100.0
+@export_range(1.0, 400.0, 1.0) var preview_surface_ray_length: float = 240.0
@export_range(0.01, 1.0, 0.01) var preview_marker_vertical_offset: float = 0.06
+@export_range(0.0, 0.5, 0.01) var water_occlusion_tolerance: float = 0.04
+@export_range(0.01, 0.5, 0.01) var solid_bobber_clearance: float = 0.13
@export_category("Withdrawal")
-@export_range(0.1, 20.0, 0.1) var withdrawal_rate: float = 4.9
+@export_range(0.1, 20.0, 0.1) var withdrawal_rate: float = 2.45
@export_range(0.1, 10.0, 0.1) var withdrawal_cancel_distance: float = 1.0
+@export_range(0.0, 1.0, 0.01) var withdrawal_surface_clearance: float = 0.4
@export_category("Timing")
-@export_range(0.1, 30.0, 0.1) var wait_time: float = 2.0
+const BITE_QUICK_MIN_SECONDS: float = 6.0
+const BITE_QUICK_MAX_SECONDS: float = 18.0
+const BITE_TYPICAL_MAX_SECONDS: float = 48.0
+const BITE_LONG_MAX_SECONDS: float = 72.0
+const BITE_MAX_SECONDS: float = 90.0
+const BITE_QUICK_PROBABILITY: float = 0.30
+const BITE_TYPICAL_PROBABILITY: float = 0.65
+const BITE_LONG_PROBABILITY: float = 0.04
+const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1
@export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0
@export_category("Selection")
@@ -83,13 +117,14 @@ enum FishingState {
@export var deterministic_selection_seed: int = 24680
@export_category("Context Testing")
-@export var context_is_night: bool = false
-@export var context_is_raining: bool = false
@export var context_event_tags: Array[StringName] = []
@export var context_bait_tags: Array[StringName] = []
@onready var _catch_controller: CatchControllerType = %CatchController
@onready var _presentation: FishingPresentationType = %FishingPresentation
+@onready var _bobber_audio: AudioStreamPlayer = %BobberAudio
+@onready var _fight_audio: AudioStreamPlayer = %FightAudio
+@onready var _reeling_audio: AudioStreamPlayer = %ReelingAudio
var state: FishingState = FishingState.READY
var _external_input_blocked: bool = false
@@ -98,28 +133,37 @@ var _local_menu_input_owners: Dictionary[StringName, bool] = {}
var _local_player: PlayerType
var _local_inventory: FishInventoryType
var _local_collection_log: CollectionLogType
+var _local_experience: PlayerExperienceType
var _local_bag: PlayerBagType
var _local_hotbar: PlayerHotbarType
var _item_catalog: ItemCatalogType
var _fishing_upgrades: PlayerFishingUpgradesType
var _item_effects: PlayerItemEffectsType
+var _network_item_use: NetworkItemUseService
var _cooler_capacity: PlayerCoolerCapacityType
+var _network_session: NetworkSessionType
+var _network_fishing: NetworkFishingServiceType
+var _world_time: WorldTimeServiceType
+var _world_weather: WorldWeatherServiceType
var _active_player: PlayerType
var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0
var _cast_direction: Vector3 = Vector3.FORWARD
var _cast_origin_position: Vector3
var _cast_target: Vector3
-var _cast_landing_is_fishable: bool = false
+var _preferred_cast_arc_height: float = -1.0
var _withdrawal_endpoint: Vector3
var _withdrawal_progress: float = 0.0
var _withdrawal_input_held: bool = false
+var _network_primary_input_held: bool = false
+var _network_input_resend_elapsed: float = 0.0
var _new_cast_press_armed: bool = true
var _bobber_water_position: Vector3
-var _fight_start_position: Vector3
var _cooldown_status: String = ""
-var _fishable_query_shape: SphereShape3D = SphereShape3D.new()
var _fish_selector: FishSelectorType = FishSelectorType.new()
+var _surface_resolver: FishingSurfaceResolverType = FishingSurfaceResolverType.new()
+var _aim_surface_sample: FishingSurfaceSampleType = FishingSurfaceSampleType.new()
+var _cast_path_is_clear: bool = false
var _selected_water_region: FishableWaterRegionType
var _selection_context: FishingContextType
var _selected_fish: FishDataType
@@ -127,36 +171,101 @@ var _pending_catch: FishCatchType
var _showcase_ready: bool = false
var _put_away_press_armed: bool = false
var _showcase_restore_generation: int = 0
+var _showcase_outcome_completed: bool = false
+var _network_auto_click_accumulator: float = 0.0
+var _network_active_barrier_index: int = -1
+var _bite_rng: RandomNumberGenerator = RandomNumberGenerator.new()
+var _pending_cleanup_message: String = ""
+var _bite_confirmation_pending: bool = false
+var _bite_confirmation_requested: bool = false
func _ready() -> void:
+ _bite_rng.randomize()
+ _configure_surface_resolver()
+ var fight_stream := _fight_audio.stream as AudioStreamWAV
+ if fight_stream != null:
+ _configure_audio_loop(fight_stream)
+ var reeling_stream := _reeling_audio.stream as AudioStreamWAV
+ if reeling_stream != null:
+ _configure_audio_loop(reeling_stream)
_catch_controller.encounter_updated.connect(_on_catch_encounter_updated)
_catch_controller.caught.connect(_on_catch_completed)
_catch_controller.escaped.connect(_on_catch_escaped)
_presentation.cast_completed.connect(_on_cast_completed)
_presentation.outcome_completed.connect(_on_outcome_completed)
+ _presentation.presentation_interrupted.connect(
+ _on_presentation_interrupted
+ )
func setup(
local_player: PlayerType,
local_inventory: FishInventoryType,
local_collection_log: CollectionLogType,
+ local_experience: PlayerExperienceType,
local_bag: PlayerBagType,
local_hotbar: PlayerHotbarType,
item_catalog: ItemCatalogType,
fishing_upgrades: PlayerFishingUpgradesType,
item_effects: PlayerItemEffectsType,
+ network_item_use: NetworkItemUseService,
cooler_capacity: PlayerCoolerCapacityType,
+ network_session: NetworkSessionType = null,
+ network_fishing: NetworkFishingServiceType = null,
+ world_time: WorldTimeServiceType = null,
+ world_weather: WorldWeatherServiceType = null,
) -> void:
_local_player = local_player
_local_inventory = local_inventory
_local_collection_log = local_collection_log
+ _local_experience = local_experience
_local_bag = local_bag
_local_hotbar = local_hotbar
_item_catalog = item_catalog
_fishing_upgrades = fishing_upgrades
_item_effects = item_effects
+ _network_item_use = network_item_use
+ if _network_item_use != null:
+ _network_item_use.local_item_use_finished.connect(
+ _on_network_item_use_finished
+ )
_cooler_capacity = cooler_capacity
+ _network_session = network_session
+ _network_fishing = network_fishing
+ _world_time = world_time
+ _world_weather = world_weather
+ if (
+ _network_session != null
+ and not _network_session.state_changed.is_connected(
+ _on_network_session_state_changed
+ )
+ ):
+ _network_session.state_changed.connect(
+ _on_network_session_state_changed
+ )
+ if _network_fishing != null:
+ _network_fishing.local_cast_accepted.connect(
+ _on_network_cast_accepted
+ )
+ _network_fishing.local_cast_rejected.connect(
+ _on_network_cast_rejected
+ )
+ _network_fishing.local_bite_started.connect(
+ _on_network_bite_started
+ )
+ _network_fishing.local_bite_pending.connect(
+ _on_network_bite_pending
+ )
+ _network_fishing.local_snapshot_received.connect(
+ _on_network_fishing_snapshot
+ )
+ _network_fishing.local_catch_received.connect(
+ _on_network_catch_received
+ )
+ _network_fishing.local_attempt_ended.connect(
+ _on_network_attempt_ended
+ )
if not _local_inventory.catches_changed.is_connected(
_on_cooler_availability_changed
):
@@ -195,6 +304,8 @@ func can_open_system_menu() -> bool:
func can_open_fishing_shop() -> bool:
return (
+ _can_use_shop_gameplay()
+ and
_gameplay_input_enabled
and not _external_input_blocked
and _local_menu_input_owners.is_empty()
@@ -202,8 +313,19 @@ func can_open_fishing_shop() -> bool:
)
+func can_use_surface_drawing() -> bool:
+ return (
+ _gameplay_input_enabled
+ and not _external_input_blocked
+ and state == FishingState.READY
+ and _active_player == null
+ )
+
+
func is_ready_for_shop_transaction() -> bool:
return (
+ _can_use_shop_gameplay()
+ and
_gameplay_input_enabled
and not _external_input_blocked
and state == FishingState.READY
@@ -239,17 +361,18 @@ func handle_escape_action() -> bool:
func set_local_menu_input_suppressed(
- owner: StringName,
+ input_owner: StringName,
suppressed: bool,
) -> void:
- if owner.is_empty():
+ if input_owner.is_empty():
return
if suppressed:
- _local_menu_input_owners[owner] = true
+ _local_menu_input_owners[input_owner] = true
else:
- _local_menu_input_owners.erase(owner)
+ _local_menu_input_owners.erase(input_owner)
if suppressed and state == FishingState.WAITING_FOR_BITE:
_withdrawal_input_held = false
+ _stop_reeling_audio()
_presentation.set_line_mode(
FishingPresentationType.LineMode.SLACK
)
@@ -259,6 +382,7 @@ func set_gameplay_input_enabled(enabled: bool) -> void:
_gameplay_input_enabled = enabled
if not enabled:
_local_menu_input_owners.clear()
+ _stop_reeling_audio()
func configure_accessibility_auto_click(
@@ -277,6 +401,11 @@ func begin_water_recovery() -> void:
FishingState.FIGHTING,
]:
_cancel_attempt()
+ # Recovery replaces the normal rod-return presentation. Finish the local
+ # cleanup now so its temporary movement lock is not captured and restored
+ # after the player respawns.
+ if state != FishingState.READY:
+ _finalize_attempt_cleanup("")
elif state == FishingState.SHOWING_CATCH:
_secure_showcase_catch_for_recovery()
@@ -291,18 +420,19 @@ func _secure_showcase_catch_for_recovery() -> void:
and _local_inventory != null
and _local_collection_log != null
):
- _local_inventory.add_catch(_pending_catch)
- _local_collection_log.mark_discovered(_pending_catch.fish_id)
+ _store_catch_progression(_pending_catch)
_pending_catch = null
_showcase_ready = false
_put_away_press_armed = false
- showcase_changed.emit("", "", 0.0, false)
+ showcase_changed.emit("", "", 0.0, 0, false)
if _active_player != null:
_active_player.end_catch_showcase(Callable(), true)
_cleanup_attempt()
func _exit_tree() -> void:
+ _stop_fight_audio()
+ _stop_reeling_audio()
_showcase_restore_generation += 1
if (
state == FishingState.SHOWING_CATCH
@@ -312,8 +442,7 @@ func _exit_tree() -> void:
and _local_collection_log != null
and is_instance_valid(_local_collection_log)
):
- _local_inventory.add_catch(_pending_catch)
- _local_collection_log.mark_discovered(_pending_catch.fish_id)
+ _store_catch_progression(_pending_catch)
_pending_catch = null
if _active_player != null and is_instance_valid(_active_player):
_active_player.end_catch_showcase(Callable(), true)
@@ -342,14 +471,21 @@ func _process(delta: float) -> void:
if not Input.is_action_pressed("fish_primary"):
_confirm_cast()
FishingState.WAITING_FOR_BITE:
- _update_waiting_for_bite(delta)
+ if _network_fishing == null:
+ _update_waiting_for_bite(delta)
+ else:
+ _resend_network_input_state(delta)
FishingState.FIGHTING:
- _catch_controller.set_effective_stats(
- _get_effective_reel_speed(),
- _get_effective_barrier_damage()
- )
- if not Input.is_action_pressed("fish_primary"):
- _catch_controller.set_reel_input(false)
+ if _network_fishing == null:
+ _catch_controller.set_effective_stats(
+ _get_effective_reel_speed(),
+ _get_effective_barrier_damage()
+ )
+ if not Input.is_action_pressed("fish_primary"):
+ _catch_controller.set_reel_input(false)
+ else:
+ _resend_network_input_state(delta)
+ _update_network_auto_click(delta)
FishingState.COOLDOWN:
_state_time_remaining -= delta
if _state_time_remaining <= 0.0:
@@ -367,7 +503,6 @@ func _unhandled_input(event: InputEvent) -> void:
return
if not event.is_action("fish_primary"):
return
-
if event.is_pressed():
match state:
FishingState.READY:
@@ -376,16 +511,28 @@ func _unhandled_input(event: InputEvent) -> void:
var active_item: ItemDataType = _get_active_item()
if (
active_item != null
- and active_item.category == ItemDataType.Category.CONSUMABLE
+ and (
+ active_item.category == ItemDataType.Category.CONSUMABLE
+ or (
+ active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID
+ )
+ )
):
- if _item_effects.use_consumable(active_item, _local_bag):
- status_changed.emit(
- _item_effects.get_feedback(active_item.item_id)
+ if _network_item_use != null:
+ _network_item_use.request_use(active_item.item_id)
+ elif _item_effects.use_item(active_item, _local_bag):
+ status_changed.emit(_item_effects.get_feedback(
+ active_item.item_id
+ ))
+ elif (
+ active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID
+ ):
+ local_speech_requested.emit(
+ PlayerItemEffectsType.FISH_FINDER_DEAD_MESSAGE
)
get_viewport().set_input_as_handled()
return
if not has_active_fishing_rod():
- status_changed.emit("select a fishing rod to cast.")
get_viewport().set_input_as_handled()
return
if (
@@ -397,13 +544,13 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
return
_begin_aiming(_local_player)
- FishingState.WAITING_FOR_BITE:
- _withdrawal_input_held = true
- _presentation.set_line_mode(
- FishingPresentationType.LineMode.TAUT
- )
FishingState.FIGHTING:
- _catch_controller.handle_primary_pressed()
+ if _network_fishing != null:
+ _network_primary_input_held = true
+ _network_input_resend_elapsed = 0.0
+ _network_fishing.submit_local_input(true, true)
+ else:
+ _catch_controller.handle_primary_pressed()
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
@@ -412,6 +559,20 @@ func _unhandled_input(event: InputEvent) -> void:
_put_away_catch()
else:
return
+ FishingState.WAITING_FOR_BITE:
+ if _bite_confirmation_pending:
+ confirm_pending_bite()
+ get_viewport().set_input_as_handled()
+ return
+ _withdrawal_input_held = true
+ _start_reeling_audio()
+ if _network_fishing != null:
+ _network_primary_input_held = true
+ _network_input_resend_elapsed = 0.0
+ _network_fishing.submit_local_input(true, true)
+ _presentation.set_line_mode(
+ FishingPresentationType.LineMode.TAUT
+ )
_:
return
get_viewport().set_input_as_handled()
@@ -419,11 +580,24 @@ func _unhandled_input(event: InputEvent) -> void:
_confirm_cast()
get_viewport().set_input_as_handled()
elif state == FishingState.WAITING_FOR_BITE:
+ if _bite_confirmation_pending:
+ get_viewport().set_input_as_handled()
+ return
_withdrawal_input_held = false
+ _stop_reeling_audio()
+ if _network_fishing != null:
+ _network_primary_input_held = false
+ _network_input_resend_elapsed = 0.0
+ _network_fishing.submit_local_input(false, false)
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
get_viewport().set_input_as_handled()
elif state == FishingState.FIGHTING:
- _catch_controller.set_reel_input(false)
+ if _network_fishing != null:
+ _network_primary_input_held = false
+ _network_input_resend_elapsed = 0.0
+ _network_fishing.submit_local_input(false, false)
+ else:
+ _catch_controller.set_reel_input(false)
get_viewport().set_input_as_handled()
@@ -431,22 +605,46 @@ func _begin_aiming(player: PlayerType) -> void:
if state != FishingState.READY or _active_player != null:
return
- _presentation.reset_water_surface_height()
_active_player = player
- _active_player.set_movement_enabled(false)
_cast_direction = _capture_cast_direction(_active_player)
_cast_origin_position = _active_player.get_cast_origin_position()
_cast_charge = 0.0
_cast_target = _calculate_cast_target(minimum_cast_distance)
state = FishingState.AIMING_CAST
- status_changed.emit("hold left click to aim • release to cast")
- var target_is_fishable: bool = is_target_fishable(_cast_target)
- var preview_position: Vector3 = _resolve_preview_surface_position(_cast_target)
+ _active_player.set_casting_visual()
+ status_changed.emit("")
+ _cast_path_is_clear = is_cast_path_clear(
+ _get_cast_launch_position(),
+ _cast_target,
+ )
+ var target_is_fishable: bool = (
+ _aim_surface_sample.is_fishable() and _cast_path_is_clear
+ )
+ var preview_position: Vector3 = _aim_surface_sample.get_marker_position(
+ preview_marker_vertical_offset
+ )
_presentation.begin_aim(
_active_player.get_fishing_rod_tip(),
_active_player.get_fishing_rod(),
preview_position,
- target_is_fishable
+ _aim_surface_sample.normal,
+ target_is_fishable,
+ _active_player.get_character_visual_scale(),
+ )
+
+
+func _can_use_shared_gameplay() -> bool:
+ return _network_session == null or _network_session.can_use_host_gameplay()
+
+
+func _can_use_shop_gameplay() -> bool:
+ return (
+ _network_session == null
+ or _network_session.is_host()
+ or (
+ _network_session.is_joined_client()
+ and _network_session.supports_server_capability(&"shop_v1")
+ )
)
@@ -503,6 +701,32 @@ func is_fighting() -> bool:
return state == FishingState.FIGHTING
+func confirm_pending_bite() -> void:
+ if state != FishingState.WAITING_FOR_BITE or not _bite_confirmation_pending:
+ return
+ _bite_confirmation_requested = true
+ if _network_fishing != null:
+ _network_primary_input_held = false
+ _network_input_resend_elapsed = 0.0
+ _network_fishing.submit_local_input(false, true)
+ else:
+ _activate_bite(true)
+
+
+func _set_bite_confirmation_pending(is_pending: bool) -> void:
+ if _bite_confirmation_pending == is_pending:
+ if not is_pending:
+ _bite_confirmation_requested = false
+ return
+ _bite_confirmation_pending = is_pending
+ _bite_confirmation_requested = false
+ bite_prompt_changed.emit(is_pending)
+
+
+func is_returning() -> bool:
+ return state == FishingState.RETURNING
+
+
func refresh_active_item_status() -> void:
if state == FishingState.READY and has_active_fishing_rod():
status_changed.emit("")
@@ -512,14 +736,27 @@ func _update_cast_charge(delta: float) -> void:
if state != FishingState.AIMING_CAST:
return
+ # Movement remains available during aiming. Re-sample the player transform
+ # so the cast origin and facing follow the player until release.
+ _cast_origin_position = _active_player.get_cast_origin_position()
+ _cast_direction = _capture_cast_direction(_active_player)
_cast_charge = minf(_cast_charge + delta / cast_charge_duration, 1.0)
var maximum_distance: float = maxf(minimum_cast_distance, maximum_cast_distance)
var distance: float = lerpf(minimum_cast_distance, maximum_distance, _cast_charge)
_cast_target = _calculate_cast_target(distance)
- var target_is_fishable: bool = is_target_fishable(_cast_target)
- var preview_position: Vector3 = _resolve_preview_surface_position(_cast_target)
+ _cast_path_is_clear = is_cast_path_clear(
+ _get_cast_launch_position(),
+ _cast_target,
+ )
+ var target_is_fishable: bool = (
+ _aim_surface_sample.is_fishable() and _cast_path_is_clear
+ )
+ var preview_position: Vector3 = _aim_surface_sample.get_marker_position(
+ preview_marker_vertical_offset
+ )
_presentation.update_aim_target(
preview_position,
+ _aim_surface_sample.normal,
_cast_charge,
target_is_fishable
)
@@ -530,21 +767,67 @@ func _confirm_cast() -> void:
if state != FishingState.AIMING_CAST:
return
+ var cast_launch_position := _get_cast_launch_position()
+ var resolved_cast_arc: Dictionary = _surface_resolver.resolve_cast_arc(
+ get_world_3d().direct_space_state,
+ cast_launch_position,
+ _cast_target,
+ _presentation.cast_arc_height,
+ )
+ var resolved_arc_height: float = float(
+ resolved_cast_arc.get("arc_height", _presentation.cast_arc_height)
+ )
+ var resolved_cast_collision: Dictionary = resolved_cast_arc.get(
+ "collision",
+ {},
+ )
+ _cast_path_is_clear = resolved_cast_collision.is_empty()
+ var cast_is_invalid: bool = (
+ not _aim_surface_sample.is_fishable() or not _cast_path_is_clear
+ )
+ var arrival_position: Vector3 = _cast_target
+ if not _cast_path_is_clear:
+ var collision_position: Variant = resolved_cast_collision.get(
+ "position",
+ _cast_target,
+ )
+ if typeof(collision_position) == TYPE_VECTOR3:
+ arrival_position = collision_position
+ if _active_player != null:
+ # Movement is available while aiming, then locks once the cast is
+ # released so the active bobber/fishing event remains anchored.
+ _active_player.set_movement_enabled(false)
state = FishingState.CASTING
- status_changed.emit("casting...")
- _presentation.begin_cast(_cast_target)
+ if _active_player != null:
+ _active_player.set_release_visual()
+ status_changed.emit("")
+ _presentation.begin_cast(
+ _cast_target,
+ arrival_position,
+ cast_is_invalid,
+ resolved_arc_height,
+ )
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
func _on_cast_completed() -> void:
if state != FishingState.CASTING:
return
+ if not _is_cast_target_valid(_cast_target):
+ _cleanup_attempt("", &"invalid")
+ return
+ _play_bobber_audio()
+ if _network_fishing != null:
+ _network_fishing.request_local_cast(
+ _cast_origin_position,
+ _cast_target,
+ _cast_charge,
+ _build_network_evidence()
+ )
+ status_changed.emit("")
+ return
_selected_water_region = get_fishable_water_region(_cast_target)
- _cast_landing_is_fishable = _selected_water_region != null
- if not _cast_landing_is_fishable:
- _cleanup_attempt("can't fish there.", &"invalid")
- return
_selection_context = _build_fishing_context(_selected_water_region)
_fish_selector.undiscovered_weight_multiplier = undiscovered_weight_multiplier
_fish_selector.rarity_weight_multipliers = []
@@ -565,31 +848,72 @@ func _on_cast_completed() -> void:
if _selected_fish == null:
_cleanup_attempt("nothing is biting here.", &"invalid")
return
+ _consume_active_bait()
state = FishingState.WAITING_FOR_BITE
- _state_time_remaining = wait_time * (
+ _active_player.set_fishing_visual(true)
+ _state_time_remaining = roll_bite_wait_time() * (
_item_effects.get_bite_time_multiplier()
if _item_effects != null
else 1.0
)
_withdrawal_progress = 0.0
_withdrawal_input_held = false
+ _network_auto_click_accumulator = 0.0
+ _network_active_barrier_index = -1
_bobber_water_position = _cast_target
_withdrawal_endpoint = Vector3(
_cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance,
- _presentation.get_water_surface_height(),
+ _cast_target.y,
_cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance
)
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
- status_changed.emit("waiting for a bite...")
+ status_changed.emit("")
+
+
+func _consume_active_bait() -> void:
+ if _active_player == null or _active_player.active_bait_id.is_empty():
+ return
+ if _local_bag == null or not _local_bag.remove_item(_active_player.active_bait_id, 1):
+ _active_player.unequip_bait()
+
+
+func roll_bite_wait_time() -> float:
+ var bucket: float = _bite_rng.randf()
+ if bucket < BITE_QUICK_PROBABILITY:
+ return _bite_rng.randf_range(
+ BITE_QUICK_MIN_SECONDS,
+ BITE_QUICK_MAX_SECONDS,
+ )
+ if bucket < BITE_QUICK_PROBABILITY + BITE_TYPICAL_PROBABILITY:
+ return _bite_rng.randf_range(
+ BITE_QUICK_MAX_SECONDS,
+ BITE_TYPICAL_MAX_SECONDS,
+ )
+ if bucket < (
+ BITE_QUICK_PROBABILITY
+ + BITE_TYPICAL_PROBABILITY
+ + BITE_LONG_PROBABILITY
+ ):
+ return _bite_rng.randf_range(
+ BITE_TYPICAL_MAX_SECONDS,
+ BITE_LONG_MAX_SECONDS,
+ )
+ return _bite_rng.randf_range(
+ BITE_LONG_MAX_SECONDS,
+ BITE_MAX_SECONDS,
+ )
func _update_waiting_for_bite(delta: float) -> void:
+ if _bite_confirmation_pending:
+ return
if (
_withdrawal_input_held
and not Input.is_action_pressed("fish_primary")
):
_withdrawal_input_held = false
+ _stop_reeling_audio()
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
if not _withdrawal_input_held:
@@ -630,16 +954,18 @@ func _update_withdrawal(delta: float) -> void:
_withdrawal_endpoint,
next_progress
)
- var clamped_position: Vector3 = find_last_fishable_position(
- _bobber_water_position,
- desired_position
+ var withdrawal_surface: FishingSurfaceSampleType = (
+ resolve_safe_withdrawal_surface(
+ _bobber_water_position,
+ desired_position,
+ )
)
- _withdrawal_progress = next_progress
- _bobber_water_position = clamped_position
- _presentation.show_withdrawal_position(_bobber_water_position)
- if not clamped_position.is_equal_approx(desired_position):
+ if not withdrawal_surface.is_fishable():
_resolve_withdrawal_at_shore()
return
+ _withdrawal_progress = next_progress
+ _bobber_water_position = withdrawal_surface.position
+ _presentation.show_withdrawal_position(_bobber_water_position)
if is_equal_approx(_withdrawal_progress, 1.0):
_cancel_from_withdrawal()
@@ -657,20 +983,14 @@ func _get_withdrawal_completion_time(delta: float) -> float:
_withdrawal_endpoint,
next_progress
)
- var clamped_position: Vector3 = find_last_fishable_position(
- _bobber_water_position,
- desired_position
+ var withdrawal_surface: FishingSurfaceSampleType = (
+ resolve_safe_withdrawal_surface(
+ _bobber_water_position,
+ desired_position,
+ )
)
- if not clamped_position.is_equal_approx(desired_position):
- var segment_length: float = _bobber_water_position.distance_to(
- desired_position
- )
- if segment_length <= 0.0:
- return 0.0
- return delta * (
- _bobber_water_position.distance_to(clamped_position)
- / segment_length
- )
+ if not withdrawal_surface.is_fishable():
+ return 0.0
if is_equal_approx(next_progress, 1.0):
return delta * (1.0 - _withdrawal_progress) / progress_step
return INF
@@ -682,10 +1002,11 @@ func _get_withdrawable_distance() -> float:
return landing_offset.length() - withdrawal_cancel_distance
-func _activate_bite() -> void:
+func _activate_bite(confirmation_override: bool = false) -> void:
if state != FishingState.WAITING_FOR_BITE:
return
+ _stop_reeling_audio()
if (
_active_player == null
or _selected_fish == null
@@ -693,19 +1014,37 @@ func _activate_bite() -> void:
):
_cancel_attempt()
return
+ if (
+ not confirmation_override
+ and _active_lure_has_effect(&"deferred_fight")
+ ):
+ _state_time_remaining = 0.0
+ _withdrawal_input_held = false
+ _set_bite_confirmation_pending(true)
+ status_changed.emit("")
+ _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
+ _presentation.show_bite()
+ return
+ _set_bite_confirmation_pending(false)
state = FishingState.FIGHTING
+ _active_player.set_fighting_visual(true)
_state_time_remaining = 0.0
_withdrawal_input_held = false
- _fight_start_position = _bobber_water_position
+ _pending_catch = _fish_selector.create_catch(_selected_fish)
+ if _pending_catch == null or not _pending_catch.is_valid():
+ _cancel_attempt()
+ return
+ _start_fight_audio()
bite_activated.emit()
status_changed.emit("fish on!")
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
- _presentation.begin_reeling()
+ _presentation.begin_fight()
_catch_controller.start_encounter(
_selected_fish.catch_profile,
_get_effective_reel_speed(),
- _get_effective_barrier_damage()
+ _get_effective_barrier_damage(),
+ _pending_catch.quality,
)
_catch_controller.set_reel_input(
Input.is_action_pressed("fish_primary")
@@ -748,7 +1087,7 @@ func _on_catch_encounter_updated(
barrier_health: PackedInt32Array,
barrier_max_health: PackedInt32Array,
active_barrier_index: int,
- visible: bool,
+ encounter_visible: bool,
) -> void:
catch_display_changed.emit(
progress,
@@ -757,24 +1096,11 @@ func _on_catch_encounter_updated(
barrier_health,
barrier_max_health,
active_barrier_index,
- visible
+ encounter_visible
)
- if state != FishingState.FIGHTING or not visible:
+ if state != FishingState.FIGHTING or not encounter_visible:
return
- var desired_position: Vector3 = _fight_start_position.lerp(
- _withdrawal_endpoint,
- progress
- )
- _bobber_water_position = find_last_fishable_position(
- _fight_start_position,
- desired_position
- )
- _presentation.show_reel_position(
- _bobber_water_position,
- Input.is_action_pressed("fish_primary")
- )
-
func _on_catch_completed() -> void:
if (
@@ -785,12 +1111,15 @@ func _on_catch_completed() -> void:
_cancel_attempt()
return
- _pending_catch = _fish_selector.create_catch(_selected_fish)
if _pending_catch == null or not _pending_catch.is_valid():
_cancel_attempt()
return
+ _stop_fight_audio()
+ _active_player.set_fighting_visual(false)
+ _active_player.set_retract_visual()
state = FishingState.SHOWING_CATCH
_showcase_ready = false
+ _showcase_outcome_completed = false
_put_away_press_armed = false
_catch_controller.reset()
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
@@ -801,29 +1130,49 @@ func _on_catch_escaped() -> void:
if state != FishingState.FIGHTING:
return
- var fish_name: String = "the fish"
- if _selected_fish != null and not _selected_fish.display_name.is_empty():
- fish_name = "the %s" % _selected_fish.display_name
- _cleanup_attempt("%s got away!" % fish_name, &"escape")
+ _stop_fight_audio()
+ _cleanup_attempt("", &"escape")
func _on_outcome_completed(outcome: StringName) -> void:
+ var retracting_player := _active_player
+ if (
+ retracting_player != null
+ and not retracting_player.is_retract_visual_complete()
+ ):
+ await retracting_player.retract_visual_finished
+ if state == FishingState.RETURNING:
+ var cleanup_message: String = _pending_cleanup_message
+ _pending_cleanup_message = ""
+ _finalize_attempt_cleanup(cleanup_message)
+ return
if (
outcome != &"catch"
or state != FishingState.SHOWING_CATCH
or _active_player == null
or _pending_catch == null
+ or _showcase_outcome_completed
):
return
+ _showcase_outcome_completed = true
_showcase_ready = true
_active_player.begin_catch_showcase(_pending_catch)
showcase_changed.emit(
_pending_catch.fish.display_name,
_pending_catch.fish.get_rarity_name(),
_pending_catch.weight_lb,
+ _pending_catch.quality,
true
)
- status_changed.emit("left click or escape to put away")
+
+
+func _on_presentation_interrupted() -> void:
+ if state in [FishingState.READY, FishingState.COOLDOWN]:
+ return
+ if _network_fishing != null and _network_fishing.has_local_attempt():
+ _network_fishing.cancel_local_attempt("")
+ return
+ _finalize_attempt_cleanup("")
func _put_away_catch() -> void:
@@ -835,23 +1184,45 @@ func _put_away_catch() -> void:
or _pending_catch == null
):
return
- var caught_name: String = _pending_catch.fish.display_name
- _local_inventory.add_catch(_pending_catch)
- _local_collection_log.mark_discovered(_pending_catch.fish_id)
+ _store_catch_progression(_pending_catch)
_pending_catch = null
_showcase_ready = false
+ _showcase_outcome_completed = false
_put_away_press_armed = false
- showcase_changed.emit("", "", 0.0, false)
+ showcase_changed.emit("", "", 0.0, 0, false)
_showcase_restore_generation += 1
var restore_generation: int = _showcase_restore_generation
_active_player.end_catch_showcase(
_finish_showcase_put_away.bind(
restore_generation,
- "caught %s!" % caught_name
+ ""
)
)
+func _store_catch_progression(fish_catch: FishCatchType) -> void:
+ if (
+ fish_catch == null
+ or _local_inventory == null
+ or _local_collection_log == null
+ or _local_experience == null
+ or _local_inventory.contains_catch_id(fish_catch.catch_id)
+ ):
+ return
+ var experience_award: int = (
+ FishExperienceType.calculate_for_collection(
+ fish_catch,
+ _local_collection_log,
+ )
+ )
+ _local_inventory.add_catch(fish_catch)
+ _local_collection_log.mark_quality_discovered(
+ fish_catch.fish_id,
+ fish_catch.quality,
+ )
+ _local_experience.award_experience(experience_award)
+
+
func _finish_showcase_put_away(
restore_generation: int,
cooldown_message: String,
@@ -869,35 +1240,33 @@ func _cleanup_attempt(
cooldown_message: String = "",
visual_outcome: StringName = &"",
) -> void:
- _showcase_restore_generation += 1
+ _set_bite_confirmation_pending(false)
+ _stop_fight_audio()
+ _stop_reeling_audio()
if _active_player != null:
- _active_player.end_catch_showcase()
- _active_player.set_movement_enabled(true)
- _active_player = null
- _state_time_remaining = 0.0
- _cast_charge = 0.0
- _cast_direction = Vector3.FORWARD
- _cast_origin_position = Vector3.ZERO
- _cast_target = Vector3.ZERO
- _cast_landing_is_fishable = false
- _withdrawal_endpoint = Vector3.ZERO
- _withdrawal_progress = 0.0
- _withdrawal_input_held = false
- _bobber_water_position = Vector3.ZERO
- _fight_start_position = Vector3.ZERO
- _selected_water_region = null
- _selection_context = null
- _selected_fish = null
- _pending_catch = null
- _showcase_ready = false
- _put_away_press_armed = false
- showcase_changed.emit("", "", 0.0, false)
- _catch_controller.reset()
- if visual_outcome.is_empty():
- _presentation.cleanup()
- _presentation.reset_water_surface_height()
- else:
- if visual_outcome in [&"catch", &"invalid", &"withdrawal"]:
+ _active_player.set_fighting_visual(false)
+ if visual_outcome.is_empty():
+ _active_player.set_fishing_visual(false)
+ elif visual_outcome == &"catch":
+ # A successful catch already retracted the line before the fish
+ # showcase. Returning from the pocket transition should settle back
+ # to idle instead of replaying the retract animation.
+ _active_player.set_fishing_visual(false)
+ else:
+ _active_player.set_retract_visual()
+ if not visual_outcome.is_empty():
+ if state == FishingState.RETURNING:
+ return
+ _showcase_restore_generation += 1
+ _pending_cleanup_message = cooldown_message
+ _showcase_ready = false
+ _showcase_outcome_completed = false
+ _put_away_press_armed = false
+ showcase_changed.emit("", "", 0.0, 0, false)
+ _catch_controller.reset()
+ state = FishingState.RETURNING
+ status_changed.emit("")
+ if visual_outcome in [&"invalid", &"withdrawal", &"escape"]:
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
@@ -906,6 +1275,42 @@ func _cleanup_attempt(
FishingPresentationType.LineMode.HIDDEN
)
_presentation.play_outcome(visual_outcome)
+ return
+ _finalize_attempt_cleanup(cooldown_message)
+
+
+func _finalize_attempt_cleanup(cooldown_message: String) -> void:
+ _showcase_restore_generation += 1
+ if _active_player != null:
+ _active_player.set_fighting_visual(false)
+ _active_player.set_fishing_visual(false)
+ _active_player.end_catch_showcase()
+ _active_player.set_movement_enabled(true)
+ _active_player = null
+ _state_time_remaining = 0.0
+ _cast_charge = 0.0
+ _cast_direction = Vector3.FORWARD
+ _cast_origin_position = Vector3.ZERO
+ _cast_target = Vector3.ZERO
+ _aim_surface_sample = FishingSurfaceSampleType.new()
+ _cast_path_is_clear = false
+ _withdrawal_endpoint = Vector3.ZERO
+ _withdrawal_progress = 0.0
+ _withdrawal_input_held = false
+ _network_primary_input_held = false
+ _network_input_resend_elapsed = 0.0
+ _bobber_water_position = Vector3.ZERO
+ _selected_water_region = null
+ _selection_context = null
+ _selected_fish = null
+ _pending_catch = null
+ _showcase_ready = false
+ _showcase_outcome_completed = false
+ _put_away_press_armed = false
+ showcase_changed.emit("", "", 0.0, 0, false)
+ _catch_controller.reset()
+ _presentation.cleanup()
+ _pending_cleanup_message = ""
if cooldown_message.is_empty():
state = FishingState.READY
@@ -920,7 +1325,6 @@ func _cleanup_attempt(
func _return_to_ready() -> void:
- _presentation.reset_water_surface_height()
state = FishingState.READY
_state_time_remaining = 0.0
_cooldown_status = ""
@@ -929,13 +1333,19 @@ func _return_to_ready() -> void:
func _cancel_attempt() -> void:
- _cleanup_attempt("fishing cancelled.", &"cancel")
+ if _network_fishing != null and _network_fishing.has_local_attempt():
+ _network_fishing.cancel_local_attempt("")
+ return
+ _cleanup_attempt("", &"cancel")
func _cancel_from_withdrawal() -> void:
if state != FishingState.WAITING_FOR_BITE:
return
_new_cast_press_armed = false
+ if _network_fishing != null and _network_fishing.has_local_attempt():
+ _network_fishing.cancel_local_attempt("")
+ return
_cleanup_attempt("", &"withdrawal")
@@ -956,61 +1366,120 @@ func _capture_cast_direction(player: PlayerType) -> Vector3:
return direction.normalized()
+func _get_cast_launch_position() -> Vector3:
+ if _active_player != null:
+ var rod_tip: Marker3D = _active_player.get_fishing_rod_tip()
+ if rod_tip != null and is_instance_valid(rod_tip):
+ return rod_tip.global_position
+ return _cast_origin_position
+
+
func _calculate_cast_target(distance: float) -> Vector3:
- var target := Vector3(
+ var query_position := Vector3(
_cast_origin_position.x + _cast_direction.x * distance,
- _presentation.get_water_surface_height(),
+ _cast_origin_position.y,
_cast_origin_position.z + _cast_direction.z * distance
)
- var water_region: FishableWaterRegionType = get_fishable_water_region(
- target
+ _aim_surface_sample = resolve_fishing_surface(
+ query_position,
+ _cast_origin_position.y,
+ )
+ if _aim_surface_sample.has_surface:
+ return _aim_surface_sample.get_bobber_position(solid_bobber_clearance)
+ return query_position
+
+
+func _configure_surface_resolver() -> void:
+ _surface_resolver.fishable_surface_mask = fishable_surface_mask
+ _surface_resolver.solid_surface_mask = (
+ preview_surface_mask & ~fishable_surface_mask
+ )
+ _surface_resolver.query_radius = fishable_query_radius
+ _surface_resolver.ray_start_height = preview_ray_start_height
+ _surface_resolver.ray_length = preview_surface_ray_length
+ _surface_resolver.water_occlusion_tolerance = water_occlusion_tolerance
+
+
+func resolve_fishing_surface(
+ target: Vector3,
+ reference_y: float = NAN,
+) -> FishingSurfaceSampleType:
+ if not is_inside_tree():
+ var empty_sample := FishingSurfaceSampleType.new()
+ empty_sample.position = target
+ return empty_sample
+ var resolved_reference_y: float = reference_y
+ if is_nan(resolved_reference_y):
+ resolved_reference_y = (
+ _active_player.global_position.y
+ if _active_player != null
+ else target.y
+ )
+ return _surface_resolver.resolve_surface(
+ get_world_3d().direct_space_state,
+ target,
+ resolved_reference_y,
+ water_occlusion_tolerance,
)
- if water_region != null:
- target.y = water_region.surface_height
- _presentation.set_water_surface_height(target.y)
- else:
- _presentation.reset_water_surface_height()
- target.y = _presentation.get_water_surface_height()
- return target
func is_target_fishable(target: Vector3) -> bool:
- return get_fishable_water_region(target) != null
+ return resolve_fishing_surface(target).is_fishable()
+
+
+func _is_cast_target_valid(target: Vector3) -> bool:
+ return (
+ is_target_fishable(target)
+ and is_cast_path_clear(_get_cast_launch_position(), target)
+ )
+
+
+func is_cast_path_clear(origin: Vector3, target: Vector3) -> bool:
+ if _preferred_cast_arc_height < 0.0:
+ _preferred_cast_arc_height = maxf(
+ _presentation.cast_arc_height,
+ 0.0,
+ )
+ var resolved_arc: Dictionary = _surface_resolver.resolve_cast_arc(
+ get_world_3d().direct_space_state,
+ origin,
+ target,
+ _preferred_cast_arc_height,
+ )
+ _presentation.cast_arc_height = float(resolved_arc["arc_height"])
+ var collision: Dictionary = resolved_arc["collision"]
+ return collision.is_empty()
+
+
+func _resolve_cast_impact_position(origin: Vector3, target: Vector3) -> Vector3:
+ var result: Dictionary = _surface_resolver.find_first_cast_collision(
+ get_world_3d().direct_space_state,
+ origin,
+ target,
+ _presentation.cast_arc_height,
+ )
+ if result.is_empty():
+ return target
+ var hit_position: Vector3 = result["position"]
+ var hit_normal: Vector3 = result.get("normal", Vector3.UP)
+ if hit_normal.is_zero_approx():
+ hit_normal = Vector3.UP
+ return hit_position + hit_normal.normalized() * 0.12
func get_fishable_water_region(
target: Vector3,
) -> FishableWaterRegionType:
- _fishable_query_shape.radius = fishable_query_radius
- var query := PhysicsShapeQueryParameters3D.new()
- query.shape = _fishable_query_shape
- query.transform = Transform3D(
- Basis.IDENTITY,
- Vector3(target.x, _presentation.get_water_surface_height(), target.z)
+ var reference_y: float = (
+ _active_player.global_position.y
+ if _active_player != null
+ else target.y
)
- query.collision_mask = fishable_surface_mask
- query.collide_with_areas = true
- query.collide_with_bodies = false
- var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_shape(
- query,
- 32
+ var surface: FishingSurfaceSampleType = resolve_fishing_surface(
+ target,
+ reference_y,
)
- var selected_region: FishableWaterRegionType
- for result: Dictionary in results:
- var collider: Object = result.get("collider")
- var region: FishableWaterRegionType = collider as FishableWaterRegionType
- if region == null or region.fish_pool == null:
- continue
- if (
- selected_region == null
- or region.selection_priority > selected_region.selection_priority
- or (
- region.selection_priority == selected_region.selection_priority
- and region.get_instance_id() < selected_region.get_instance_id()
- )
- ):
- selected_region = region
- return selected_region
+ return surface.water_region if surface.is_fishable() else null
func _build_fishing_context(
@@ -1018,68 +1487,350 @@ func _build_fishing_context(
) -> FishingContextType:
var context := FishingContextType.new()
context.location_tags = region.location_tags.duplicate()
+ context.water_type = region.water_type
context.active_event_tags = context_event_tags.duplicate()
- context.active_bait_tags = context_bait_tags.duplicate()
- context.is_night = context_is_night
- context.is_raining = context_is_raining
+ context.active_bait_tags = _get_active_bait_tags()
+ if _world_time != null:
+ context.is_night = _world_time.is_night_period()
+ context.is_day_night_transition = _world_time.is_transition()
+ if _world_weather != null:
+ context.is_raining = _world_weather.is_raining()
+ context.is_foggy = _world_weather.is_foggy()
return context
-func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3:
- if from.is_equal_approx(to):
- return from
-
- var segment_length: float = from.distance_to(to)
- var sample_spacing: float = maxf(fishable_query_radius, 0.05)
- var sample_count: int = maxi(1, ceili(segment_length / sample_spacing))
- var last_valid_fraction: float = 0.0
-
- for sample_index: int in range(1, sample_count + 1):
- var sample_fraction: float = float(sample_index) / float(sample_count)
- var sample_position: Vector3 = from.lerp(to, sample_fraction)
- if is_target_fishable(sample_position):
- last_valid_fraction = sample_fraction
- continue
-
- var invalid_fraction: float = sample_fraction
- for _iteration: int in range(10):
- var midpoint: float = (
- last_valid_fraction + invalid_fraction
- ) * 0.5
- if is_target_fishable(from.lerp(to, midpoint)):
- last_valid_fraction = midpoint
- else:
- invalid_fraction = midpoint
- return from.lerp(to, last_valid_fraction)
-
- return to
+func build_network_context(
+ region: FishableWaterRegionType,
+) -> FishingContextType:
+ return _build_fishing_context(region)
-func _resolve_preview_surface_position(target: Vector3) -> Vector3:
- var ray_start := Vector3(
- target.x,
- target.y + preview_ray_start_height,
- target.z
+func _get_active_bait_tags() -> Array[StringName]:
+ if _local_player == null or _item_catalog == null:
+ return []
+ var bait_id: StringName = _local_player.active_bait_id
+ if bait_id.is_empty() or _local_bag == null or not _local_bag.owns_item(bait_id):
+ return []
+ var item: ItemDataType = _item_catalog.get_item_by_id(bait_id)
+ return item.bait_tags.duplicate() if item != null and item.is_bait() else []
+
+
+func _active_lure_has_effect(effect_id: StringName) -> bool:
+ if (
+ _local_player == null
+ or _item_catalog == null
+ or _local_bag == null
+ or _local_player.active_lure_id.is_empty()
+ or not _local_bag.owns_item(_local_player.active_lure_id)
+ ):
+ return false
+ var lure: ItemDataType = _item_catalog.get_item_by_id(
+ _local_player.active_lure_id
)
- var ray_end := Vector3(
- target.x,
- ray_start.y - preview_ray_length,
- target.z
- )
- var query := PhysicsRayQueryParameters3D.create(
- ray_start,
- ray_end,
- preview_surface_mask
- )
- query.collide_with_areas = true
- query.collide_with_bodies = true
- var result: Dictionary = get_world_3d().direct_space_state.intersect_ray(query)
- if result.is_empty():
- return target
+ return lure != null and lure.is_lure() and effect_id in lure.lure_effects
- var hit_position: Vector3 = result["position"]
- return Vector3(
- target.x,
- hit_position.y + preview_marker_vertical_offset,
- target.z
+
+func _build_network_evidence() -> Dictionary:
+ var rarity_multipliers: Array[float] = []
+ for rarity: int in range(FishDataType.Rarity.size()):
+ rarity_multipliers.append(1.0)
+ var discovered_ids: Array[String] = []
+ if _local_collection_log != null:
+ for fish_id: StringName in _local_collection_log.get_discovered_ids():
+ discovered_ids.append(str(fish_id))
+ var item: ItemDataType = _get_active_item()
+ return {
+ "bait_id": str(_active_player.active_bait_id),
+ "lure_id": str(_active_player.active_lure_id),
+ "rod_id": str(item.item_id) if item != null else "",
+ "reel_speed": _active_player.reel_speed * (
+ _fishing_upgrades.get_reel_speed_multiplier()
+ if _fishing_upgrades != null else 1.0
+ ),
+ "barrier_damage": (
+ _fishing_upgrades.get_barrier_damage()
+ if _fishing_upgrades != null else _active_player.click_power
+ ),
+ "bite_multiplier": 1.0,
+ "rarity_multipliers": rarity_multipliers,
+ "discovered_fish_ids": discovered_ids,
+ "capacity_available": not _is_cooler_full(),
+ }
+
+
+func _on_network_item_use_finished(
+ accepted: bool,
+ message: String,
+) -> void:
+ if (
+ not accepted
+ and message == PlayerItemEffectsType.FISH_FINDER_DEAD_MESSAGE
+ ):
+ status_changed.emit("")
+ local_speech_requested.emit(message)
+ return
+ status_changed.emit(message)
+ if accepted:
+ refresh_active_item_status()
+
+
+func _on_network_cast_accepted(
+ _attempt_id: String,
+ target: Vector3,
+) -> void:
+ if state != FishingState.CASTING:
+ # A cast response can arrive after water recovery has already cancelled
+ # its local presentation. Do not leave that late authoritative attempt
+ # alive with its movement lock still applied.
+ if _network_fishing != null and _network_fishing.has_local_attempt():
+ _network_fishing.cancel_local_attempt("")
+ return
+ _cast_target = target
+ _bobber_water_position = target
+ state = FishingState.WAITING_FOR_BITE
+ if _active_player != null:
+ _active_player.set_fishing_visual(true)
+ _network_primary_input_held = false
+ _network_input_resend_elapsed = 0.0
+ _presentation.show_withdrawal_position(_bobber_water_position)
+ _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
+ status_changed.emit("")
+
+
+func _on_network_cast_rejected(message: String) -> void:
+ if state not in [FishingState.CASTING, FishingState.AIMING_CAST]:
+ return
+ var visible_message: String = message
+ if message.strip_edges().to_lower() in [
+ "cannot fish here.",
+ "cannot fish here",
+ "can't fish here.",
+ "can't fish here",
+ "can't fish there.",
+ "can't fish there",
+ ]:
+ visible_message = ""
+ _cleanup_attempt(visible_message, &"invalid")
+
+
+func _on_network_bite_pending(_attempt_id: String) -> void:
+ if state != FishingState.WAITING_FOR_BITE:
+ return
+ _stop_reeling_audio()
+ _withdrawal_input_held = false
+ _network_primary_input_held = false
+ _network_input_resend_elapsed = 0.0
+ _set_bite_confirmation_pending(true)
+ status_changed.emit("")
+ _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
+ _presentation.show_bite()
+
+
+func _on_network_bite_started(_attempt_id: String) -> void:
+ if state != FishingState.WAITING_FOR_BITE:
+ return
+ _stop_reeling_audio()
+ _set_bite_confirmation_pending(false)
+ state = FishingState.FIGHTING
+ if _active_player != null:
+ _active_player.set_fighting_visual(true)
+ _withdrawal_input_held = false
+ _network_primary_input_held = Input.is_action_pressed("fish_primary")
+ _network_input_resend_elapsed = 0.0
+ _network_auto_click_accumulator = 0.0
+ _network_active_barrier_index = -1
+ _start_fight_audio()
+ bite_activated.emit()
+ status_changed.emit("fish on!")
+ _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
+ _presentation.begin_fight()
+ _presentation.show_bite()
+ _network_fishing.submit_local_input(
+ _network_primary_input_held,
+ false
+ )
+
+
+func _resend_network_input_state(delta: float) -> void:
+ if _network_fishing == null or not _network_fishing.has_local_attempt():
+ return
+ _network_input_resend_elapsed += delta
+ if _network_input_resend_elapsed < NETWORK_INPUT_RESEND_INTERVAL_SECONDS:
+ return
+ _network_input_resend_elapsed = fmod(
+ _network_input_resend_elapsed,
+ NETWORK_INPUT_RESEND_INTERVAL_SECONDS,
+ )
+ _network_fishing.submit_local_input(
+ false if _bite_confirmation_pending else _network_primary_input_held,
+ _bite_confirmation_pending and _bite_confirmation_requested,
+ )
+
+
+func _on_network_fishing_snapshot(snapshot: Dictionary) -> void:
+ if state not in [
+ FishingState.WAITING_FOR_BITE,
+ FishingState.FIGHTING,
+ ]:
+ return
+ var positions := PackedFloat32Array(snapshot.get("barrier_positions", []))
+ var health := PackedInt32Array(snapshot.get("barrier_health", []))
+ var maximum_health := PackedInt32Array(
+ snapshot.get("barrier_max_health", [])
+ )
+ var progress: float = float(snapshot.get("progress", 0.0))
+ var chase_progress: float = float(snapshot.get("chase_progress", 0.0))
+ if state == FishingState.FIGHTING:
+ _network_active_barrier_index = int(
+ snapshot.get("active_barrier_index", -1)
+ )
+ catch_display_changed.emit(
+ progress,
+ chase_progress,
+ positions,
+ health,
+ maximum_health,
+ int(snapshot.get("active_barrier_index", -1)),
+ bool(snapshot.get("visible", false))
+ )
+ var bobber_data: Array = snapshot.get("bobber_position", [])
+ if bobber_data.size() == 3:
+ _bobber_water_position = Vector3(
+ float(bobber_data[0]),
+ float(bobber_data[1]),
+ float(bobber_data[2])
+ )
+ if state == FishingState.WAITING_FOR_BITE:
+ _presentation.show_withdrawal_position(_bobber_water_position)
+
+
+func _update_network_auto_click(delta: float) -> void:
+ if (
+ not _catch_controller.auto_click_enabled
+ or not Input.is_action_pressed("fish_primary")
+ or _network_active_barrier_index < 0
+ ):
+ _network_auto_click_accumulator = 0.0
+ return
+ _network_auto_click_accumulator += delta
+ var interval: float = maxf(
+ _catch_controller.auto_click_interval,
+ 0.05
+ )
+ while _network_auto_click_accumulator >= interval:
+ _network_auto_click_accumulator -= interval
+ _network_fishing.submit_local_input(true, true)
+
+
+func _on_network_catch_received(fish_catch: FishCatchType) -> void:
+ if state != FishingState.FIGHTING or fish_catch == null:
+ return
+ _stop_fight_audio()
+ _pending_catch = fish_catch
+ if _active_player != null:
+ _active_player.set_fighting_visual(false)
+ _active_player.set_fishing_visual(false)
+ state = FishingState.SHOWING_CATCH
+ _showcase_ready = false
+ _showcase_outcome_completed = false
+ _put_away_press_armed = false
+ catch_display_changed.emit(
+ 0.0, 0.0, PackedFloat32Array(), PackedInt32Array(),
+ PackedInt32Array(), -1, false
+ )
+ _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
+ if _active_player != null:
+ _active_player.set_retract_visual()
+ _presentation.play_outcome(&"catch")
+
+
+func _on_network_attempt_ended(
+ outcome: StringName,
+ message: String,
+) -> void:
+ if state not in [
+ FishingState.CASTING,
+ FishingState.WAITING_FOR_BITE,
+ FishingState.FIGHTING,
+ ]:
+ return
+ var visual_outcome: StringName = &"cancel"
+ if outcome == &"escape":
+ visual_outcome = &"escape"
+ elif outcome == &"withdrawal":
+ visual_outcome = &"withdrawal"
+ _cleanup_attempt(message, visual_outcome)
+
+
+func _on_network_session_state_changed(state_value: NetworkSession.State) -> void:
+ if state_value in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _stop_fight_audio()
+ _stop_reeling_audio()
+
+
+func _start_fight_audio() -> void:
+ if _fight_audio == null or _fight_audio.stream == null:
+ return
+ if not _fight_audio.playing:
+ _fight_audio.play()
+
+
+func _play_bobber_audio() -> void:
+ if _bobber_audio == null or _bobber_audio.stream == null:
+ return
+ _bobber_audio.play()
+
+
+func _stop_fight_audio() -> void:
+ if _fight_audio != null and _fight_audio.playing:
+ _fight_audio.stop()
+
+
+func _configure_audio_loop(stream: AudioStreamWAV) -> void:
+ stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
+ if stream.loop_end <= stream.loop_begin:
+ stream.loop_begin = 0
+ stream.loop_end = maxi(
+ 1,
+ roundi(stream.get_length() * float(stream.mix_rate)),
+ )
+
+
+func _start_reeling_audio() -> void:
+ if _reeling_audio == null or _reeling_audio.stream == null:
+ return
+ if not _reeling_audio.playing:
+ _reeling_audio.play()
+
+
+func _stop_reeling_audio() -> void:
+ if _reeling_audio != null and _reeling_audio.playing:
+ _reeling_audio.stop()
+
+
+func resolve_safe_withdrawal_surface(
+ from: Vector3,
+ to: Vector3,
+ toward_player: Vector3 = Vector3.ZERO,
+) -> FishingSurfaceSampleType:
+ var direction_to_player: Vector3 = toward_player
+ if direction_to_player.is_zero_approx():
+ direction_to_player = to - from
+ var reference_y: float = (
+ _active_player.global_position.y
+ if _active_player != null
+ else maxf(from.y, to.y)
+ )
+ return _surface_resolver.resolve_withdrawal_surface(
+ get_world_3d().direct_space_state,
+ from,
+ to,
+ direction_to_player,
+ reference_y,
+ withdrawal_surface_clearance,
)
diff --git a/fishing/fishing_spot.tscn b/fishing/fishing_spot.tscn
index d9ba7ad..ef6d0bf 100644
--- a/fishing/fishing_spot.tscn
+++ b/fishing/fishing_spot.tscn
@@ -1,6 +1,9 @@
-[gd_scene load_steps=11 format=3]
+[gd_scene load_steps=15 format=3]
[ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"]
+[ext_resource type="AudioStream" path="res://audio/sfx/fishing/fighting.wav" id="2_fighting_audio"]
+[ext_resource type="AudioStream" path="res://audio/sfx/fishing/reeling.wav" id="2_reeling_audio"]
+[ext_resource type="AudioStream" path="res://audio/sfx/fishing/bobber.wav" id="2_bobber_audio"]
[ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"]
[ext_resource type="Script" path="res://fishing/catch_controller.gd" id="4_catch"]
@@ -14,6 +17,7 @@ roughness = 0.45
[sub_resource type="StandardMaterial3D" id="LineMaterial"]
shading_mode = 0
+cull_mode = 2
albedo_color = Color(0.9, 0.9, 0.82, 1)
[sub_resource type="CylinderMesh" id="CastTargetMesh"]
@@ -36,11 +40,29 @@ emission = Color(1, 0.01, 0.08, 1)
emission_energy_multiplier = 2.2
[sub_resource type="BoxMesh" id="InvalidCrossMesh"]
-size = Vector3(0.72, 0.06, 0.12)
+size = Vector3(0.62, 0.04, 0.09)
[node name="FishingSpot" type="Node3D"]
script = ExtResource("1_spot")
+[node name="BobberAudio" type="AudioStreamPlayer" parent="."]
+unique_name_in_owner = true
+stream = ExtResource("2_bobber_audio")
+volume_db = -14.0
+bus = &"SFX"
+
+[node name="FightAudio" type="AudioStreamPlayer" parent="."]
+unique_name_in_owner = true
+stream = ExtResource("2_fighting_audio")
+volume_db = -10.0
+bus = &"SFX"
+
+[node name="ReelingAudio" type="AudioStreamPlayer" parent="."]
+unique_name_in_owner = true
+stream = ExtResource("2_reeling_audio")
+volume_db = -10.0
+bus = &"SFX"
+
[node name="CatchController" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("4_catch")
@@ -48,24 +70,24 @@ script = ExtResource("4_catch")
[node name="FishingPresentation" type="Node3D" parent="."]
unique_name_in_owner = true
script = ExtResource("3_presentation")
-valid_target_material = SubResource("CastTargetMaterial")
-invalid_target_material = SubResource("InvalidTargetMaterial")
-[node name="CastTargetMarker" type="MeshInstance3D" parent="FishingPresentation"]
+[node name="CastTargetMarker" type="Node3D" parent="FishingPresentation"]
+unique_name_in_owner = true
+
+[node name="ValidTargetMarker" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"]
unique_name_in_owner = true
mesh = SubResource("CastTargetMesh")
material_override = SubResource("CastTargetMaterial")
-[node name="InvalidCrossA" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"]
+[node name="InvalidTargetMarker" type="Node3D" parent="FishingPresentation/CastTargetMarker"]
unique_name_in_owner = true
-position = Vector3(0, 0.075, 0)
+
+[node name="StrokeA" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker/InvalidTargetMarker"]
rotation = Vector3(0, 0.785398, 0)
mesh = SubResource("InvalidCrossMesh")
material_override = SubResource("InvalidTargetMaterial")
-[node name="InvalidCrossB" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"]
-unique_name_in_owner = true
-position = Vector3(0, 0.075, 0)
+[node name="StrokeB" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker/InvalidTargetMarker"]
rotation = Vector3(0, -0.785398, 0)
mesh = SubResource("InvalidCrossMesh")
material_override = SubResource("InvalidTargetMaterial")
diff --git a/fishing/fishing_surface_resolver.gd b/fishing/fishing_surface_resolver.gd
new file mode 100644
index 0000000..821d8cc
--- /dev/null
+++ b/fishing/fishing_surface_resolver.gd
@@ -0,0 +1,271 @@
+class_name FishingSurfaceResolver
+extends RefCounted
+
+const FishableWaterRegionType = preload(
+ "res://world/fishable_water_region.gd"
+)
+const FishingSurfaceSampleType = preload(
+ "res://fishing/fishing_surface_sample.gd"
+)
+
+var fishable_surface_mask: int = 4
+var solid_surface_mask: int = 1
+var query_radius: float = 0.08
+var ray_start_height: float = 100.0
+var ray_length: float = 240.0
+var water_occlusion_tolerance: float = 0.04
+var arc_sample_spacing: float = 0.3
+
+var _water_query_shape: CylinderShape3D = CylinderShape3D.new()
+
+
+func resolve_surface(
+ space_state: PhysicsDirectSpaceState3D,
+ query_position: Vector3,
+ reference_y: float,
+ maximum_solid_rise: float = INF,
+) -> FishingSurfaceSampleType:
+ var sample := FishingSurfaceSampleType.new()
+ var ray_top: float = maxf(query_position.y, reference_y) + ray_start_height
+ var ray_bottom: float = ray_top - ray_length
+ var water_region: FishableWaterRegionType = _find_highest_water_region(
+ space_state,
+ query_position,
+ ray_top,
+ ray_bottom,
+ )
+ var solid_ray_top: float = minf(
+ ray_top,
+ reference_y + maximum_solid_rise,
+ )
+ var solid_hit: Dictionary = {}
+ if solid_ray_top > ray_bottom:
+ solid_hit = _find_top_solid_surface(
+ space_state,
+ query_position,
+ solid_ray_top,
+ ray_bottom,
+ )
+
+ if water_region != null:
+ var water_height: float = water_region.get_surface_height()
+ var solid_is_above_water: bool = false
+ if not solid_hit.is_empty():
+ var solid_position: Vector3 = solid_hit["position"]
+ solid_is_above_water = (
+ solid_position.y >= water_height - water_occlusion_tolerance
+ )
+ if not solid_is_above_water:
+ sample.has_surface = true
+ sample.position = Vector3(
+ query_position.x,
+ water_height,
+ query_position.z,
+ )
+ sample.normal = Vector3.UP
+ sample.water_region = water_region
+ sample.is_water_surface = true
+ return sample
+
+ if not solid_hit.is_empty():
+ sample.has_surface = true
+ sample.position = solid_hit["position"]
+ var hit_normal: Vector3 = solid_hit.get("normal", Vector3.UP)
+ sample.normal = (
+ hit_normal.normalized()
+ if not hit_normal.is_zero_approx()
+ else Vector3.UP
+ )
+
+ return sample
+
+
+func find_first_cast_collision(
+ space_state: PhysicsDirectSpaceState3D,
+ origin: Vector3,
+ target: Vector3,
+ arc_height: float,
+) -> Dictionary:
+ if origin.is_equal_approx(target):
+ return {}
+ var segment_count: int = maxi(
+ 12,
+ ceili(origin.distance_to(target) / maxf(arc_sample_spacing, 0.05)),
+ )
+ var previous_position: Vector3 = origin
+ for segment_index: int in range(1, segment_count + 1):
+ var progress: float = float(segment_index) / float(segment_count)
+ var next_position: Vector3 = origin.lerp(target, progress)
+ next_position.y += sin(progress * PI) * arc_height
+ var hit: Dictionary = _intersect_solid_segment(
+ space_state,
+ previous_position,
+ next_position,
+ )
+ if not hit.is_empty():
+ return hit
+ previous_position = next_position
+ return {}
+
+
+func resolve_cast_arc(
+ space_state: PhysicsDirectSpaceState3D,
+ origin: Vector3,
+ target: Vector3,
+ preferred_arc_height: float,
+) -> Dictionary:
+ var raised_arc_height: float = maxf(preferred_arc_height, 0.0)
+ var raised_collision := find_first_cast_collision(
+ space_state,
+ origin,
+ target,
+ raised_arc_height,
+ )
+ if raised_collision.is_empty() or is_zero_approx(raised_arc_height):
+ return {
+ "arc_height": raised_arc_height,
+ "collision": raised_collision,
+ }
+
+ # A canopy or pier roof can intersect the decorative raised arc even when
+ # the direct path out toward the water is unobstructed. Prefer that low cast
+ # instead of visibly sending the bobber and line upward into the ceiling.
+ var direct_collision := find_first_cast_collision(
+ space_state,
+ origin,
+ target,
+ 0.0,
+ )
+ return {
+ "arc_height": 0.0,
+ "collision": direct_collision,
+ }
+
+
+func resolve_withdrawal_surface(
+ space_state: PhysicsDirectSpaceState3D,
+ current_position: Vector3,
+ desired_position: Vector3,
+ toward_player: Vector3,
+ reference_y: float,
+ shore_clearance: float,
+) -> FishingSurfaceSampleType:
+ var desired_sample: FishingSurfaceSampleType = resolve_surface(
+ space_state,
+ desired_position,
+ reference_y,
+ )
+ if not desired_sample.is_fishable():
+ return _blocked_withdrawal_sample(current_position)
+
+ var horizontal_direction: Vector3 = toward_player
+ horizontal_direction.y = 0.0
+ if not horizontal_direction.is_zero_approx() and shore_clearance > 0.0:
+ horizontal_direction = horizontal_direction.normalized()
+ var lookahead_position: Vector3 = (
+ desired_sample.position + horizontal_direction * shore_clearance
+ )
+ var lookahead_sample: FishingSurfaceSampleType = resolve_surface(
+ space_state,
+ lookahead_position,
+ reference_y,
+ )
+ if not lookahead_sample.is_fishable():
+ return _blocked_withdrawal_sample(current_position)
+
+ return desired_sample
+
+
+func _blocked_withdrawal_sample(
+ current_position: Vector3,
+) -> FishingSurfaceSampleType:
+ var sample := FishingSurfaceSampleType.new()
+ sample.position = current_position
+ return sample
+
+
+func _find_highest_water_region(
+ space_state: PhysicsDirectSpaceState3D,
+ query_position: Vector3,
+ ray_top: float,
+ ray_bottom: float,
+) -> FishableWaterRegionType:
+ _water_query_shape.radius = maxf(query_radius, 0.01)
+ _water_query_shape.height = maxf(ray_top - ray_bottom, 0.1)
+ var query := PhysicsShapeQueryParameters3D.new()
+ query.shape = _water_query_shape
+ query.transform = Transform3D(
+ Basis.IDENTITY,
+ Vector3(
+ query_position.x,
+ (ray_top + ray_bottom) * 0.5,
+ query_position.z,
+ ),
+ )
+ query.collision_mask = fishable_surface_mask
+ query.collide_with_areas = true
+ query.collide_with_bodies = false
+ var results: Array[Dictionary] = space_state.intersect_shape(query, 64)
+ var selected_region: FishableWaterRegionType = null
+ for result: Dictionary in results:
+ var region: FishableWaterRegionType = (
+ result.get("collider") as FishableWaterRegionType
+ )
+ # A water surface remains the visible top surface even when it has no
+ # fish pool. Fishability is a separate policy checked by the sample.
+ if region == null:
+ continue
+ var surface_height: float = region.get_surface_height()
+ if surface_height < ray_bottom or surface_height > ray_top:
+ continue
+ if selected_region == null:
+ selected_region = region
+ continue
+ var selected_height: float = selected_region.get_surface_height()
+ if (
+ surface_height > selected_height + 0.001
+ or (
+ is_equal_approx(surface_height, selected_height)
+ and (
+ region.selection_priority > selected_region.selection_priority
+ or (
+ region.selection_priority
+ == selected_region.selection_priority
+ and region.get_instance_id()
+ < selected_region.get_instance_id()
+ )
+ )
+ )
+ ):
+ selected_region = region
+ return selected_region
+
+
+func _find_top_solid_surface(
+ space_state: PhysicsDirectSpaceState3D,
+ query_position: Vector3,
+ ray_top: float,
+ ray_bottom: float,
+) -> Dictionary:
+ return _intersect_solid_segment(
+ space_state,
+ Vector3(query_position.x, ray_top, query_position.z),
+ Vector3(query_position.x, ray_bottom, query_position.z),
+ )
+
+
+func _intersect_solid_segment(
+ space_state: PhysicsDirectSpaceState3D,
+ from: Vector3,
+ to: Vector3,
+) -> Dictionary:
+ if from.is_equal_approx(to) or solid_surface_mask == 0:
+ return {}
+ var query := PhysicsRayQueryParameters3D.create(
+ from,
+ to,
+ solid_surface_mask,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ return space_state.intersect_ray(query)
diff --git a/fishing/fishing_surface_resolver.gd.uid b/fishing/fishing_surface_resolver.gd.uid
new file mode 100644
index 0000000..606a2fa
--- /dev/null
+++ b/fishing/fishing_surface_resolver.gd.uid
@@ -0,0 +1 @@
+uid://dcyvrak5pvlc
diff --git a/fishing/fishing_surface_sample.gd b/fishing/fishing_surface_sample.gd
new file mode 100644
index 0000000..121658b
--- /dev/null
+++ b/fishing/fishing_surface_sample.gd
@@ -0,0 +1,36 @@
+class_name FishingSurfaceSample
+extends RefCounted
+
+const FishableWaterRegionType = preload(
+ "res://world/fishable_water_region.gd"
+)
+const WaterSurfaceMotionType = preload(
+ "res://world/water_surface_motion.gd"
+)
+
+var has_surface: bool = false
+var position: Vector3 = Vector3.ZERO
+var normal: Vector3 = Vector3.UP
+var water_region: FishableWaterRegionType
+var is_water_surface: bool = false
+
+
+func is_fishable() -> bool:
+ return is_water_surface and water_region != null and water_region.fish_pool != null
+
+
+func get_marker_position(vertical_offset: float) -> Vector3:
+ if not has_surface:
+ return position
+ var presentation_offset := vertical_offset
+ if is_water_surface:
+ presentation_offset += (
+ WaterSurfaceMotionType.get_default_height_offset()
+ )
+ return position + normal.normalized() * presentation_offset
+
+
+func get_bobber_position(solid_clearance: float) -> Vector3:
+ if not has_surface or is_water_surface:
+ return position
+ return position + normal.normalized() * solid_clearance
diff --git a/fishing/fishing_surface_sample.gd.uid b/fishing/fishing_surface_sample.gd.uid
new file mode 100644
index 0000000..8c00c2b
--- /dev/null
+++ b/fishing/fishing_surface_sample.gd.uid
@@ -0,0 +1 @@
+uid://5n0ku0fxh7xt
diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd
new file mode 100644
index 0000000..5a5b05b
--- /dev/null
+++ b/fishing/remote_fishing_presentation.gd
@@ -0,0 +1,320 @@
+class_name RemoteFishingPresentation
+extends Node3D
+
+const WaterSurfaceMotionType = preload(
+ "res://world/water_surface_motion.gd"
+)
+const LINE_THICKNESS: float = 0.016
+
+signal return_completed
+
+var _owner: Player
+var _bobber: MeshInstance3D
+var _line: MeshInstance3D
+var _line_mesh := ImmediateMesh.new()
+var _target: Vector3
+var _pending_target: Vector3
+var _active: bool = false
+var _cast_tween: Tween
+var _return_tween: Tween
+var _showcase_tween: Tween
+var _return_showcase_catch: FishCatch
+var _bobber_idle_elapsed: float = 0.0
+var _bobber_base_scale: Vector3 = Vector3.ONE
+
+
+func setup(owning_player: Player) -> void:
+ _owner = owning_player
+ _bobber = MeshInstance3D.new()
+ var bobber_mesh := SphereMesh.new()
+ bobber_mesh.radius = 0.12
+ bobber_mesh.height = 0.24
+ _bobber.mesh = bobber_mesh
+ var bobber_material := StandardMaterial3D.new()
+ bobber_material.albedo_color = Color(0.92, 0.12, 0.08, 1.0)
+ bobber_material.roughness = 0.45
+ _bobber.material_override = bobber_material
+ add_child(_bobber)
+ _line = MeshInstance3D.new()
+ _line.mesh = _line_mesh
+ var line_material := StandardMaterial3D.new()
+ line_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ line_material.cull_mode = BaseMaterial3D.CULL_DISABLED
+ line_material.albedo_color = Color(0.9, 0.9, 0.82, 1.0)
+ _line.material_override = line_material
+ add_child(_line)
+ cleanup()
+
+
+func show_cast(origin: Vector3, target: Vector3) -> void:
+ if (
+ _owner == null
+ or not origin.is_finite()
+ or not target.is_finite()
+ ):
+ return
+ _kill_cast_tween()
+ _active = true
+ _bobber_base_scale = Vector3.ONE * _owner.get_character_visual_scale()
+ _target = origin
+ _pending_target = target
+ _bobber_idle_elapsed = 0.0
+ _bobber.global_position = origin
+ _bobber.scale = _bobber_base_scale
+ _bobber.visible = true
+ _line.visible = true
+ _owner.set_active_item_is_rod(true)
+ _owner.set_release_visual()
+ _redraw_line()
+ _cast_tween = create_tween()
+ _cast_tween.set_trans(Tween.TRANS_SINE)
+ _cast_tween.set_ease(Tween.EASE_IN_OUT)
+ _cast_tween.tween_method(
+ _set_cast_sample.bind(origin, target),
+ 0.0,
+ 1.0,
+ 0.65,
+ )
+ _cast_tween.finished.connect(_on_cast_finished)
+
+
+func update_bobber(world_position: Vector3) -> void:
+ if not _active or not world_position.is_finite():
+ return
+ _pending_target = world_position
+ if _cast_tween != null:
+ return
+ _target = world_position
+ _bobber.global_position = world_position
+ _redraw_line()
+
+
+func show_bite() -> void:
+ if not _active:
+ return
+ if _owner != null and is_instance_valid(_owner):
+ _owner.set_fighting_visual(true)
+ var tween: Tween = create_tween()
+ tween.tween_property(_bobber, "scale", _bobber_base_scale * 0.7, 0.08)
+ tween.tween_property(_bobber, "scale", _bobber_base_scale, 0.12)
+
+
+func play_return(showcase_catch: FishCatch = null) -> void:
+ if _owner != null and is_instance_valid(_owner):
+ _owner.set_fighting_visual(false)
+ _owner.set_retract_visual()
+ if not _active or _bobber == null:
+ cleanup()
+ return_completed.emit()
+ return
+ _kill_cast_tween()
+ _kill_return_tween()
+ _return_showcase_catch = showcase_catch
+ var start: Vector3 = _bobber.global_position
+ var target: Vector3 = start
+ if _owner != null and is_instance_valid(_owner):
+ var tip: Marker3D = _owner.get_fishing_rod_tip()
+ if tip != null:
+ target = tip.global_position
+ _return_tween = create_tween()
+ _return_tween.set_trans(Tween.TRANS_QUAD)
+ _return_tween.set_ease(Tween.EASE_IN)
+ _return_tween.tween_method(
+ _set_return_sample.bind(start, target),
+ 0.0,
+ 1.0,
+ 0.42,
+ )
+ _return_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.1)
+ _return_tween.finished.connect(_on_return_finished)
+
+
+func cleanup() -> void:
+ _kill_cast_tween()
+ _kill_return_tween()
+ _kill_showcase_tween()
+ _active = false
+ _pending_target = Vector3.ZERO
+ _return_showcase_catch = null
+ _bobber_idle_elapsed = 0.0
+ if _owner != null and is_instance_valid(_owner):
+ _owner.set_fighting_visual(false)
+ _owner.set_fishing_visual(false)
+ _owner.end_catch_showcase(Callable(), true)
+ if _bobber != null:
+ _bobber.visible = false
+ _bobber.scale = Vector3.ONE
+ _bobber_base_scale = Vector3.ONE
+ if _line != null:
+ _line.visible = false
+ _line_mesh.clear_surfaces()
+
+
+func _set_cast_sample(
+ progress: float,
+ start: Vector3,
+ target: Vector3,
+) -> void:
+ _target = start.lerp(target, progress)
+ _target.y += sin(progress * PI) * 2.0
+ _target.y += (
+ WaterSurfaceMotionType.get_default_height_offset()
+ * smoothstep(0.72, 1.0, progress)
+ )
+ _bobber.global_position = _target
+
+
+func _on_cast_finished() -> void:
+ _cast_tween = null
+ _bobber_idle_elapsed = 0.0
+ if _owner != null and is_instance_valid(_owner):
+ _owner.set_fishing_visual(true)
+ _apply_bobber_idle_motion()
+ _redraw_line()
+
+
+func _apply_bobber_idle_motion() -> void:
+ var phase: float = _bobber_idle_elapsed * 0.7 * TAU
+ _target = _pending_target + Vector3.UP * (
+ WaterSurfaceMotionType.get_default_height_offset()
+ + sin(phase) * 0.035
+ )
+ _bobber.global_position = _target
+ _bobber.rotation.z = deg_to_rad(4.0) * sin(phase * 0.73)
+
+
+func _set_return_sample(
+ progress: float,
+ start: Vector3,
+ target: Vector3,
+) -> void:
+ var eased_progress: float = 1.0 - pow(1.0 - progress, 3.0)
+ _target = start.lerp(target, eased_progress)
+ _target.y += sin(eased_progress * PI) * 1.3
+ _bobber.global_position = _target
+ _bobber.rotation = Vector3.ZERO
+
+
+func _on_return_finished() -> void:
+ _return_tween = null
+ _active = false
+ _bobber.visible = false
+ _line.visible = false
+ _line_mesh.clear_surfaces()
+ if (
+ _owner != null
+ and is_instance_valid(_owner)
+ and not _owner.is_retract_visual_complete()
+ ):
+ await _owner.retract_visual_finished
+ if (
+ _return_showcase_catch != null
+ and _owner != null
+ and is_instance_valid(_owner)
+ ):
+ _owner.begin_remote_catch_showcase(_return_showcase_catch)
+ _return_showcase_catch = null
+ _showcase_tween = create_tween()
+ _showcase_tween.tween_interval(2.5)
+ _showcase_tween.finished.connect(_on_showcase_finished)
+ return
+ return_completed.emit()
+
+
+func _on_showcase_finished() -> void:
+ _showcase_tween = null
+ if _owner != null and is_instance_valid(_owner):
+ _owner.end_catch_showcase(_on_showcase_put_away_finished)
+ return
+ _on_showcase_put_away_finished()
+
+
+func _on_showcase_put_away_finished() -> void:
+ return_completed.emit()
+
+
+func _kill_cast_tween() -> void:
+ if _cast_tween != null and _cast_tween.is_valid():
+ _cast_tween.kill()
+ _cast_tween = null
+
+
+func _kill_return_tween() -> void:
+ if _return_tween != null and _return_tween.is_valid():
+ _return_tween.kill()
+ _return_tween = null
+
+
+func _kill_showcase_tween() -> void:
+ if _showcase_tween != null and _showcase_tween.is_valid():
+ _showcase_tween.kill()
+ _showcase_tween = null
+
+
+func _process(delta: float) -> void:
+ if _owner != null and not _owner.is_remote_presentation_visible():
+ _bobber.visible = false
+ _line.visible = false
+ return
+ if _active:
+ if _cast_tween == null and _return_tween == null:
+ _bobber_idle_elapsed += delta
+ _apply_bobber_idle_motion()
+ _redraw_line()
+
+
+func _redraw_line() -> void:
+ if _owner == null or not is_instance_valid(_owner):
+ cleanup()
+ return
+ var tip: Marker3D = _owner.get_fishing_rod_tip()
+ if tip == null:
+ return
+ _line_mesh.clear_surfaces()
+ var midpoint: Vector3 = tip.global_position.lerp(_target, 0.5)
+ midpoint.y -= 0.12
+ _draw_line_ribbon(PackedVector3Array([
+ tip.global_position,
+ midpoint,
+ _target,
+ ]))
+
+
+func _draw_line_ribbon(points: PackedVector3Array) -> void:
+ var camera: Camera3D = get_viewport().get_camera_3d()
+ if camera == null:
+ _line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
+ for point: Vector3 in points:
+ _line_mesh.surface_add_vertex(to_local(point))
+ _line_mesh.surface_end()
+ return
+
+ var half_thickness: float = LINE_THICKNESS * 0.5
+ _line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
+ for point_index: int in range(1, points.size()):
+ var start: Vector3 = points[point_index - 1]
+ var end: Vector3 = points[point_index]
+ var segment: Vector3 = end - start
+ if segment.length_squared() <= 0.000001:
+ continue
+ var to_camera: Vector3 = (
+ camera.global_position - start.lerp(end, 0.5)
+ )
+ var side: Vector3 = segment.normalized().cross(to_camera.normalized())
+ if side.length_squared() <= 0.000001:
+ side = segment.normalized().cross(Vector3.UP)
+ if side.length_squared() <= 0.000001:
+ side = segment.normalized().cross(Vector3.RIGHT)
+ side = side.normalized() * half_thickness
+
+ var start_left: Vector3 = to_local(start - side)
+ var start_right: Vector3 = to_local(start + side)
+ var end_right: Vector3 = to_local(end + side)
+ var end_left: Vector3 = to_local(end - side)
+ _line_mesh.surface_add_vertex(start_left)
+ _line_mesh.surface_add_vertex(start_right)
+ _line_mesh.surface_add_vertex(end_right)
+ _line_mesh.surface_add_vertex(start_left)
+ _line_mesh.surface_add_vertex(end_right)
+ _line_mesh.surface_add_vertex(end_left)
+ _line_mesh.surface_end()
diff --git a/fishing/remote_fishing_presentation.gd.uid b/fishing/remote_fishing_presentation.gd.uid
new file mode 100644
index 0000000..62f590d
--- /dev/null
+++ b/fishing/remote_fishing_presentation.gd.uid
@@ -0,0 +1 @@
+uid://db3t8mrcaw062
diff --git a/icon.svg b/icon.svg
deleted file mode 100644
index c6bbb7d..0000000
--- a/icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd
index 89e192d..a4052f5 100644
--- a/inventory/fish_inventory.gd
+++ b/inventory/fish_inventory.gd
@@ -8,6 +8,13 @@ signal catches_changed
var _catches: Array[FishCatchType] = []
var _next_catch_sequence: int = 1
+var _reservation_service: PlayerAssetReservationService
+
+
+func set_reservation_service(
+ reservation_service: PlayerAssetReservationService,
+) -> void:
+ _reservation_service = reservation_service
func add_catch(fish_catch: FishCatchType) -> void:
@@ -101,6 +108,25 @@ func remove_catch_by_id(catch_id: StringName) -> FishCatchType:
func remove_catches_by_ids(
catch_ids: Array[StringName],
+) -> Array[FishCatchType]:
+ return _remove_catches_by_ids(catch_ids, "", "")
+
+
+func remove_reserved_catch_for_mail_transfer(
+ catch_id: StringName,
+ reservation_id: String,
+ transfer_id: String,
+) -> FishCatchType:
+ var removed := _remove_catches_by_ids(
+ [catch_id], reservation_id, transfer_id
+ )
+ return removed.front() if removed.size() == 1 else null
+
+
+func _remove_catches_by_ids(
+ catch_ids: Array[StringName],
+ mail_reservation_id: String,
+ mail_transfer_id: String,
) -> Array[FishCatchType]:
var removed: Array[FishCatchType] = []
if catch_ids.is_empty():
@@ -115,6 +141,17 @@ func remove_catches_by_ids(
if fish_catch == null:
removed.clear()
return removed
+ if (
+ _reservation_service != null
+ and _reservation_service.is_fish_reserved(catch_id)
+ and not _reservation_service.authorize_mail_fish_removal(
+ mail_reservation_id,
+ catch_id,
+ mail_transfer_id,
+ )
+ ):
+ removed.clear()
+ return removed
removed.append(fish_catch)
var remaining: Array[FishCatchType] = []
diff --git a/inventory/player_bag.gd b/inventory/player_bag.gd
index 1e885ee..4fc12b6 100644
--- a/inventory/player_bag.gd
+++ b/inventory/player_bag.gd
@@ -5,10 +5,15 @@ const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const OwnedItemType = preload("res://items/owned_item.gd")
+const DEFAULT_UNLOCKED_BAIT_IDS: Array[StringName] = [&"worms"]
+
signal contents_changed
var _catalog: ItemCatalogType
var _items: Array[OwnedItemType] = []
+var _unlocked_bait_ids: Array[StringName] = (
+ DEFAULT_UNLOCKED_BAIT_IDS.duplicate()
+)
func setup(catalog: ItemCatalogType) -> void:
@@ -27,6 +32,8 @@ func add_item(item_id: StringName, quantity: int = 1) -> bool:
owned.item_id = item_id
owned.quantity = quantity
_items.append(owned)
+ if item.is_bait() and not _unlocked_bait_ids.has(item_id):
+ _unlocked_bait_ids.append(item_id)
contents_changed.emit()
return true
@@ -85,6 +92,46 @@ func get_all_items() -> Array[OwnedItemType]:
return result
+func get_unlocked_bait_ids() -> Array[StringName]:
+ return _unlocked_bait_ids.duplicate()
+
+
+func is_bait_unlocked(item_id: StringName) -> bool:
+ return _unlocked_bait_ids.has(item_id)
+
+
+func get_unlocked_bait_items() -> Array[OwnedItemType]:
+ var result: Array[OwnedItemType] = []
+ for item_id: StringName in _unlocked_bait_ids:
+ var owned: OwnedItemType = get_owned_item(item_id)
+ if owned != null:
+ result.append(owned.duplicate_record())
+ continue
+ var empty_record := OwnedItemType.new()
+ empty_record.item_id = item_id
+ empty_record.quantity = 0
+ result.append(empty_record)
+ return result
+
+
+func replace_unlocked_bait_ids(item_ids: Array[StringName]) -> bool:
+ var validated: Array[StringName] = DEFAULT_UNLOCKED_BAIT_IDS.duplicate()
+ var seen: Dictionary[StringName, bool] = {}
+ for item_id: StringName in item_ids:
+ if item_id.is_empty() or seen.has(item_id):
+ return false
+ seen[item_id] = true
+ if validated.has(item_id):
+ continue
+ var item: ItemDataType = _resolve_valid_item(item_id)
+ if item == null or not item.is_bait():
+ return false
+ validated.append(item_id)
+ _unlocked_bait_ids = validated
+ contents_changed.emit()
+ return true
+
+
func replace_all_items(items: Array[OwnedItemType]) -> bool:
var validated: Array[OwnedItemType] = []
var seen: Dictionary[StringName, bool] = {}
diff --git a/inventory/player_hotbar.gd b/inventory/player_hotbar.gd
index 94cbb79..e9dbcec 100644
--- a/inventory/player_hotbar.gd
+++ b/inventory/player_hotbar.gd
@@ -5,28 +5,56 @@ const SLOT_COUNT: int = 9
const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+
+enum AssignmentKind {
+ EMPTY,
+ ITEM,
+ FISH,
+}
signal slots_changed
signal selected_slot_changed(slot_index: int, item_id: StringName)
+signal selected_assignment_changed(
+ slot_index: int,
+ kind: AssignmentKind,
+ identity: StringName,
+)
var _bag: PlayerBagType
var _catalog: ItemCatalogType
+var _fish_inventory: FishInventoryType
var _slots: Array[StringName] = []
+var _fish_slots: Array[StringName] = []
var _selected_slot: int = 0
func _init() -> void:
_slots.resize(SLOT_COUNT)
_slots.fill(StringName())
+ _fish_slots.resize(SLOT_COUNT)
+ _fish_slots.fill(StringName())
-func setup(bag: PlayerBagType, catalog: ItemCatalogType) -> void:
+func setup(
+ bag: PlayerBagType,
+ catalog: ItemCatalogType,
+ fish_inventory: FishInventoryType = null,
+) -> void:
_bag = bag
_catalog = catalog
+ _fish_inventory = fish_inventory
if _bag != null and not _bag.contents_changed.is_connected(
_on_bag_contents_changed
):
_bag.contents_changed.connect(_on_bag_contents_changed)
+ if (
+ _fish_inventory != null
+ and not _fish_inventory.catches_changed.is_connected(
+ _on_fish_inventory_changed
+ )
+ ):
+ _fish_inventory.catches_changed.connect(_on_fish_inventory_changed)
_validate_assignments()
@@ -35,23 +63,55 @@ func assign_item(slot_index: int, item_id: StringName) -> bool:
return false
if _slots[slot_index] == item_id:
return true
+ var selected_slot_was_affected: bool = slot_index == _selected_slot
for index: int in range(SLOT_COUNT):
if index != slot_index and _slots[index] == item_id:
_slots[index] = StringName()
+ selected_slot_was_affected = (
+ selected_slot_was_affected or index == _selected_slot
+ )
_slots[slot_index] = item_id
+ _fish_slots[slot_index] = StringName()
slots_changed.emit()
- if slot_index == _selected_slot:
- selected_slot_changed.emit(_selected_slot, item_id)
+ if selected_slot_was_affected:
+ _emit_selected_assignment()
+ return true
+
+
+func assign_fish(slot_index: int, catch_id: StringName) -> bool:
+ if not _is_slot_valid(slot_index) or not _can_assign_fish(catch_id):
+ return false
+ if _fish_slots[slot_index] == catch_id:
+ return true
+ var selected_slot_was_affected: bool = slot_index == _selected_slot
+ for index: int in range(SLOT_COUNT):
+ if index != slot_index and _fish_slots[index] == catch_id:
+ _fish_slots[index] = StringName()
+ selected_slot_was_affected = (
+ selected_slot_was_affected or index == _selected_slot
+ )
+ _slots[slot_index] = StringName()
+ _fish_slots[slot_index] = catch_id
+ slots_changed.emit()
+ if selected_slot_was_affected:
+ _emit_selected_assignment()
return true
func clear_slot(slot_index: int) -> bool:
- if not _is_slot_valid(slot_index) or _slots[slot_index].is_empty():
+ if (
+ not _is_slot_valid(slot_index)
+ or (
+ _slots[slot_index].is_empty()
+ and _fish_slots[slot_index].is_empty()
+ )
+ ):
return false
_slots[slot_index] = StringName()
+ _fish_slots[slot_index] = StringName()
slots_changed.emit()
if slot_index == _selected_slot:
- selected_slot_changed.emit(_selected_slot, StringName())
+ _emit_selected_assignment()
return true
@@ -65,12 +125,12 @@ func swap_slots(from_index: int, to_index: int) -> bool:
var temporary: StringName = _slots[from_index]
_slots[from_index] = _slots[to_index]
_slots[to_index] = temporary
+ temporary = _fish_slots[from_index]
+ _fish_slots[from_index] = _fish_slots[to_index]
+ _fish_slots[to_index] = temporary
slots_changed.emit()
if from_index == _selected_slot or to_index == _selected_slot:
- selected_slot_changed.emit(
- _selected_slot,
- _slots[_selected_slot]
- )
+ _emit_selected_assignment()
return true
@@ -80,7 +140,7 @@ func select_slot(slot_index: int) -> bool:
if _selected_slot == slot_index:
return true
_selected_slot = slot_index
- selected_slot_changed.emit(_selected_slot, _slots[_selected_slot])
+ _emit_selected_assignment()
return true
@@ -100,21 +160,55 @@ func get_selected_item_id() -> StringName:
return _slots[_selected_slot]
+func get_selected_fish_catch_id() -> StringName:
+ return _fish_slots[_selected_slot]
+
+
+func get_selected_assignment_kind() -> AssignmentKind:
+ return get_assignment_kind(_selected_slot)
+
+
func get_item_id(slot_index: int) -> StringName:
return _slots[slot_index] if _is_slot_valid(slot_index) else StringName()
+func get_fish_catch_id(slot_index: int) -> StringName:
+ return (
+ _fish_slots[slot_index]
+ if _is_slot_valid(slot_index)
+ else StringName()
+ )
+
+
+func get_assignment_kind(slot_index: int) -> AssignmentKind:
+ if not _is_slot_valid(slot_index):
+ return AssignmentKind.EMPTY
+ if not _fish_slots[slot_index].is_empty():
+ return AssignmentKind.FISH
+ if not _slots[slot_index].is_empty():
+ return AssignmentKind.ITEM
+ return AssignmentKind.EMPTY
+
+
func get_slots() -> Array[StringName]:
return _slots.duplicate()
+func get_fish_slots() -> Array[StringName]:
+ return _fish_slots.duplicate()
+
+
func replace_state(
slots: Array[StringName],
selected_slot: int,
+ fish_slots: Array[StringName] = [],
) -> bool:
var normalized: Array[StringName] = []
normalized.resize(SLOT_COUNT)
normalized.fill(StringName())
+ var normalized_fish: Array[StringName] = []
+ normalized_fish.resize(SLOT_COUNT)
+ normalized_fish.fill(StringName())
var seen_assignments: Dictionary[StringName, bool] = {}
for index: int in range(mini(slots.size(), SLOT_COUNT)):
var item_id: StringName = slots[index]
@@ -123,19 +217,28 @@ func replace_state(
if _can_assign(item_id) and not seen_assignments.has(item_id):
normalized[index] = item_id
seen_assignments[item_id] = true
+ var seen_fish: Dictionary[StringName, bool] = {}
+ for index: int in range(mini(fish_slots.size(), SLOT_COUNT)):
+ if not normalized[index].is_empty():
+ continue
+ var catch_id: StringName = fish_slots[index]
+ if _can_assign_fish(catch_id) and not seen_fish.has(catch_id):
+ normalized_fish[index] = catch_id
+ seen_fish[catch_id] = true
_slots = normalized
+ _fish_slots = normalized_fish
_selected_slot = clampi(selected_slot, 0, SLOT_COUNT - 1)
slots_changed.emit()
- selected_slot_changed.emit(
- _selected_slot,
- _slots[_selected_slot]
- )
+ _emit_selected_assignment()
return true
func has_any_assignment() -> bool:
- for item_id: StringName in _slots:
- if not item_id.is_empty():
+ for index: int in range(SLOT_COUNT):
+ if (
+ not _slots[index].is_empty()
+ or not _fish_slots[index].is_empty()
+ ):
return true
return false
@@ -149,23 +252,49 @@ func _can_assign(item_id: StringName) -> bool:
return item != null and item.is_valid() and item.hotbar_allowed
+func _can_assign_fish(catch_id: StringName) -> bool:
+ return (
+ not catch_id.is_empty()
+ and _fish_inventory != null
+ and _fish_inventory.contains_catch_id(catch_id)
+ )
+
+
func _validate_assignments() -> void:
var changed: bool = false
for index: int in range(SLOT_COUNT):
if not _slots[index].is_empty() and not _can_assign(_slots[index]):
_slots[index] = StringName()
changed = true
+ if (
+ not _fish_slots[index].is_empty()
+ and not _can_assign_fish(_fish_slots[index])
+ ):
+ _fish_slots[index] = StringName()
+ changed = true
if changed:
slots_changed.emit()
- selected_slot_changed.emit(
- _selected_slot,
- _slots[_selected_slot]
- )
+ _emit_selected_assignment()
func _on_bag_contents_changed() -> void:
_validate_assignments()
+func _on_fish_inventory_changed() -> void:
+ _validate_assignments()
+
+
+func _emit_selected_assignment() -> void:
+ var kind: AssignmentKind = get_selected_assignment_kind()
+ var identity: StringName = StringName()
+ if kind == AssignmentKind.ITEM:
+ identity = _slots[_selected_slot]
+ elif kind == AssignmentKind.FISH:
+ identity = _fish_slots[_selected_slot]
+ selected_slot_changed.emit(_selected_slot, _slots[_selected_slot])
+ selected_assignment_changed.emit(_selected_slot, kind, identity)
+
+
func _is_slot_valid(slot_index: int) -> bool:
return slot_index >= 0 and slot_index < SLOT_COUNT
diff --git a/items/catalog/art_kit.tres b/items/catalog/art_kit.tres
new file mode 100644
index 0000000..ee5b28e
--- /dev/null
+++ b/items/catalog/art_kit.tres
@@ -0,0 +1,17 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1_item")
+item_id = &"art_kit"
+display_name = "Art Kit"
+description = "Press P in the game world to paint!"
+category = 1
+icon = ExtResource("2_icon")
+stackable = false
+max_stack = 1
+usable = false
+equippable = true
+hotbar_allowed = true
diff --git a/items/catalog/basic_fishing_rod.tres b/items/catalog/basic_fishing_rod.tres
index 4530abc..f43f9ca 100644
--- a/items/catalog/basic_fishing_rod.tres
+++ b/items/catalog/basic_fishing_rod.tres
@@ -1,7 +1,7 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1_script"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/basic_fishing_rod.svg" id="2_icon"]
+[ext_resource type="Texture2D" path="res://items/icons/equipment/64_rod_base_thick.png" id="2_icon"]
[resource]
script = ExtResource("1_script")
diff --git a/items/catalog/batteries.tres b/items/catalog/batteries.tres
new file mode 100644
index 0000000..9f26650
--- /dev/null
+++ b/items/catalog/batteries.tres
@@ -0,0 +1,15 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
+
+[resource]
+script = ExtResource("1_item")
+item_id = &"batteries"
+display_name = "batteries"
+description = "replacement batteries for the fish finder."
+category = 3
+stackable = true
+max_stack = 99
+usable = false
+equippable = false
+hotbar_allowed = false
diff --git a/items/catalog/coffee.tres b/items/catalog/coffee.tres
index 755b671..00d5206 100644
--- a/items/catalog/coffee.tres
+++ b/items/catalog/coffee.tres
@@ -1,7 +1,7 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/coffee.svg" id="2"]
+[ext_resource type="Texture2D" path="res://items/icons/consumables/64_consumable_coffee.png" id="2"]
[resource]
script = ExtResource("1")
diff --git a/items/catalog/energy_drink.tres b/items/catalog/energy_drink.tres
index 813230e..d3fcd93 100644
--- a/items/catalog/energy_drink.tres
+++ b/items/catalog/energy_drink.tres
@@ -1,7 +1,7 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/energy_drink.svg" id="2"]
+[ext_resource type="Texture2D" path="res://items/icons/consumables/64_consumable_energydrink_redfish.png" id="2"]
[resource]
script = ExtResource("1")
diff --git a/items/catalog/fish_finder.tres b/items/catalog/fish_finder.tres
index a37fb4a..a3fb6c4 100644
--- a/items/catalog/fish_finder.tres
+++ b/items/catalog/fish_finder.tres
@@ -1,16 +1,17 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/fish_finder.svg" id="2"]
+[ext_resource type="Texture2D" path="res://items/icons/consumables/64_consumable_fishfinder.png" id="2"]
[resource]
script = ExtResource("1")
item_id = &"fish_finder"
display_name = "fish finder"
-description = "temporarily shortens bite waits and improves rarity weights."
-category = 3
+description = "uses one battery to temporarily shorten bite waits and improve rarity weights."
+category = 1
icon = ExtResource("2")
-stackable = true
-max_stack = 99
+stackable = false
+max_stack = 1
usable = true
+equippable = true
hotbar_allowed = true
diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres
index 3cccb42..43377ba 100644
--- a/items/catalog/item_catalog.tres
+++ b/items/catalog/item_catalog.tres
@@ -1,4 +1,4 @@
-[gd_resource type="Resource" script_class="ItemCatalog" load_steps=8 format=3]
+[gd_resource type="Resource" script_class="ItemCatalog" load_steps=18 format=3]
[ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"]
[ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"]
@@ -7,7 +7,17 @@
[ext_resource type="Resource" path="res://items/catalog/snack.tres" id="5_snack"]
[ext_resource type="Resource" path="res://items/catalog/fish_finder.tres" id="6_finder"]
[ext_resource type="Resource" path="res://items/catalog/cooler_expansion.tres" id="7_cooler"]
+[ext_resource type="Resource" path="res://items/catalog/art_kit.tres" id="8_art_kit"]
+[ext_resource type="Resource" path="res://items/catalog/worms.tres" id="9_worms"]
+[ext_resource type="Resource" path="res://items/catalog/snails.tres" id="10_snails"]
+[ext_resource type="Resource" path="res://items/catalog/shrimp.tres" id="11_shrimp"]
+[ext_resource type="Resource" path="res://items/catalog/squid_chunks.tres" id="12_squid"]
+[ext_resource type="Resource" path="res://items/catalog/whole_anchovy.tres" id="13_anchovy"]
+[ext_resource type="Resource" path="res://items/catalog/whole_sardine.tres" id="14_sardine"]
+[ext_resource type="Resource" path="res://items/catalog/luminous_roe.tres" id="15_roe"]
+[ext_resource type="Resource" path="res://items/catalog/the_standby.tres" id="16_standby"]
+[ext_resource type="Resource" path="res://items/catalog/batteries.tres" id="17_batteries"]
[resource]
script = ExtResource("1_script")
-items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("7_cooler")]
+items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("7_cooler"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries")]
diff --git a/items/catalog/luminous_roe.tres b/items/catalog/luminous_roe.tres
new file mode 100644
index 0000000..7814f42
--- /dev/null
+++ b/items/catalog/luminous_roe.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_roe_luminous.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"luminous_roe"
+display_name = "luminous roe"
+description = "rare glowing roe with the strongest rare catch odds."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"bait_rarity_6"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/catalog/shrimp.tres b/items/catalog/shrimp.tres
new file mode 100644
index 0000000..e308163
--- /dev/null
+++ b/items/catalog/shrimp.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_shrimp.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"shrimp"
+display_name = "shrimp"
+description = "lively bait that noticeably improves rare catch chances."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"bait_rarity_2"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/catalog/snack.tres b/items/catalog/snack.tres
index 9d7c89b..4215595 100644
--- a/items/catalog/snack.tres
+++ b/items/catalog/snack.tres
@@ -1,12 +1,12 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/snack.svg" id="2"]
+[ext_resource type="Texture2D" path="res://items/icons/consumables/64_consumable_energybar.png" id="2"]
[resource]
script = ExtResource("1")
item_id = &"snack"
-display_name = "snack"
+display_name = "Energy Bar"
description = "temporarily adds one barrier damage."
category = 3
icon = ExtResource("2")
diff --git a/items/catalog/snails.tres b/items/catalog/snails.tres
new file mode 100644
index 0000000..12d4497
--- /dev/null
+++ b/items/catalog/snails.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_snail.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"snails"
+display_name = "snails"
+description = "slow-moving bait with a better chance for rarer catches."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"bait_rarity_1"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/catalog/squid_chunks.tres b/items/catalog/squid_chunks.tres
new file mode 100644
index 0000000..dfe245f
--- /dev/null
+++ b/items/catalog/squid_chunks.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_squid.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"squid_chunks"
+display_name = "squid chunks"
+description = "rich cut bait that strongly favors rarer catches."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"bait_rarity_3"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/catalog/the_standby.tres b/items/catalog/the_standby.tres
new file mode 100644
index 0000000..1fcc92e
--- /dev/null
+++ b/items/catalog/the_standby.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
+
+[resource]
+script = ExtResource("1_item")
+item_id = &"the_standby"
+display_name = "The Standby"
+description = "holds a bite until you're ready to start the fight."
+category = 7
+lure_effects = Array[StringName]([&"deferred_fight"])
+stackable = false
+max_stack = 1
+usable = false
+equippable = true
+hotbar_allowed = false
diff --git a/items/catalog/whole_anchovy.tres b/items/catalog/whole_anchovy.tres
new file mode 100644
index 0000000..cbc60b6
--- /dev/null
+++ b/items/catalog/whole_anchovy.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_sardine.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"whole_anchovy"
+display_name = "sardine"
+description = "valuable oily schooling bait tuned for exceptional catches."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"bait_rarity_5"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/catalog/whole_sardine.tres b/items/catalog/whole_sardine.tres
new file mode 100644
index 0000000..c5d0163
--- /dev/null
+++ b/items/catalog/whole_sardine.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_minnow.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"whole_sardine"
+display_name = "minnow"
+description = "small live bait with high rare catch potential."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"bait_rarity_4"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/catalog/worms.tres b/items/catalog/worms.tres
new file mode 100644
index 0000000..f01258e
--- /dev/null
+++ b/items/catalog/worms.tres
@@ -0,0 +1,16 @@
+[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
+[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_worms.png" id="2_icon"]
+
+[resource]
+script = ExtResource("1")
+item_id = &"worms"
+display_name = "worms"
+description = "basic bait that opens a small chance for rarer catches."
+icon = ExtResource("2_icon")
+category = 2
+bait_tags = Array[StringName]([&"worm", &"bait_rarity_0"])
+stackable = true
+max_stack = 10
+hotbar_allowed = false
diff --git a/items/icons/art/art_kit.png b/items/icons/art/art_kit.png
new file mode 100644
index 0000000..39bc32b
Binary files /dev/null and b/items/icons/art/art_kit.png differ
diff --git a/items/icons/art/art_kit.png.import b/items/icons/art/art_kit.png.import
new file mode 100644
index 0000000..053fd1f
--- /dev/null
+++ b/items/icons/art/art_kit.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://datu7j7tq2k3e"
+path="res://.godot/imported/art_kit.png-b54a66a446203c844f97c37241065423.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit.png"
+dest_files=["res://.godot/imported/art_kit.png-b54a66a446203c844f97c37241065423.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/art/art_kit_eraser.png b/items/icons/art/art_kit_eraser.png
new file mode 100644
index 0000000..6eb3003
Binary files /dev/null and b/items/icons/art/art_kit_eraser.png differ
diff --git a/items/icons/art/art_kit_eraser.png.import b/items/icons/art/art_kit_eraser.png.import
new file mode 100644
index 0000000..a25a8d1
--- /dev/null
+++ b/items/icons/art/art_kit_eraser.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cdbba3bvhuear"
+path="res://.godot/imported/art_kit_eraser.png-4df8445ba3139b8236508fc61a024158.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit_eraser.png"
+dest_files=["res://.godot/imported/art_kit_eraser.png-4df8445ba3139b8236508fc61a024158.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/art/art_kit_grid_finish_light.png b/items/icons/art/art_kit_grid_finish_light.png
new file mode 100644
index 0000000..684efed
Binary files /dev/null and b/items/icons/art/art_kit_grid_finish_light.png differ
diff --git a/items/icons/art/art_kit_grid_finish_light.png.import b/items/icons/art/art_kit_grid_finish_light.png.import
new file mode 100644
index 0000000..63d8261
--- /dev/null
+++ b/items/icons/art/art_kit_grid_finish_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dm24dy3ebvqmv"
+path="res://.godot/imported/art_kit_grid_finish_light.png-fe16d574640fb174cb8b6ff3cfb95e5e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit_grid_finish_light.png"
+dest_files=["res://.godot/imported/art_kit_grid_finish_light.png-fe16d574640fb174cb8b6ff3cfb95e5e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/art/art_kit_grid_hidden_light.png b/items/icons/art/art_kit_grid_hidden_light.png
new file mode 100644
index 0000000..af01077
Binary files /dev/null and b/items/icons/art/art_kit_grid_hidden_light.png differ
diff --git a/items/icons/art/art_kit_grid_hidden_light.png.import b/items/icons/art/art_kit_grid_hidden_light.png.import
new file mode 100644
index 0000000..95d7ac3
--- /dev/null
+++ b/items/icons/art/art_kit_grid_hidden_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cv6yicrysb0vs"
+path="res://.godot/imported/art_kit_grid_hidden_light.png-e92350d8a3e2c3e3bf286658f1ae0c41.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit_grid_hidden_light.png"
+dest_files=["res://.godot/imported/art_kit_grid_hidden_light.png-e92350d8a3e2c3e3bf286658f1ae0c41.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/art/art_kit_grid_light.png b/items/icons/art/art_kit_grid_light.png
new file mode 100644
index 0000000..58089a3
Binary files /dev/null and b/items/icons/art/art_kit_grid_light.png differ
diff --git a/items/icons/art/art_kit_grid_light.png.import b/items/icons/art/art_kit_grid_light.png.import
new file mode 100644
index 0000000..6435819
--- /dev/null
+++ b/items/icons/art/art_kit_grid_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dh2tu3ptqe6f7"
+path="res://.godot/imported/art_kit_grid_light.png-d445eba68746a34d73bbf1744fe56777.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit_grid_light.png"
+dest_files=["res://.godot/imported/art_kit_grid_light.png-d445eba68746a34d73bbf1744fe56777.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/art/art_kit_grid_restore_light.png b/items/icons/art/art_kit_grid_restore_light.png
new file mode 100644
index 0000000..9517e6d
Binary files /dev/null and b/items/icons/art/art_kit_grid_restore_light.png differ
diff --git a/items/icons/art/art_kit_grid_restore_light.png.import b/items/icons/art/art_kit_grid_restore_light.png.import
new file mode 100644
index 0000000..9672eab
--- /dev/null
+++ b/items/icons/art/art_kit_grid_restore_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ckxdpi5sqerao"
+path="res://.godot/imported/art_kit_grid_restore_light.png-d2f2458a90dabc79430d7ff0e0e3385d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit_grid_restore_light.png"
+dest_files=["res://.godot/imported/art_kit_grid_restore_light.png-d2f2458a90dabc79430d7ff0e0e3385d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/art/art_kit_marker.png b/items/icons/art/art_kit_marker.png
new file mode 100644
index 0000000..403f69c
Binary files /dev/null and b/items/icons/art/art_kit_marker.png differ
diff --git a/items/icons/art/art_kit_marker.png.import b/items/icons/art/art_kit_marker.png.import
new file mode 100644
index 0000000..cffece6
--- /dev/null
+++ b/items/icons/art/art_kit_marker.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b20kab6kxh7ap"
+path="res://.godot/imported/art_kit_marker.png-77e7b82481305350436ed5cca046df56.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/art/art_kit_marker.png"
+dest_files=["res://.godot/imported/art_kit_marker.png-77e7b82481305350436ed5cca046df56.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_minnow.png b/items/icons/bait/64_bait_minnow.png
new file mode 100644
index 0000000..f083d65
Binary files /dev/null and b/items/icons/bait/64_bait_minnow.png differ
diff --git a/items/icons/bait/64_bait_minnow.png.import b/items/icons/bait/64_bait_minnow.png.import
new file mode 100644
index 0000000..67f57f5
--- /dev/null
+++ b/items/icons/bait/64_bait_minnow.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://oc1nckfxeir0"
+path="res://.godot/imported/64_bait_minnow.png-25bb645caffd0cad87b30d6b38b80ca4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_minnow.png"
+dest_files=["res://.godot/imported/64_bait_minnow.png-25bb645caffd0cad87b30d6b38b80ca4.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_roe_luminous.png b/items/icons/bait/64_bait_roe_luminous.png
new file mode 100644
index 0000000..dc89fbd
Binary files /dev/null and b/items/icons/bait/64_bait_roe_luminous.png differ
diff --git a/items/icons/bait/64_bait_roe_luminous.png.import b/items/icons/bait/64_bait_roe_luminous.png.import
new file mode 100644
index 0000000..b0a78d0
--- /dev/null
+++ b/items/icons/bait/64_bait_roe_luminous.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bnqji6j6nigjq"
+path="res://.godot/imported/64_bait_roe_luminous.png-cfa7eaf935630a20e2e8111f803c7741.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_roe_luminous.png"
+dest_files=["res://.godot/imported/64_bait_roe_luminous.png-cfa7eaf935630a20e2e8111f803c7741.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_sardine.png b/items/icons/bait/64_bait_sardine.png
new file mode 100644
index 0000000..9161ba5
Binary files /dev/null and b/items/icons/bait/64_bait_sardine.png differ
diff --git a/items/icons/bait/64_bait_sardine.png.import b/items/icons/bait/64_bait_sardine.png.import
new file mode 100644
index 0000000..03f94bf
--- /dev/null
+++ b/items/icons/bait/64_bait_sardine.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ddbyrbcbm40i4"
+path="res://.godot/imported/64_bait_sardine.png-28b5d49b506c3007ebf15dcc8f198a94.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_sardine.png"
+dest_files=["res://.godot/imported/64_bait_sardine.png-28b5d49b506c3007ebf15dcc8f198a94.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_shrimp.png b/items/icons/bait/64_bait_shrimp.png
new file mode 100644
index 0000000..8a20783
Binary files /dev/null and b/items/icons/bait/64_bait_shrimp.png differ
diff --git a/items/icons/bait/64_bait_shrimp.png.import b/items/icons/bait/64_bait_shrimp.png.import
new file mode 100644
index 0000000..8c65f4a
--- /dev/null
+++ b/items/icons/bait/64_bait_shrimp.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bpq47xg8tv6th"
+path="res://.godot/imported/64_bait_shrimp.png-f68293fc8a81e803d5eeb1f268e4f3d3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_shrimp.png"
+dest_files=["res://.godot/imported/64_bait_shrimp.png-f68293fc8a81e803d5eeb1f268e4f3d3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_snail.png b/items/icons/bait/64_bait_snail.png
new file mode 100644
index 0000000..39e6c16
Binary files /dev/null and b/items/icons/bait/64_bait_snail.png differ
diff --git a/items/icons/bait/64_bait_snail.png.import b/items/icons/bait/64_bait_snail.png.import
new file mode 100644
index 0000000..de8d419
--- /dev/null
+++ b/items/icons/bait/64_bait_snail.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dihevd23dh5x3"
+path="res://.godot/imported/64_bait_snail.png-9a7a0c9f93be442a8a1d685a5324e3f6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_snail.png"
+dest_files=["res://.godot/imported/64_bait_snail.png-9a7a0c9f93be442a8a1d685a5324e3f6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_squid.png b/items/icons/bait/64_bait_squid.png
new file mode 100644
index 0000000..57e2b64
Binary files /dev/null and b/items/icons/bait/64_bait_squid.png differ
diff --git a/items/icons/bait/64_bait_squid.png.import b/items/icons/bait/64_bait_squid.png.import
new file mode 100644
index 0000000..41bf2b4
--- /dev/null
+++ b/items/icons/bait/64_bait_squid.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://kf3aglrd1en3"
+path="res://.godot/imported/64_bait_squid.png-6ec6c9776ffcae93fd2fa312c8a9013d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_squid.png"
+dest_files=["res://.godot/imported/64_bait_squid.png-6ec6c9776ffcae93fd2fa312c8a9013d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/bait/64_bait_worms.png b/items/icons/bait/64_bait_worms.png
new file mode 100644
index 0000000..51cb396
Binary files /dev/null and b/items/icons/bait/64_bait_worms.png differ
diff --git a/items/icons/bait/64_bait_worms.png.import b/items/icons/bait/64_bait_worms.png.import
new file mode 100644
index 0000000..5fbd6cd
--- /dev/null
+++ b/items/icons/bait/64_bait_worms.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bn6815x0rrspw"
+path="res://.godot/imported/64_bait_worms.png-c9f31d77527e73888fc56963d4e78e30.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/bait/64_bait_worms.png"
+dest_files=["res://.godot/imported/64_bait_worms.png-c9f31d77527e73888fc56963d4e78e30.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/consumables/64_consumable_coffee.png b/items/icons/consumables/64_consumable_coffee.png
new file mode 100644
index 0000000..3282a17
Binary files /dev/null and b/items/icons/consumables/64_consumable_coffee.png differ
diff --git a/items/icons/consumables/64_consumable_coffee.png.import b/items/icons/consumables/64_consumable_coffee.png.import
new file mode 100644
index 0000000..0d9a595
--- /dev/null
+++ b/items/icons/consumables/64_consumable_coffee.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://be3j2ahyt3cpe"
+path="res://.godot/imported/64_consumable_coffee.png-ba557079916477db3de4b894fdb65382.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/consumables/64_consumable_coffee.png"
+dest_files=["res://.godot/imported/64_consumable_coffee.png-ba557079916477db3de4b894fdb65382.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/consumables/64_consumable_coffee_plus.png b/items/icons/consumables/64_consumable_coffee_plus.png
new file mode 100644
index 0000000..621f796
Binary files /dev/null and b/items/icons/consumables/64_consumable_coffee_plus.png differ
diff --git a/items/icons/consumables/64_consumable_coffee_plus.png.import b/items/icons/consumables/64_consumable_coffee_plus.png.import
new file mode 100644
index 0000000..5e117c2
--- /dev/null
+++ b/items/icons/consumables/64_consumable_coffee_plus.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cxw5la6tr67di"
+path="res://.godot/imported/64_consumable_coffee_plus.png-512713373bb8b17a715adef67a378d07.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/consumables/64_consumable_coffee_plus.png"
+dest_files=["res://.godot/imported/64_consumable_coffee_plus.png-512713373bb8b17a715adef67a378d07.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/consumables/64_consumable_energybar.png b/items/icons/consumables/64_consumable_energybar.png
new file mode 100644
index 0000000..1657a6f
Binary files /dev/null and b/items/icons/consumables/64_consumable_energybar.png differ
diff --git a/items/icons/consumables/64_consumable_energybar.png.import b/items/icons/consumables/64_consumable_energybar.png.import
new file mode 100644
index 0000000..d68f7f3
--- /dev/null
+++ b/items/icons/consumables/64_consumable_energybar.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://deqccg83oy4hw"
+path="res://.godot/imported/64_consumable_energybar.png-8ac97efdf3bd5f681012ff6ea4dcdf16.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/consumables/64_consumable_energybar.png"
+dest_files=["res://.godot/imported/64_consumable_energybar.png-8ac97efdf3bd5f681012ff6ea4dcdf16.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/consumables/64_consumable_energydrink_redfish.png b/items/icons/consumables/64_consumable_energydrink_redfish.png
new file mode 100644
index 0000000..92a55dc
Binary files /dev/null and b/items/icons/consumables/64_consumable_energydrink_redfish.png differ
diff --git a/items/icons/consumables/64_consumable_energydrink_redfish.png.import b/items/icons/consumables/64_consumable_energydrink_redfish.png.import
new file mode 100644
index 0000000..43d9867
--- /dev/null
+++ b/items/icons/consumables/64_consumable_energydrink_redfish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d1jsfo8fy2cwx"
+path="res://.godot/imported/64_consumable_energydrink_redfish.png-c06a52f92093d918ce0318a12e99e46c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/consumables/64_consumable_energydrink_redfish.png"
+dest_files=["res://.godot/imported/64_consumable_energydrink_redfish.png-c06a52f92093d918ce0318a12e99e46c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/consumables/64_consumable_fish.png b/items/icons/consumables/64_consumable_fish.png
new file mode 100644
index 0000000..3738c28
Binary files /dev/null and b/items/icons/consumables/64_consumable_fish.png differ
diff --git a/items/icons/consumables/64_consumable_fish.png.import b/items/icons/consumables/64_consumable_fish.png.import
new file mode 100644
index 0000000..6a685fc
--- /dev/null
+++ b/items/icons/consumables/64_consumable_fish.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://m0hbd148mkit"
+path="res://.godot/imported/64_consumable_fish.png-a8d0ad1847950c457e505029adcac628.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/consumables/64_consumable_fish.png"
+dest_files=["res://.godot/imported/64_consumable_fish.png-a8d0ad1847950c457e505029adcac628.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/consumables/64_consumable_fishfinder.png b/items/icons/consumables/64_consumable_fishfinder.png
new file mode 100644
index 0000000..5c92ce1
Binary files /dev/null and b/items/icons/consumables/64_consumable_fishfinder.png differ
diff --git a/items/icons/consumables/64_consumable_fishfinder.png.import b/items/icons/consumables/64_consumable_fishfinder.png.import
new file mode 100644
index 0000000..1e4d3a1
--- /dev/null
+++ b/items/icons/consumables/64_consumable_fishfinder.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://keij7qvi06oe"
+path="res://.godot/imported/64_consumable_fishfinder.png-b07857ff1ce529c6f69ccae5892d747a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/consumables/64_consumable_fishfinder.png"
+dest_files=["res://.godot/imported/64_consumable_fishfinder.png-b07857ff1ce529c6f69ccae5892d747a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/equipment/64_cooler_plus.png b/items/icons/equipment/64_cooler_plus.png
new file mode 100644
index 0000000..acbd889
Binary files /dev/null and b/items/icons/equipment/64_cooler_plus.png differ
diff --git a/items/icons/equipment/64_cooler_plus.png.import b/items/icons/equipment/64_cooler_plus.png.import
new file mode 100644
index 0000000..44e493a
--- /dev/null
+++ b/items/icons/equipment/64_cooler_plus.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://baht2hredfky8"
+path="res://.godot/imported/64_cooler_plus.png-d0daa39246f92fd9774ec1721970efad.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/equipment/64_cooler_plus.png"
+dest_files=["res://.godot/imported/64_cooler_plus.png-d0daa39246f92fd9774ec1721970efad.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/equipment/64_rod_base_thick.png b/items/icons/equipment/64_rod_base_thick.png
new file mode 100644
index 0000000..fb56ec2
Binary files /dev/null and b/items/icons/equipment/64_rod_base_thick.png differ
diff --git a/items/icons/equipment/64_rod_base_thick.png.import b/items/icons/equipment/64_rod_base_thick.png.import
new file mode 100644
index 0000000..c8594f3
--- /dev/null
+++ b/items/icons/equipment/64_rod_base_thick.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://02t4tyyt0vwi"
+path="res://.godot/imported/64_rod_base_thick.png-60188964cb71ab30683aa983d1300765.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/equipment/64_rod_base_thick.png"
+dest_files=["res://.godot/imported/64_rod_base_thick.png-60188964cb71ab30683aa983d1300765.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/placeholder/basic_fishing_rod.svg b/items/icons/placeholder/basic_fishing_rod.svg
deleted file mode 100644
index ee4a812..0000000
--- a/items/icons/placeholder/basic_fishing_rod.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
diff --git a/items/icons/placeholder/basic_fishing_rod.svg.import b/items/icons/placeholder/basic_fishing_rod.svg.import
deleted file mode 100644
index e2aef7d..0000000
--- a/items/icons/placeholder/basic_fishing_rod.svg.import
+++ /dev/null
@@ -1,43 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://c6krnuo5lp861"
-path="res://.godot/imported/basic_fishing_rod.svg-bc9101c1a69623deb55f1b3189ae4567.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://items/icons/placeholder/basic_fishing_rod.svg"
-dest_files=["res://.godot/imported/basic_fishing_rod.svg-bc9101c1a69623deb55f1b3189ae4567.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/items/icons/placeholder/coffee.svg b/items/icons/placeholder/coffee.svg
deleted file mode 100644
index d30c96a..0000000
--- a/items/icons/placeholder/coffee.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/items/icons/placeholder/energy_drink.svg b/items/icons/placeholder/energy_drink.svg
deleted file mode 100644
index da04ac7..0000000
--- a/items/icons/placeholder/energy_drink.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/items/icons/placeholder/energy_drink.svg.import b/items/icons/placeholder/energy_drink.svg.import
deleted file mode 100644
index 0e79f6a..0000000
--- a/items/icons/placeholder/energy_drink.svg.import
+++ /dev/null
@@ -1,43 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://d3elrkttv1f1h"
-path="res://.godot/imported/energy_drink.svg-8d7bc90bd15610c7852e4cf522a5b1ca.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://items/icons/placeholder/energy_drink.svg"
-dest_files=["res://.godot/imported/energy_drink.svg-8d7bc90bd15610c7852e4cf522a5b1ca.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/items/icons/placeholder/fish_coin.svg b/items/icons/placeholder/fish_coin.svg
deleted file mode 100644
index ac4da92..0000000
--- a/items/icons/placeholder/fish_coin.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/items/icons/placeholder/fish_finder.svg b/items/icons/placeholder/fish_finder.svg
deleted file mode 100644
index 7bbc0b1..0000000
--- a/items/icons/placeholder/fish_finder.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/items/icons/placeholder/fish_finder.svg.import b/items/icons/placeholder/fish_finder.svg.import
deleted file mode 100644
index df63a31..0000000
--- a/items/icons/placeholder/fish_finder.svg.import
+++ /dev/null
@@ -1,43 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://cxsaix6vp7t6u"
-path="res://.godot/imported/fish_finder.svg-33ab7d731566e03c2f84e185d3503b7d.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://items/icons/placeholder/fish_finder.svg"
-dest_files=["res://.godot/imported/fish_finder.svg-33ab7d731566e03c2f84e185d3503b7d.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/items/icons/placeholder/reel_speed_upgrade.svg b/items/icons/placeholder/reel_speed_upgrade.svg
deleted file mode 100644
index a92d9fb..0000000
--- a/items/icons/placeholder/reel_speed_upgrade.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/items/icons/placeholder/reel_speed_upgrade.svg.import b/items/icons/placeholder/reel_speed_upgrade.svg.import
deleted file mode 100644
index 211c08f..0000000
--- a/items/icons/placeholder/reel_speed_upgrade.svg.import
+++ /dev/null
@@ -1,43 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://c2hal1gkmnuqa"
-path="res://.godot/imported/reel_speed_upgrade.svg-6bce0cfdbf18895938d0bcb4850467c8.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://items/icons/placeholder/reel_speed_upgrade.svg"
-dest_files=["res://.godot/imported/reel_speed_upgrade.svg-6bce0cfdbf18895938d0bcb4850467c8.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/items/icons/placeholder/snack.svg b/items/icons/placeholder/snack.svg
deleted file mode 100644
index edff1ec..0000000
--- a/items/icons/placeholder/snack.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/items/icons/shop/32_currency.png b/items/icons/shop/32_currency.png
new file mode 100644
index 0000000..de2a80c
Binary files /dev/null and b/items/icons/shop/32_currency.png differ
diff --git a/items/icons/shop/32_currency.png.import b/items/icons/shop/32_currency.png.import
new file mode 100644
index 0000000..1614c30
--- /dev/null
+++ b/items/icons/shop/32_currency.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bf3jlmqhmmuge"
+path="res://.godot/imported/32_currency.png-c07c9577bae5b0fbbae9513440339b6f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/shop/32_currency.png"
+dest_files=["res://.godot/imported/32_currency.png-c07c9577bae5b0fbbae9513440339b6f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/shop/64_power_plus.png b/items/icons/shop/64_power_plus.png
new file mode 100644
index 0000000..8aacdbc
Binary files /dev/null and b/items/icons/shop/64_power_plus.png differ
diff --git a/items/icons/shop/64_power_plus.png.import b/items/icons/shop/64_power_plus.png.import
new file mode 100644
index 0000000..c82907d
--- /dev/null
+++ b/items/icons/shop/64_power_plus.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://xh2g4eytrjep"
+path="res://.godot/imported/64_power_plus.png-98b7e06d3090a4411d56e2046b8c9d4b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/shop/64_power_plus.png"
+dest_files=["res://.godot/imported/64_power_plus.png-98b7e06d3090a4411d56e2046b8c9d4b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/icons/shop/64_speed_plus.png b/items/icons/shop/64_speed_plus.png
new file mode 100644
index 0000000..bd605fe
Binary files /dev/null and b/items/icons/shop/64_speed_plus.png differ
diff --git a/items/icons/shop/64_speed_plus.png.import b/items/icons/shop/64_speed_plus.png.import
new file mode 100644
index 0000000..6d330de
--- /dev/null
+++ b/items/icons/shop/64_speed_plus.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dk6yx5cbpblo6"
+path="res://.godot/imported/64_speed_plus.png-c695fb4021c17dfa94ed7aa48ce262c1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://items/icons/shop/64_speed_plus.png"
+dest_files=["res://.godot/imported/64_speed_plus.png-c695fb4021c17dfa94ed7aa48ce262c1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/items/item_data.gd b/items/item_data.gd
index ebcca91..a34cec0 100644
--- a/items/item_data.gd
+++ b/items/item_data.gd
@@ -9,12 +9,15 @@ enum Category {
UTILITY,
QUEST,
COSMETIC,
+ LURE,
}
@export var item_id: StringName
@export var display_name: String
@export_multiline var description: String
@export var category: Category = Category.UTILITY
+@export var bait_tags: Array[StringName] = []
+@export var lure_effects: Array[StringName] = []
@export var icon: Texture2D
@export var stackable: bool = false
@export_range(1, 999, 1) var max_stack: int = 1
@@ -34,3 +37,11 @@ func is_valid() -> bool:
func get_category_name() -> String:
return Category.keys()[category].to_lower()
+
+
+func is_bait() -> bool:
+ return category == Category.BAIT and not bait_tags.is_empty()
+
+
+func is_lure() -> bool:
+ return category == Category.LURE and equippable
diff --git a/jobs/job_catalog.gd b/jobs/job_catalog.gd
new file mode 100644
index 0000000..009a152
--- /dev/null
+++ b/jobs/job_catalog.gd
@@ -0,0 +1,416 @@
+class_name JobCatalog
+extends RefCounted
+
+const FishDataType = preload("res://fish/fish_data.gd")
+
+enum Kind {
+ CATCH_TOTAL,
+ CATCH_WATER,
+ SELL_TOTAL,
+ CATCH_QUALITY,
+ CATCH_PHASE,
+ CATCH_WEATHER,
+ CATCH_SPECIES,
+ REACH_LEVEL,
+ DISCOVER_SPECIES,
+ MASTER_QUALITIES,
+}
+
+const DAILY_JOB_COUNT: int = 4
+const WEATHER_SEGMENT_HOURS: float = (
+ WorldWeatherService.DAILY_PLAN_SEGMENT_HOURS
+)
+const WEATHER_SEGMENT_COUNT: int = (
+ WorldWeatherService.DAILY_PLAN_SEGMENT_COUNT
+)
+const MAX_JOB_REWARD_COINS: int = 100000
+const MAX_JOB_REWARD_EXPERIENCE: int = 10000
+
+
+static func generate_daily_jobs(
+ plan_id: String,
+ candidates: Array[FishDataType],
+ allow_weather_jobs: bool = true,
+) -> Array[Dictionary]:
+ var rng := RandomNumberGenerator.new()
+ rng.seed = plan_id.hash()
+ var jobs: Array[Dictionary] = [
+ _job(
+ "fresh_catches",
+ "fresh catch delivery",
+ "catch 5 fresh water fish",
+ Kind.CATCH_WATER,
+ 5,
+ 35,
+ 50,
+ {"water_type": int(WaterType.Type.FRESH_WATER)},
+ ),
+ _job(
+ "sell_fish",
+ "market supply",
+ "sell 5 fish",
+ Kind.SELL_TOTAL,
+ 5,
+ 40,
+ 50,
+ ),
+ ]
+ var optional: Array[Dictionary] = [
+ _job(
+ "quality_catch",
+ "quality check",
+ "catch an impressive fish or better",
+ Kind.CATCH_QUALITY,
+ 1,
+ 55,
+ 75,
+ {"minimum_quality": int(FishQuality.Tier.IMPRESSIVE)},
+ ),
+ _job(
+ "night_catch",
+ "night shift",
+ "catch 3 fish at night",
+ Kind.CATCH_PHASE,
+ 3,
+ 35,
+ 50,
+ {"phase": int(WorldTimeService.Phase.NIGHT)},
+ ),
+ ]
+ if allow_weather_jobs:
+ optional.append(_job(
+ "fog_catch",
+ "through the fog",
+ "catch 2 fish in foggy weather",
+ Kind.CATCH_WEATHER,
+ 2,
+ 65,
+ 75,
+ {"weather": int(WorldWeatherService.Weather.FOGGY)},
+ ))
+ var species: Array[FishDataType] = []
+ for fish: FishDataType in candidates:
+ if fish != null and fish.is_selectable():
+ species.append(fish)
+ if not species.is_empty():
+ species.sort_custom(
+ func(a: FishDataType, b: FishDataType) -> bool:
+ return String(a.id) < String(b.id)
+ )
+ var species_index: int = rng.randi_range(0, species.size() - 1)
+ var selected: FishDataType = species[species_index]
+ optional.append(_job(
+ "species_%s" % String(selected.id),
+ "specific request",
+ "catch 3 %s" % selected.display_name.to_lower(),
+ Kind.CATCH_SPECIES,
+ 3,
+ 45,
+ 50,
+ {"fish_id": String(selected.id)},
+ ))
+ _shuffle(optional, rng)
+ while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty():
+ jobs.append(optional.pop_back())
+ return jobs
+
+
+static func generate_weather_schedule(
+ plan_id: String,
+ jobs: Array[Dictionary],
+ anchor_index: int = 0,
+) -> Array[Dictionary]:
+ anchor_index = clampi(anchor_index, 0, WEATHER_SEGMENT_COUNT - 1)
+ var required: Array[int] = []
+ for job: Dictionary in jobs:
+ if int(job.get("kind", -1)) != Kind.CATCH_WEATHER:
+ continue
+ var weather: int = int(job.get("weather", -1))
+ if WorldWeatherService.is_valid_weather(weather) and weather not in required:
+ required.append(weather)
+ var rng := RandomNumberGenerator.new()
+ rng.seed = plan_id.hash() ^ 0x57454154
+ var weather_values: Array[int] = []
+ for index: int in WEATHER_SEGMENT_COUNT:
+ var roll: float = rng.randf()
+ var weather: int = int(WorldWeatherService.Weather.SUNNY)
+ if roll >= 0.42 and roll < 0.70:
+ weather = int(WorldWeatherService.Weather.CLOUDY)
+ elif roll >= 0.70 and roll < 0.88:
+ weather = int(WorldWeatherService.Weather.RAINY)
+ elif roll >= 0.88:
+ weather = int(WorldWeatherService.Weather.FOGGY)
+ weather_values.append(weather)
+ for requirement_index: int in required.size():
+ var early_index: int = mini(
+ anchor_index + requirement_index,
+ WEATHER_SEGMENT_COUNT - 2,
+ )
+ var remaining_segments: int = WEATHER_SEGMENT_COUNT - early_index
+ var later_index: int = mini(
+ early_index + maxi(
+ 1, floori(float(remaining_segments) / 2.0)
+ ),
+ WEATHER_SEGMENT_COUNT - 1,
+ )
+ weather_values[early_index] = required[requirement_index]
+ weather_values[later_index] = required[requirement_index]
+ var schedule: Array[Dictionary] = []
+ for index: int in WEATHER_SEGMENT_COUNT:
+ schedule.append({
+ "start_hour": fposmod(
+ WorldTimeService.DAY_START_HOUR
+ + float(index) * WEATHER_SEGMENT_HOURS,
+ WorldTimeService.HOURS_PER_DAY,
+ ),
+ "weather": weather_values[index],
+ })
+ return schedule
+
+
+static func lifetime_chains(
+ registered_species_count: int,
+) -> Array[Dictionary]:
+ var chains: Array[Dictionary] = [
+ _chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [
+ [150, 250], [700, 1000], [2500, 3000],
+ ]),
+ _chain("sell", Kind.SELL_TOTAL, [50, 500, 2500], [
+ [125, 200], [600, 800], [2200, 2400],
+ ]),
+ _chain("level", Kind.REACH_LEVEL, [5, 10, 25, 50], [
+ [100, 150], [250, 350], [1000, 1200], [3000, 3500],
+ ]),
+ ]
+ if registered_species_count > 0:
+ chains.append(_chain(
+ "discover",
+ Kind.DISCOVER_SPECIES,
+ [registered_species_count],
+ [[500, 750]],
+ ))
+ chains.append(_chain(
+ "master",
+ Kind.MASTER_QUALITIES,
+ [registered_species_count],
+ [[1500, 2000]],
+ ))
+ return chains
+
+
+static func visible_lifetime_jobs(
+ registered_species_count: int,
+ claimed_ids: Array[String],
+) -> Array[Dictionary]:
+ var visible: Array[Dictionary] = []
+ for chain: Dictionary in lifetime_chains(registered_species_count):
+ var targets: Array = chain.get("targets", [])
+ var rewards: Array = chain.get("rewards", [])
+ for tier_index: int in targets.size():
+ var job_id: String = "%s_%d" % [
+ str(chain.get("id", "")),
+ int(targets[tier_index]),
+ ]
+ if job_id in claimed_ids:
+ continue
+ var reward: Array = rewards[tier_index]
+ visible.append(_job(
+ job_id,
+ _lifetime_title(str(chain.get("id", "")), int(targets[tier_index])),
+ _lifetime_description(
+ str(chain.get("id", "")), int(targets[tier_index])
+ ),
+ int(chain.get("kind", Kind.CATCH_TOTAL)) as Kind,
+ int(targets[tier_index]),
+ int(reward[0]),
+ int(reward[1]),
+ ))
+ break
+ return visible
+
+
+static func weather_requirements(jobs: Array[Dictionary]) -> Array[int]:
+ var result: Array[int] = []
+ for job: Dictionary in jobs:
+ if int(job.get("kind", -1)) != Kind.CATCH_WEATHER:
+ continue
+ var weather: int = int(job.get("weather", -1))
+ if WorldWeatherService.is_valid_weather(weather) and weather not in result:
+ result.append(weather)
+ return result
+
+
+static func is_valid_job(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var job: Dictionary = value
+ var kind: int = int(job.get("kind", -1))
+ if not (
+ typeof(job.get("id")) == TYPE_STRING
+ and not str(job["id"]).is_empty()
+ and str(job["id"]).length() <= 96
+ and typeof(job.get("title")) == TYPE_STRING
+ and str(job["title"]).length() <= 96
+ and typeof(job.get("description")) == TYPE_STRING
+ and str(job["description"]).length() <= 160
+ and is_bounded_integer(
+ job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_QUALITIES
+ )
+ and kind >= Kind.CATCH_TOTAL
+ and kind <= Kind.MASTER_QUALITIES
+ and is_bounded_integer(job.get("target"), 1, 1000000000)
+ and is_bounded_integer(
+ job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS
+ )
+ and is_bounded_integer(
+ job.get("experience"), 0, MAX_JOB_REWARD_EXPERIENCE
+ )
+ ):
+ return false
+ match kind:
+ Kind.CATCH_WATER:
+ var water_type: int = int(job.get("water_type", -1))
+ return (
+ is_bounded_integer(
+ job.get("water_type"),
+ WaterType.Type.FRESH_WATER,
+ WaterType.Type.OTHER,
+ )
+ and water_type >= WaterType.Type.FRESH_WATER
+ and water_type <= WaterType.Type.OTHER
+ )
+ Kind.CATCH_QUALITY:
+ return (
+ is_bounded_integer(
+ job.get("minimum_quality"),
+ FishQuality.Tier.BORING,
+ FishQuality.Tier.SHINY,
+ )
+ and FishQuality.is_valid(int(job.get("minimum_quality", -1)))
+ )
+ Kind.CATCH_PHASE:
+ var phase: int = int(job.get("phase", -1))
+ return (
+ is_bounded_integer(
+ job.get("phase"),
+ WorldTimeService.Phase.DAWN,
+ WorldTimeService.Phase.NIGHT,
+ )
+ and phase >= WorldTimeService.Phase.DAWN
+ and phase <= WorldTimeService.Phase.NIGHT
+ )
+ Kind.CATCH_WEATHER:
+ return (
+ is_bounded_integer(
+ job.get("weather"),
+ WorldWeatherService.Weather.SUNNY,
+ WorldWeatherService.Weather.FOGGY,
+ )
+ and WorldWeatherService.is_valid_weather(
+ int(job.get("weather", -1))
+ )
+ )
+ Kind.CATCH_SPECIES:
+ return (
+ typeof(job.get("fish_id")) == TYPE_STRING
+ and not str(job.get("fish_id", "")).is_empty()
+ and str(job.get("fish_id", "")).length() <= 96
+ )
+ return true
+
+
+static func is_bounded_integer(
+ value: Variant,
+ minimum: int,
+ maximum: int,
+) -> bool:
+ if typeof(value) == TYPE_INT:
+ return int(value) >= minimum and int(value) <= maximum
+ if typeof(value) != TYPE_FLOAT:
+ return false
+ var number: float = float(value)
+ return (
+ is_finite(number)
+ and number >= float(minimum)
+ and number <= float(maximum)
+ and is_equal_approx(number, round(number))
+ )
+
+
+static func is_valid_weather_schedule(value: Variant) -> bool:
+ return WorldWeatherService.is_valid_daily_plan_schedule(value)
+
+
+static func _job(
+ id: String,
+ title: String,
+ description: String,
+ kind: Kind,
+ target: int,
+ fish_coin: int,
+ experience: int,
+ extra: Dictionary = {},
+) -> Dictionary:
+ var result: Dictionary = {
+ "id": id,
+ "title": title,
+ "description": description,
+ "kind": int(kind),
+ "target": target,
+ "fish_coin": fish_coin,
+ "experience": experience,
+ }
+ result.merge(extra, true)
+ return result
+
+
+static func _chain(
+ id: String,
+ kind: Kind,
+ targets: Array[int],
+ rewards: Array,
+) -> Dictionary:
+ return {
+ "id": id,
+ "kind": int(kind),
+ "targets": targets,
+ "rewards": rewards,
+ }
+
+
+static func _lifetime_title(chain_id: String, target: int) -> String:
+ match chain_id:
+ "catch":
+ return "seasoned angler"
+ "sell":
+ return "market regular"
+ "level":
+ return "reach level %d" % target
+ "discover":
+ return "complete the catalog"
+ "master":
+ return "quality master"
+ return "long-term job"
+
+
+static func _lifetime_description(chain_id: String, target: int) -> String:
+ match chain_id:
+ "catch":
+ return "catch %d fish" % target
+ "sell":
+ return "sell %d fish" % target
+ "level":
+ return "reach player level %d" % target
+ "discover":
+ return "discover all %d cataloged fish" % target
+ "master":
+ return "collect every quality of all %d fish" % target
+ return "keep fishing"
+
+
+static func _shuffle(values: Array[Dictionary], rng: RandomNumberGenerator) -> void:
+ for index: int in range(values.size() - 1, 0, -1):
+ var swap_index: int = rng.randi_range(0, index)
+ var value: Dictionary = values[index]
+ values[index] = values[swap_index]
+ values[swap_index] = value
diff --git a/jobs/job_catalog.gd.uid b/jobs/job_catalog.gd.uid
new file mode 100644
index 0000000..f54ab48
--- /dev/null
+++ b/jobs/job_catalog.gd.uid
@@ -0,0 +1 @@
+uid://c52vqhdties3o
diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd
new file mode 100644
index 0000000..18d6f89
--- /dev/null
+++ b/jobs/player_job_service.gd
@@ -0,0 +1,814 @@
+class_name PlayerJobService
+extends Node
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+
+const FORMAT_VERSION: int = 1
+const MAX_PROGRESS_VALUE: int = 1000000000
+const MAX_PENDING_REWARDS: int = 64
+const DAILY_REFRESH_HOUR: float = WorldTimeService.DAY_START_HOUR
+
+signal changed
+signal board_changed
+signal reward_claimed(title: String, fish_coin: int, experience: int)
+signal status_changed(message: String)
+
+var _wallet: PlayerWallet
+var _experience: PlayerExperience
+var _collection: CollectionLog
+var _catalog: FishPoolType
+var _world_time: WorldTimeService
+var _world_weather: WorldWeatherService
+var _session: NetworkSession
+var _save_manager: PlayerSaveManager
+var _network_fishing: NetworkFishingService
+var _network_sale: NetworkSaleService
+
+var _progression_ready: bool = false
+var _host_board: Dictionary = {}
+var _active_board: Dictionary = {}
+var _active_plan_id: String = ""
+var _daily_progress: Dictionary[String, int] = {}
+var _daily_completions: Dictionary[String, int] = {}
+var _pending_rewards: Array[Dictionary] = []
+var _lifetime_claimed: Array[String] = []
+var _total_catches: int = 0
+var _total_sold: int = 0
+var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR
+
+
+func setup(
+ wallet: PlayerWallet,
+ experience: PlayerExperience,
+ collection: CollectionLog,
+ catalog: FishPoolType,
+ world_time: WorldTimeService,
+ world_weather: WorldWeatherService,
+ session: NetworkSession,
+) -> void:
+ _wallet = wallet
+ _experience = experience
+ _collection = collection
+ _catalog = catalog
+ _world_time = world_time
+ _world_weather = world_weather
+ _session = session
+ _daily_clock_hour = _world_time.get_time_hours()
+ _world_time.natural_time_advanced.connect(_on_natural_time_advanced)
+ _session.state_changed.connect(_on_session_state_changed)
+ _collection.collection_changed.connect(_on_current_state_changed)
+ _experience.experience_changed.connect(_on_experience_changed)
+
+
+func bind_authoritative_services(
+ network_fishing: NetworkFishingService,
+ network_sale: NetworkSaleService,
+) -> void:
+ _network_fishing = network_fishing
+ _network_sale = network_sale
+ if not _network_fishing.local_catch_received.is_connected(
+ _on_authoritative_catch
+ ):
+ _network_fishing.local_catch_received.connect(_on_authoritative_catch)
+ if not _network_sale.local_sale_finished.is_connected(
+ _on_authoritative_sale_finished
+ ):
+ _network_sale.local_sale_finished.connect(
+ _on_authoritative_sale_finished
+ )
+
+
+func set_save_manager(save_manager: PlayerSaveManager) -> void:
+ _save_manager = save_manager
+
+
+func begin_progression_session() -> void:
+ _progression_ready = true
+ if _session.is_host():
+ _activate_host_board()
+ changed.emit()
+
+
+func end_progression_session() -> void:
+ _progression_ready = false
+ _active_board = {}
+ changed.emit()
+
+
+func get_daily_jobs() -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ var jobs: Array = _active_board.get("jobs", [])
+ for value: Variant in jobs:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var job: Dictionary = (value as Dictionary).duplicate(true)
+ var job_id: String = str(job.get("id", ""))
+ job["progress"] = mini(
+ int(_daily_progress.get(job_id, 0)), int(job.get("target", 1))
+ )
+ job["completed_count"] = int(_daily_completions.get(job_id, 0))
+ var pending: Array[Dictionary] = _pending_for_job(
+ _active_plan_id, job_id
+ )
+ job["pending_count"] = pending.size()
+ job["claim_id"] = (
+ str(pending[0].get("claim_id", "")) if not pending.is_empty() else ""
+ )
+ job["claimable"] = not pending.is_empty()
+ result.append(job)
+ return result
+
+
+func get_lifetime_jobs() -> Array[Dictionary]:
+ var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs(
+ _registered_species_count(), _lifetime_claimed
+ )
+ for job: Dictionary in result:
+ var progress: int = _lifetime_progress(job)
+ job["progress"] = mini(progress, int(job.get("target", 1)))
+ job["complete"] = progress >= int(job.get("target", 1))
+ job["claimable"] = bool(job["complete"])
+ return result
+
+
+func get_pending_rewards() -> Array[Dictionary]:
+ return _pending_rewards.duplicate(true)
+
+
+func get_forecast() -> Array[Dictionary]:
+ var schedule: Array = _active_board.get("weather_schedule", [])
+ var result: Array[Dictionary] = []
+ for value: Variant in schedule:
+ if typeof(value) == TYPE_DICTIONARY:
+ result.append((value as Dictionary).duplicate(true))
+ return result
+
+
+func get_plan_id() -> String:
+ return _active_plan_id
+
+
+func has_active_board() -> bool:
+ return validate_board(_active_board)
+
+
+func get_time_until_refresh_text() -> String:
+ if _active_plan_id.is_empty() or _world_time == null:
+ return "daily jobs unavailable"
+ var elapsed: float = fposmod(
+ _daily_clock_hour - DAILY_REFRESH_HOUR,
+ WorldTimeService.HOURS_PER_DAY,
+ )
+ var hours_remaining: float = WorldTimeService.HOURS_PER_DAY - elapsed
+ var real_seconds: int = ceili(
+ hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND
+ )
+ return "refreshes in %d:%02d" % [
+ floori(float(real_seconds) / 60.0), real_seconds % 60,
+ ]
+
+
+func claim(claim_id: String) -> bool:
+ if claim_id.is_empty() or _wallet == null or _experience == null:
+ return false
+ for index: int in _pending_rewards.size():
+ var reward: Dictionary = _pending_rewards[index]
+ if str(reward.get("claim_id", "")) != claim_id:
+ continue
+ return _claim_pending_reward(index, reward)
+ for lifetime_job: Dictionary in get_lifetime_jobs():
+ if str(lifetime_job.get("id", "")) != claim_id:
+ continue
+ if not bool(lifetime_job.get("claimable", false)):
+ return false
+ return _claim_lifetime_reward(lifetime_job)
+ return false
+
+
+func get_host_board_network_data() -> Dictionary:
+ var board := _host_board.duplicate(true)
+ if not board.is_empty():
+ board["daily_clock_hour"] = _daily_clock_hour
+ return board
+
+
+func apply_remote_board(board: Dictionary) -> bool:
+ if not validate_board(board) or not _matches_canonical_board(board):
+ return false
+ var plan_id: String = str(board.get("plan_id", ""))
+ if plan_id != _active_plan_id:
+ _expire_incomplete_daily_jobs()
+ _active_plan_id = plan_id
+ _daily_progress.clear()
+ _daily_completions.clear()
+ _active_board = board.duplicate(true)
+ if board.has("daily_clock_hour"):
+ _daily_clock_hour = float(board["daily_clock_hour"])
+ changed.emit()
+ return true
+
+
+func clear_remote_board() -> void:
+ if _session != null and _session.is_host():
+ _activate_host_board()
+ return
+ _active_board = {}
+ changed.emit()
+
+
+func to_save_data() -> Dictionary:
+ var progress: Dictionary = {}
+ for job_id: String in _daily_progress:
+ progress[job_id] = _daily_progress[job_id]
+ var completions: Dictionary = {}
+ for job_id: String in _daily_completions:
+ completions[job_id] = _daily_completions[job_id]
+ return {
+ "format_version": FORMAT_VERSION,
+ "host_board": _host_board.duplicate(true),
+ "active_plan_id": _active_plan_id,
+ "daily_progress": progress,
+ "daily_completions": completions,
+ "daily_clock_hour": _daily_clock_hour,
+ "pending_rewards": _pending_rewards.duplicate(true),
+ "lifetime_claimed": _lifetime_claimed.duplicate(),
+ "statistics": {
+ "fish_caught": _total_catches,
+ "fish_sold": _total_sold,
+ },
+ }
+
+
+func restore_from_save_data(data: Dictionary) -> bool:
+ if not validate_save_data(data):
+ return false
+ var received_board: Dictionary = _active_board.duplicate(true)
+ _host_board = (data.get("host_board", {}) as Dictionary).duplicate(true)
+ _active_plan_id = str(data.get("active_plan_id", ""))
+ _daily_progress.clear()
+ var progress: Dictionary = data.get("daily_progress", {})
+ for key: Variant in progress:
+ _daily_progress[str(key)] = int(progress[key])
+ _daily_completions.clear()
+ var completions: Dictionary = data.get("daily_completions", {})
+ for key: Variant in completions:
+ _daily_completions[str(key)] = mini(int(completions[key]), 1)
+ _daily_clock_hour = float(data.get(
+ "daily_clock_hour",
+ _world_time.get_time_hours()
+ if _world_time != null
+ else WorldTimeService.DEFAULT_START_HOUR,
+ ))
+ _pending_rewards.clear()
+ var rewards: Array = data.get("pending_rewards", [])
+ var restored_daily_rewards: Dictionary[String, bool] = {}
+ for value: Variant in rewards:
+ var reward: Dictionary = value
+ var reward_key: String = "%s/%s" % [
+ str(reward.get("source_plan_id", "")),
+ str(reward.get("source_job_id", "")),
+ ]
+ if restored_daily_rewards.has(reward_key):
+ continue
+ restored_daily_rewards[reward_key] = true
+ _pending_rewards.append(reward.duplicate(true))
+ _lifetime_claimed.clear()
+ var claimed: Array = data.get("lifetime_claimed", [])
+ for value: Variant in claimed:
+ _lifetime_claimed.append(str(value))
+ var statistics: Dictionary = data.get("statistics", {})
+ _total_catches = int(statistics.get("fish_caught", 0))
+ _total_sold = int(statistics.get("fish_sold", 0))
+ if _session != null and _session.is_host():
+ _active_board = _host_board.duplicate(true)
+ _active_plan_id = str(_host_board.get("plan_id", ""))
+ elif (
+ _session != null
+ and _session.is_joined_client()
+ and not received_board.is_empty()
+ ):
+ apply_remote_board(received_board)
+ _apply_host_weather_plan()
+ changed.emit()
+ return true
+
+
+func reset_to_defaults() -> void:
+ _host_board = {}
+ _active_board = {}
+ _active_plan_id = ""
+ _daily_progress.clear()
+ _daily_completions.clear()
+ _pending_rewards.clear()
+ _lifetime_claimed.clear()
+ _total_catches = 0
+ _total_sold = 0
+ _daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR
+ changed.emit()
+
+
+static func default_save_data() -> Dictionary:
+ return {
+ "format_version": FORMAT_VERSION,
+ "host_board": {},
+ "active_plan_id": "",
+ "daily_progress": {},
+ "daily_completions": {},
+ "daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR,
+ "pending_rewards": [],
+ "lifetime_claimed": [],
+ "statistics": {"fish_caught": 0, "fish_sold": 0},
+ }
+
+
+static func validate_board(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var board: Dictionary = value
+ if (
+ typeof(board.get("plan_id")) != TYPE_STRING
+ or str(board.get("plan_id", "")).is_empty()
+ or str(board.get("plan_id", "")).length() > 96
+ or not JobCatalog.is_bounded_integer(
+ board.get("cycle"), 0, MAX_PROGRESS_VALUE
+ )
+ or not JobCatalog.is_bounded_integer(
+ board.get("schedule_anchor_index"),
+ 0,
+ JobCatalog.WEATHER_SEGMENT_COUNT - 1,
+ )
+ or typeof(board.get("jobs")) != TYPE_ARRAY
+ or not JobCatalog.is_valid_weather_schedule(
+ board.get("weather_schedule", [])
+ )
+ ):
+ return false
+ var jobs: Array = board.get("jobs", [])
+ if jobs.is_empty() or jobs.size() > 8:
+ return false
+ var seen: Dictionary[String, bool] = {}
+ for job_value: Variant in jobs:
+ if not JobCatalog.is_valid_job(job_value):
+ return false
+ var job: Dictionary = job_value
+ var job_id: String = str(job.get("id", ""))
+ if seen.has(job_id):
+ return false
+ seen[job_id] = true
+ return true
+
+
+static func validate_save_data(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var data: Dictionary = value
+ if (
+ not JobCatalog.is_bounded_integer(
+ data.get("format_version"), FORMAT_VERSION, FORMAT_VERSION
+ )
+ or typeof(data.get("host_board")) != TYPE_DICTIONARY
+ or typeof(data.get("active_plan_id")) != TYPE_STRING
+ or typeof(data.get("daily_progress")) != TYPE_DICTIONARY
+ or typeof(data.get("daily_completions")) != TYPE_DICTIONARY
+ or typeof(data.get("pending_rewards")) != TYPE_ARRAY
+ or typeof(data.get("lifetime_claimed")) != TYPE_ARRAY
+ or typeof(data.get("statistics")) != TYPE_DICTIONARY
+ ):
+ return false
+ var host_board: Dictionary = data.get("host_board", {})
+ if not host_board.is_empty() and not validate_board(host_board):
+ return false
+ if (
+ data.has("daily_clock_hour")
+ and (
+ typeof(data["daily_clock_hour"]) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(data["daily_clock_hour"]))
+ or float(data["daily_clock_hour"]) < 0.0
+ or float(data["daily_clock_hour"]) >= WorldTimeService.HOURS_PER_DAY
+ )
+ ):
+ return false
+ var progress: Dictionary = data.get("daily_progress", {})
+ if progress.size() > 8:
+ return false
+ for key: Variant in progress:
+ if (
+ typeof(key) != TYPE_STRING
+ or str(key).is_empty()
+ or str(key).length() > 96
+ or not JobCatalog.is_bounded_integer(
+ progress[key], 0, MAX_PROGRESS_VALUE
+ )
+ ):
+ return false
+ var completions: Dictionary = data.get("daily_completions", {})
+ if completions.size() > 8:
+ return false
+ for key: Variant in completions:
+ if (
+ typeof(key) != TYPE_STRING
+ or str(key).is_empty()
+ or str(key).length() > 96
+ or not JobCatalog.is_bounded_integer(
+ completions[key], 0, MAX_PROGRESS_VALUE
+ )
+ ):
+ return false
+ var rewards: Array = data.get("pending_rewards", [])
+ if rewards.size() > MAX_PENDING_REWARDS:
+ return false
+ for reward: Variant in rewards:
+ if not _valid_reward(reward):
+ return false
+ var claimed: Array = data.get("lifetime_claimed", [])
+ if claimed.size() > 64 or not _valid_string_array(claimed, 96):
+ return false
+ var statistics: Dictionary = data.get("statistics", {})
+ return (
+ JobCatalog.is_bounded_integer(
+ statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE
+ )
+ and JobCatalog.is_bounded_integer(
+ statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE
+ )
+ )
+
+
+func _activate_host_board() -> void:
+ if _host_board.is_empty():
+ _generate_host_board(0)
+ else:
+ _active_board = _host_board.duplicate(true)
+ _switch_plan(str(_host_board.get("plan_id", "")))
+ _apply_host_weather_plan()
+ board_changed.emit()
+ changed.emit()
+
+
+func _generate_host_board(cycle: int) -> void:
+ _expire_incomplete_daily_jobs()
+ var random_suffix: String = Crypto.new().generate_random_bytes(12).hex_encode()
+ var plan_id: String = "daily:%d:%s" % [cycle, random_suffix]
+ var candidates: Array[FishDataType] = []
+ if _catalog != null:
+ for fish: FishDataType in _catalog.candidates:
+ if fish != null:
+ candidates.append(fish)
+ var schedule_anchor_index: int = _current_weather_segment()
+ var allow_weather_jobs: bool = (
+ JobCatalog.WEATHER_SEGMENT_COUNT - schedule_anchor_index >= 2
+ )
+ var jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs(
+ plan_id, candidates, allow_weather_jobs
+ )
+ _host_board = {
+ "plan_id": plan_id,
+ "cycle": cycle,
+ "schedule_anchor_index": schedule_anchor_index,
+ "jobs": jobs,
+ "weather_schedule": JobCatalog.generate_weather_schedule(
+ plan_id, jobs, schedule_anchor_index
+ ),
+ }
+ _active_board = _host_board.duplicate(true)
+ _switch_plan(plan_id)
+ _apply_host_weather_plan()
+ board_changed.emit()
+ changed.emit()
+
+
+func _switch_plan(plan_id: String) -> void:
+ if plan_id == _active_plan_id:
+ return
+ _active_plan_id = plan_id
+ _daily_progress.clear()
+ _daily_completions.clear()
+
+
+func _apply_host_weather_plan() -> void:
+ if (
+ _world_weather == null
+ or _world_time == null
+ or _session == null
+ or not _session.is_host()
+ or _host_board.is_empty()
+ ):
+ return
+ var schedule: Array = _host_board.get("weather_schedule", [])
+ _world_weather.configure_daily_plan(
+ str(_host_board.get("plan_id", "")), schedule, _world_time
+ )
+
+
+func _on_natural_time_advanced(hours: float) -> void:
+ if not is_finite(hours) or hours <= 0.0:
+ return
+ var previous_hour := _daily_clock_hour
+ _daily_clock_hour = fposmod(
+ _daily_clock_hour + hours,
+ WorldTimeService.HOURS_PER_DAY,
+ )
+ if (
+ _progression_ready
+ and _session != null
+ and _session.is_host()
+ and _crossed_daily_refresh(previous_hour, _daily_clock_hour)
+ ):
+ var cycle: int = int(_host_board.get("cycle", -1)) + 1
+ _generate_host_board(maxi(cycle, 0))
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if not _progression_ready:
+ return
+ if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]:
+ _activate_host_board()
+ elif state == NetworkSession.State.JOINED_CLIENT:
+ _active_board = {}
+ changed.emit()
+ elif state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ clear_remote_board()
+
+
+func _on_authoritative_catch(fish_catch: FishCatchType) -> void:
+ if not _progression_ready or fish_catch == null or not fish_catch.is_valid():
+ return
+ _total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE)
+ _update_daily_for_catch(fish_catch)
+ changed.emit()
+
+
+func _on_authoritative_sale_finished(
+ _request_id: String,
+ accepted: bool,
+ _message: String,
+ catch_ids: Array[StringName],
+ _payout: int,
+) -> void:
+ if not _progression_ready or not accepted or catch_ids.is_empty():
+ return
+ _total_sold = mini(_total_sold + catch_ids.size(), MAX_PROGRESS_VALUE)
+ for job: Dictionary in get_daily_jobs():
+ if int(job.get("kind", -1)) == JobCatalog.Kind.SELL_TOTAL:
+ _advance_daily(job, catch_ids.size())
+ changed.emit()
+
+
+func _update_daily_for_catch(fish_catch: FishCatchType) -> void:
+ for job: Dictionary in get_daily_jobs():
+ var matches: bool = false
+ match int(job.get("kind", -1)):
+ JobCatalog.Kind.CATCH_TOTAL:
+ matches = true
+ JobCatalog.Kind.CATCH_WATER:
+ matches = fish_catch.fish.is_allowed_in_water(
+ int(job.get("water_type", WaterType.Type.OTHER)) as WaterType.Type
+ )
+ JobCatalog.Kind.CATCH_QUALITY:
+ matches = fish_catch.quality >= int(job.get("minimum_quality", 0))
+ JobCatalog.Kind.CATCH_PHASE:
+ matches = int(_world_time.get_phase()) == int(job.get("phase", -1))
+ JobCatalog.Kind.CATCH_WEATHER:
+ matches = int(_world_weather.get_weather()) == int(job.get("weather", -1))
+ JobCatalog.Kind.CATCH_SPECIES:
+ matches = String(fish_catch.fish_id) == str(job.get("fish_id", ""))
+ if matches:
+ _advance_daily(job, 1)
+
+
+func _advance_daily(job: Dictionary, amount: int) -> void:
+ var job_id: String = str(job.get("id", ""))
+ if job_id.is_empty() or amount <= 0:
+ return
+ if int(_daily_completions.get(job_id, 0)) > 0:
+ return
+ var target: int = int(job.get("target", 1))
+ var progress: int = mini(
+ int(_daily_progress.get(job_id, 0)) + amount,
+ target,
+ )
+ if progress >= target and _pending_rewards.size() < MAX_PENDING_REWARDS:
+ _daily_completions[job_id] = 1
+ var reward: Dictionary = {
+ "claim_id": _daily_claim_id(_active_plan_id, job_id),
+ "source_plan_id": _active_plan_id,
+ "source_job_id": job_id,
+ "title": str(job.get("title", "daily job")),
+ "fish_coin": int(job.get("fish_coin", 0)),
+ "experience": int(job.get("experience", 0)),
+ }
+ _pending_rewards.append(reward)
+ status_changed.emit("job complete — payment ready on the net")
+ _daily_progress[job_id] = progress
+
+
+func _claim_pending_reward(index: int, reward: Dictionary) -> bool:
+ var state_snapshot: Dictionary = to_save_data()
+ var wallet_snapshot: int = _wallet.get_balance()
+ var experience_snapshot: int = _experience.get_total_experience()
+ _pending_rewards.remove_at(index)
+ if not _apply_reward(reward) or not _save_claim_transaction():
+ _wallet.restore_balance(wallet_snapshot)
+ _experience.restore_total_experience(experience_snapshot)
+ restore_from_save_data(state_snapshot)
+ return false
+ _reward_claimed(reward)
+ return true
+
+
+func _claim_lifetime_reward(job: Dictionary) -> bool:
+ var job_id: String = str(job.get("id", ""))
+ if job_id.is_empty() or job_id in _lifetime_claimed:
+ return false
+ var state_snapshot: Dictionary = to_save_data()
+ var wallet_snapshot: int = _wallet.get_balance()
+ var experience_snapshot: int = _experience.get_total_experience()
+ _lifetime_claimed.append(job_id)
+ if not _apply_reward(job) or not _save_claim_transaction():
+ _wallet.restore_balance(wallet_snapshot)
+ _experience.restore_total_experience(experience_snapshot)
+ restore_from_save_data(state_snapshot)
+ return false
+ _reward_claimed(job)
+ return true
+
+
+func _apply_reward(reward: Dictionary) -> bool:
+ var fish_coin: int = int(reward.get("fish_coin", 0))
+ var experience: int = int(reward.get("experience", 0))
+ if fish_coin > 0 and not _wallet.credit(fish_coin):
+ return false
+ if experience > 0 and not _experience.award_experience(experience):
+ return false
+ changed.emit()
+ return true
+
+
+func _save_claim_transaction() -> bool:
+ return _save_manager != null and _save_manager.save_if_dirty()
+
+
+func _reward_claimed(reward: Dictionary) -> void:
+ var title: String = str(reward.get("title", "job"))
+ var fish_coin: int = int(reward.get("fish_coin", 0))
+ var experience: int = int(reward.get("experience", 0))
+ reward_claimed.emit(title, fish_coin, experience)
+ status_changed.emit("payment received — %d xp" % experience)
+ changed.emit()
+
+
+func _lifetime_progress(job: Dictionary) -> int:
+ match int(job.get("kind", -1)):
+ JobCatalog.Kind.CATCH_TOTAL:
+ return _total_catches
+ JobCatalog.Kind.SELL_TOTAL:
+ return _total_sold
+ JobCatalog.Kind.REACH_LEVEL:
+ return _experience.get_level() if _experience != null else 0
+ JobCatalog.Kind.DISCOVER_SPECIES:
+ return _collection.get_discovered_ids().size() if _collection != null else 0
+ JobCatalog.Kind.MASTER_QUALITIES:
+ var mastered: int = 0
+ if _collection != null and _catalog != null:
+ for fish: FishDataType in _catalog.candidates:
+ if fish != null and _collection.has_mastered(fish.id):
+ mastered += 1
+ return mastered
+ return 0
+
+
+func _registered_species_count() -> int:
+ var count: int = 0
+ if _catalog != null:
+ for fish: FishDataType in _catalog.candidates:
+ if fish != null and fish.is_selectable():
+ count += 1
+ return count
+
+
+func _expire_incomplete_daily_jobs() -> void:
+ _daily_progress.clear()
+ _daily_completions.clear()
+
+
+func _on_current_state_changed() -> void:
+ if _progression_ready:
+ changed.emit()
+
+
+func _on_experience_changed(_total: int, _level: int) -> void:
+ if _progression_ready:
+ changed.emit()
+
+
+func _pending_for_job(
+ plan_id: String,
+ job_id: String,
+) -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for reward: Dictionary in _pending_rewards:
+ if (
+ str(reward.get("source_plan_id", "")) == plan_id
+ and str(reward.get("source_job_id", "")) == job_id
+ ):
+ result.append(reward)
+ return result
+
+
+func _matches_canonical_board(board: Dictionary) -> bool:
+ var plan_id: String = str(board.get("plan_id", ""))
+ var candidates: Array[FishDataType] = []
+ if _catalog != null:
+ for fish: FishDataType in _catalog.candidates:
+ if fish != null:
+ candidates.append(fish)
+ var anchor_index: int = int(board.get("schedule_anchor_index", -1))
+ var allow_weather_jobs: bool = (
+ JobCatalog.WEATHER_SEGMENT_COUNT - anchor_index >= 2
+ )
+ var jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs(
+ plan_id, candidates, allow_weather_jobs
+ )
+ var schedule: Array[Dictionary] = JobCatalog.generate_weather_schedule(
+ plan_id, jobs, anchor_index
+ )
+ return (
+ board.get("jobs", []) == jobs
+ and board.get("weather_schedule", []) == schedule
+ )
+
+
+func _current_weather_segment() -> int:
+ if _world_time == null:
+ return 0
+ var elapsed_hours: float = fposmod(
+ _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR,
+ WorldTimeService.HOURS_PER_DAY,
+ )
+ return clampi(
+ floori(elapsed_hours / JobCatalog.WEATHER_SEGMENT_HOURS),
+ 0,
+ JobCatalog.WEATHER_SEGMENT_COUNT - 1,
+ )
+
+
+static func _daily_claim_id(
+ plan_id: String,
+ job_id: String,
+) -> String:
+ return "%s/%s" % [plan_id, job_id]
+
+
+static func _crossed_daily_refresh(previous: float, current: float) -> bool:
+ if current >= previous:
+ return previous < DAILY_REFRESH_HOUR and current >= DAILY_REFRESH_HOUR
+ return (
+ previous < DAILY_REFRESH_HOUR
+ or current >= DAILY_REFRESH_HOUR
+ )
+
+
+static func _valid_string_array(values: Array, maximum_length: int) -> bool:
+ var seen: Dictionary[String, bool] = {}
+ for value: Variant in values:
+ if typeof(value) != TYPE_STRING:
+ return false
+ var text: String = str(value)
+ if text.is_empty() or text.length() > maximum_length or seen.has(text):
+ return false
+ seen[text] = true
+ return true
+
+
+static func _valid_reward(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var reward: Dictionary = value
+ return (
+ typeof(reward.get("claim_id")) == TYPE_STRING
+ and not str(reward.get("claim_id", "")).is_empty()
+ and str(reward.get("claim_id", "")).length() <= 196
+ and typeof(reward.get("source_plan_id")) == TYPE_STRING
+ and not str(reward.get("source_plan_id", "")).is_empty()
+ and str(reward.get("source_plan_id", "")).length() <= 96
+ and typeof(reward.get("source_job_id")) == TYPE_STRING
+ and not str(reward.get("source_job_id", "")).is_empty()
+ and str(reward.get("source_job_id", "")).length() <= 96
+ and typeof(reward.get("title")) == TYPE_STRING
+ and str(reward.get("title", "")).length() <= 96
+ and JobCatalog.is_bounded_integer(
+ reward.get("fish_coin"), 0, JobCatalog.MAX_JOB_REWARD_COINS
+ )
+ and JobCatalog.is_bounded_integer(
+ reward.get("experience"),
+ 0,
+ JobCatalog.MAX_JOB_REWARD_EXPERIENCE,
+ )
+ )
diff --git a/jobs/player_job_service.gd.uid b/jobs/player_job_service.gd.uid
new file mode 100644
index 0000000..110c6e2
--- /dev/null
+++ b/jobs/player_job_service.gd.uid
@@ -0,0 +1 @@
+uid://daqtje84gqgu0
diff --git a/main/main.gd b/main/main.gd
index 133bceb..e028509 100644
--- a/main/main.gd
+++ b/main/main.gd
@@ -16,25 +16,103 @@ const PlayerSettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
const PlayerSettingsType = preload("res://settings/player_settings.gd")
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
const TitleScreenType = preload("res://ui/title_screen.gd")
const PauseMenuType = preload("res://ui/pause_menu.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
+const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const ItemDataType = preload("res://items/item_data.gd")
const FishingShopType = preload("res://ui/fishing_shop.gd")
const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
+const UIPixelationPresenterType = preload(
+ "res://ui/ui_pixelation_presenter.gd"
+)
+const PixelationResetOverlayType = preload(
+ "res://ui/pixelation_reset_overlay.gd"
+)
+const WorldPixelationPostprocessType = preload(
+ "res://main/world_pixelation_postprocess.gd"
+)
+const NetworkSessionType = preload("res://network/network_session.gd")
+const DiscoveryClientType = preload("res://network/discovery_client.gd")
+const DedicatedServerConfigType = preload(
+ "res://server/dedicated_server_config.gd"
+)
+const NetworkProfilePreferencesType = preload(
+ "res://network/network_profile_preferences.gd"
+)
+const SavedServerStoreType = preload(
+ "res://network/saved_server_store.gd"
+)
+const PlayerSpawnServiceType = preload(
+ "res://network/player_spawn_service.gd"
+)
+const NetworkFishingServiceType = preload(
+ "res://network/network_fishing_service.gd"
+)
+const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
+const NetworkSaleServiceType = preload(
+ "res://network/network_sale_service.gd"
+)
+const NetworkShopServiceType = preload(
+ "res://network/network_shop_service.gd"
+)
+const NetworkItemUseServiceType = preload(
+ "res://network/network_item_use_service.gd"
+)
+const NetworkFishShowcaseServiceType = preload(
+ "res://network/network_fish_showcase_service.gd"
+)
+const NetworkSurfaceDrawingServiceType = preload(
+ "res://network/network_surface_drawing_service.gd"
+)
+const NetworkChatServiceType = preload(
+ "res://network/network_chat_service.gd"
+)
+const NetworkMailServiceType = preload(
+ "res://network/network_mail_service.gd"
+)
+const PlayerAssetReservationServiceType = preload(
+ "res://progression/player_asset_reservation_service.gd"
+)
+const PlayerAppearanceStoreType = preload(
+ "res://progression/player_appearance_store.gd"
+)
+const NetworkProfileServiceType = preload(
+ "res://network/network_profile_service.gd"
+)
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldTimeVisualControllerType = preload(
+ "res://world/world_time_visual_controller.gd"
+)
+const NetworkWorldTimeServiceType = preload(
+ "res://network/network_world_time_service.gd"
+)
+const WorldWeatherServiceType = preload("res://world/world_weather_service.gd")
+const NetworkWorldWeatherServiceType = preload(
+ "res://network/network_world_weather_service.gd"
+)
+const RainAmbienceType = preload("res://world/rain_ambience.gd")
+const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
+const NetworkJobServiceType = preload("res://network/network_job_service.gd")
const TITLE_MUSIC_SILENCE_DB: float = -80.0
+const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
+const PLAYER_MENU_PATTERN_SCALE: float = 0.85
+const PLAYER_MENU_PATTERN_SCROLL_VELOCITY := Vector2(-7.0, -5.0)
+const SHOP_PATTERN_SCALE: float = 1.75
@export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@export var main_shop_buyer_profile: FishBuyerProfileType
@export var item_catalog: ItemCatalogType
@export_category("Title Music")
-@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -14.0
-@export_range(0.0, 5.0, 0.05) var title_music_fade_in_seconds: float = 1.0
-@export_range(0.0, 5.0, 0.05) var title_music_fade_out_seconds: float = 0.75
+@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0
+@export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0
@onready var _test_world: TestWorldType = $TestWorld
@onready var _player: PlayerType = %Player
@@ -43,30 +121,383 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
@onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery
@onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager
@onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager
+@onready var _controller_mapping_manager: ControllerMappingManagerType = (
+ %ControllerMappingManager
+)
+@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
@onready var _title_music: AudioStreamPlayer = %TitleMusic
+@onready var _dusk_music: AudioStreamPlayer = %DuskMusic
+@onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation
+@onready var _pixelation_reset: PixelationResetOverlayType = (
+ %PixelationResetOverlay
+)
+@onready var _world_pixelation: WorldPixelationPostprocessType = (
+ %WorldPixelationPostprocess
+)
+@onready var _network_session: NetworkSessionType = %NetworkSession
+@onready var _discovery: DiscoveryClientType = %DiscoveryClient
+@onready var _world_time: WorldTimeServiceType = %WorldTimeService
+@onready var _world_time_visuals: WorldTimeVisualControllerType = (
+ %WorldTimeVisualController
+)
+@onready var _network_world_time: NetworkWorldTimeServiceType = (
+ %NetworkWorldTimeService
+)
+@onready var _world_weather: WorldWeatherServiceType = %WorldWeatherService
+@onready var _network_world_weather: NetworkWorldWeatherServiceType = (
+ %NetworkWorldWeatherService
+)
+@onready var _player_jobs: PlayerJobServiceType = %PlayerJobService
+@onready var _network_jobs: NetworkJobServiceType = %NetworkJobService
+@onready var _data_root: PlayerDataRoot = %PlayerDataRoot
+@onready var _identity_backups: IdentityBackupService = %IdentityBackupService
+@onready var _network_profile: NetworkProfilePreferencesType = (
+ %NetworkProfilePreferences
+)
+@onready var _player_identity: PlayerIdentityStore = %PlayerIdentityStore
+@onready var _host_identity: HostIdentityStore = %HostIdentityStore
+@onready var _known_players: KnownPlayerStore = %KnownPlayerStore
+@onready var _server_trust: ServerTrustStore = %ServerTrustStore
+@onready var _relationships: PlayerRelationshipStore = %PlayerRelationshipStore
+@onready var _host_bans: HostBanStore = %HostBanStore
+@onready var _saved_servers: SavedServerStoreType = %SavedServerStore
+@onready var _player_spawn_service: PlayerSpawnServiceType = (
+ %PlayerSpawnService
+)
+@onready var _network_fishing: NetworkFishingServiceType = (
+ %NetworkFishingService
+)
+@onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService
+@onready var _network_shop: NetworkShopServiceType = %NetworkShopService
+@onready var _network_item_use: NetworkItemUseServiceType = (
+ %NetworkItemUseService
+)
+@onready var _network_fish_showcase: NetworkFishShowcaseServiceType = (
+ %NetworkFishShowcaseService
+)
+@onready var _network_surface_drawing: NetworkSurfaceDrawingServiceType = (
+ %NetworkSurfaceDrawingService
+)
+@onready var _network_chat: NetworkChatServiceType = %NetworkChatService
+@onready var _network_mail: NetworkMailServiceType = %NetworkMailService
+@onready var _network_player_list: NetworkPlayerListService = %NetworkPlayerListService
+@onready var _asset_reservations: PlayerAssetReservationServiceType = (
+ %PlayerAssetReservationService
+)
+@onready var _appearance_store: PlayerAppearanceStoreType = %PlayerAppearanceStore
+@onready var _network_profile_service: NetworkProfileServiceType = (
+ %NetworkProfileService
+)
+@onready var _players_root: Node3D = $Players
+@onready var _surface_drawings_root: Node3D = $SurfaceDrawings
+@onready var _title_background: ColorRect = %TitleBackground
+@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop
+@onready var _shop_backdrop: ColorRect = %ShopBackdrop
var _gameplay_started: bool = false
var _shop_interaction: FishingShopInteractionType
var _title_music_tween: Tween
var _title_music_transition_generation: int = 0
var _title_music_requested: bool = false
+var _dusk_music_played_for_natural_day: bool = false
var _quit_in_progress: bool = false
+var _join_requested_from_title: bool = false
+var _join_requested_from_pause: bool = false
+var _player_menu_backdrop_tween: Tween
+var _shop_backdrop_tween: Tween
+var _pending_join_endpoint: String = ""
+var _server_trust_dialog: ConfirmationDialog
+var _pending_trust_changed: bool = false
+var _identity_notice_dialog: AcceptDialog
+var _data_setup_dialog: ConfirmationDialog
+var _data_setup_choose_button: Button
+var _data_setup_current_button: Button
+var _data_folder_dialog: FileDialog
+var _data_folder_picker_generation: int = 0
+var _restore_data_setup_after_picker: bool = false
+var _application_initialized := false
+var _pending_existing_root_path := ""
+var _local_recovery_attempt_id: String = ""
+var _dedicated_runtime: bool = false
+
+@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
+var _rain_ambience: RainAmbienceType
func _ready() -> void:
+ _dedicated_runtime = _is_dedicated_server_runtime()
+ if _dedicated_runtime:
+ call_deferred("_start_dedicated_server")
+ return
+ DisplayServer.window_set_title("NETfishing")
+ _rain_ambience = RainAmbienceType.new()
+ _rain_ambience.name = "RainAmbience"
+ add_child(_rain_ambience)
+ _rain_ambience.configure(_world_weather)
+ _shoreline_ambience.configure(
+ _player,
+ _test_world.get_saltwater_shoreline_mesh(),
+ )
+ var menu_pattern_material := (
+ _player_menu_backdrop.material as ShaderMaterial
+ )
+ if menu_pattern_material != null:
+ menu_pattern_material.set_shader_parameter(
+ "display_scale",
+ PLAYER_MENU_PATTERN_SCALE,
+ )
+ menu_pattern_material.set_shader_parameter(
+ "scroll_velocity_pixels",
+ PLAYER_MENU_PATTERN_SCROLL_VELOCITY,
+ )
+ var shop_pattern_material := _shop_backdrop.material as ShaderMaterial
+ if shop_pattern_material != null:
+ shop_pattern_material.set_shader_parameter(
+ "display_scale",
+ SHOP_PATTERN_SCALE,
+ )
+ shop_pattern_material.set_shader_parameter(
+ "scroll_velocity_pixels",
+ PLAYER_MENU_PATTERN_SCROLL_VELOCITY,
+ )
+ get_window().size_changed.connect(_resize_native_overlays)
+ _resize_native_overlays()
+ if not _settings_manager.settings_changed.is_connected(
+ _apply_runtime_settings
+ ):
+ _settings_manager.settings_changed.connect(_apply_runtime_settings)
+ _settings_manager.load_settings()
+ _interface_fonts.enforce_standard_font()
+ if _data_root.resolve():
+ _configure_portable_stores()
+ _initialize_after_data_root()
+ return
+ _show_data_root_setup()
+
+
+func _is_dedicated_server_runtime() -> bool:
+ if OS.has_feature("dedicated_server"):
+ return true
+ return "--dedicated-server" in OS.get_cmdline_user_args()
+
+
+func _start_dedicated_server() -> void:
+ var config: DedicatedServerConfigType = DedicatedServerConfigType.from_runtime()
+ if not config.is_valid():
+ _fail_dedicated_server(config.error_message)
+ return
+ var data_path: String = config.data_directory
+ var manifest_path: String = data_path.path_join(
+ PlayerDataRoot.MANIFEST_FILENAME
+ )
+ if not FileAccess.file_exists(manifest_path):
+ var created: Dictionary = _data_root.create_unbound_root(data_path)
+ if not bool(created.get("ok", false)):
+ _fail_dedicated_server(str(created.get(
+ "message", "Could not create the server data directory."
+ )))
+ return
+ if not _data_root.activate_process_root(data_path):
+ _fail_dedicated_server(_data_root.error_message)
+ return
+ var identity_directory: String = data_path.path_join("server")
+ if DirAccess.make_dir_recursive_absolute(identity_directory) != OK:
+ _fail_dedicated_server("Could not create the server identity directory.")
+ return
+ _host_identity.configure("host_identity", true, identity_directory)
+ if not _discovery.set_base_url_override(config.discovery_url):
+ _fail_dedicated_server("The discovery URL is invalid.")
+ return
+ if not _network_session.configure_dedicated_operators(
+ config.operator_fingerprints
+ ):
+ _fail_dedicated_server("The server operator list is invalid.")
+ return
+ _configure_portable_stores()
+ if not _discovery.configure_dedicated_runtime(config.server_name):
+ _fail_dedicated_server("The server room name is invalid.")
+ return
+ _initialize_application(true)
+ _player.set_local_control(false)
+ _player.visible = false
+ _player.collision_layer = 0
+ _player.collision_mask = 0
+ _player.set_physics_process(false)
+ if not _network_session.start_dedicated_host(
+ config.port,
+ config.max_players,
+ config.bind_address,
+ ):
+ _fail_dedicated_server("Could not start the dedicated server.")
+ return
+ _network_session.set_session_display_name(config.server_name)
+ if not _network_session.set_host_open(true):
+ _fail_dedicated_server("Could not open the dedicated server.")
+ return
+ _player_jobs.begin_progression_session()
+ if config.public_listing and not _discovery.set_discoverable(true):
+ _network_session.disconnect_session("Discovery setup failed.")
+ _fail_dedicated_server(_discovery.get_host_status_message())
+ return
+ print(
+ "NETfishing dedicated server ready: %s on %s:%d (%d players, %s)"
+ % [
+ config.server_name,
+ config.bind_address,
+ config.port,
+ config.max_players,
+ "public" if config.public_listing else "unlisted",
+ ]
+ )
+ print(
+ "Server identity: %s"
+ % _network_session.get_host_identity_fingerprint()
+ )
+
+
+func _fail_dedicated_server(message: String) -> void:
+ push_error("Dedicated server startup failed: %s" % message)
+ get_tree().quit(1)
+
+
+func _configure_portable_stores() -> void:
+ _save_manager.configure_storage(
+ _data_root.path_for(&"player_save"), _data_root
+ )
+ _network_profile.configure_storage(
+ _data_root.path_for(&"network_profile"), _data_root
+ )
+ _appearance_store.configure_storage(
+ _data_root.path_for(&"player_appearance"), _data_root
+ )
+ _saved_servers.configure_storage(
+ _data_root.path_for(&"saved_servers"), _data_root
+ )
+ _known_players.configure_storage(
+ _data_root.path_for(&"known_players"), _data_root
+ )
+ _relationships.configure_storage(
+ _data_root.path_for(&"player_relationships"), _data_root
+ )
+ _server_trust.configure_storage(
+ _data_root.path_for(&"server_trust"), _data_root
+ )
+ _host_bans.configure_storage(
+ _data_root.path_for(&"host_bans"), _data_root
+ )
+
+
+func _initialize_after_data_root() -> void:
+ _initialize_application(false)
+
+
+func _initialize_application(dedicated: bool) -> void:
+ if _application_initialized:
+ return
+ _application_initialized = true
_player.global_transform = _test_world.get_player_spawn_transform()
_player.velocity = Vector3.ZERO
+ _player_spawn_service.setup(
+ _players_root,
+ _player,
+ _test_world.get_player_spawn_transform()
+ )
+ _network_session.setup(
+ _network_profile,
+ _saved_servers,
+ _player_spawn_service,
+ _player_identity,
+ _host_identity,
+ _known_players,
+ _server_trust,
+ _host_bans,
+ dedicated,
+ )
+ _discovery.setup(_network_session)
+ if not dedicated:
+ _world_time_visuals.setup(
+ _world_time,
+ _test_world.get_world_environment(),
+ _test_world.get_sun(),
+ _world_weather,
+ _player,
+ Callable(_player, "get_active_gameplay_camera"),
+ )
+ if not _world_time.natural_time_advanced.is_connected(
+ _on_natural_time_advanced
+ ):
+ _world_time.natural_time_advanced.connect(
+ _on_natural_time_advanced
+ )
+ _network_world_time.setup(_network_session, _world_time)
+ _network_world_weather.setup(_network_session, _world_weather)
+ _player_jobs.setup(
+ _player.wallet,
+ _player.experience,
+ _player.collection_log,
+ fish_catalog,
+ _world_time,
+ _world_weather,
+ _network_session,
+ )
+ _network_jobs.setup(_network_session, _player_jobs)
+ _identity_backups.setup(_data_root, _player_identity, _host_identity)
+ _network_profile_service.setup(
+ _network_session,
+ _network_profile,
+ _appearance_store,
+ _player_spawn_service,
+ )
+ _network_player_list.setup(
+ _network_session,
+ _relationships,
+ _host_bans,
+ _known_players,
+ _player_spawn_service,
+ _network_chat,
+ _network_mail,
+ )
+ if not dedicated:
+ _network_session.set_local_appearance_snapshot(
+ _appearance_store.get_snapshot()
+ )
+ _network_session.join_authenticated.connect(
+ _on_network_join_authenticated
+ )
+ _network_session.connection_error.connect(
+ _on_network_connection_error
+ )
+ _network_session.server_trust_required.connect(
+ _on_server_trust_required
+ )
+ _network_session.server_lost.connect(_on_network_server_lost)
+ _network_session.peer_identity_observed.connect(
+ _on_peer_identity_observed
+ )
+ _network_session.remote_recovery_requested.connect(
+ _on_remote_recovery_requested
+ )
+ _network_session.remote_recovery_presentation_changed.connect(
+ _on_remote_recovery_presentation_changed
+ )
+
+
_player.fish_sale_service.setup(
_player.inventory,
- _player.wallet
+ _player.wallet,
+ _asset_reservations
)
_player.bag.setup(item_catalog)
- _player.hotbar.setup(_player.bag, item_catalog)
+ _player.hotbar.setup(_player.bag, item_catalog, _player.inventory)
_shop_interaction = _test_world.get_fishing_shop()
- _shop_interaction.setup_local_player(_player)
- _shop_interaction.local_player_range_changed.connect(
- _on_shop_range_changed
- )
+ if not dedicated:
+ _shop_interaction.setup_local_player(_player)
+ _shop_interaction.local_player_range_changed.connect(
+ _on_shop_range_changed
+ )
+ _game_ui.set_shop_npc_player_in_range(
+ _shop_interaction.is_local_player_in_range()
+ )
_save_manager.setup(
_player.inventory,
_player.collection_log,
@@ -76,24 +507,131 @@ func _ready() -> void:
_player.hotbar,
item_catalog,
_player.fishing_upgrades,
- _player.cooler_capacity
+ _player.cooler_capacity,
+ _player.art_unlocks,
+ _player.experience,
+ _world_time,
+ _world_weather,
+ _player_jobs,
)
+ _player_jobs.set_save_manager(_save_manager)
_save_manager.set_autosave_enabled(false)
+ _asset_reservations.setup(
+ _player.wallet, _player.inventory, _player.bag, item_catalog
+ )
+ _player.inventory.set_reservation_service(_asset_reservations)
+ _network_mail.setup(
+ _network_session,
+ _asset_reservations,
+ _player.wallet,
+ _player.inventory,
+ _player.bag,
+ _player.collection_log,
+ _player.cooler_capacity,
+ item_catalog,
+ fish_catalog,
+ _save_manager
+ )
+ _network_item_use.setup(
+ _network_session,
+ _player_spawn_service,
+ item_catalog,
+ _player.bag,
+ _player.item_effects,
+ _save_manager,
+ _asset_reservations
+ )
+ _network_fish_showcase.setup(
+ _network_session,
+ _player_spawn_service,
+ fish_catalog,
+ _player.inventory,
+ _player.hotbar,
+ )
+ _network_surface_drawing.setup(
+ _network_session,
+ _player_spawn_service,
+ _relationships,
+ _player,
+ _surface_drawings_root,
+ _player.bag,
+ _player.hotbar,
+ _player.art_unlocks,
+ )
+ _network_player_list.set_surface_drawing_service(
+ _network_surface_drawing
+ )
+ _network_chat.setup(_network_session, _world_time, _world_weather)
+ _network_fishing.setup(
+ _network_session,
+ _player_spawn_service,
+ _fishing_spot,
+ _player.inventory,
+ _player.bag,
+ _player.collection_log,
+ _player.cooler_capacity,
+ _player.experience,
+ _save_manager,
+ item_catalog,
+ fish_catalog,
+ _network_item_use
+ )
+ var sale_buyers: Array[FishBuyerProfileType] = [
+ pelican_buyer_profile,
+ main_shop_buyer_profile,
+ ]
+ _network_sale.setup(
+ _network_session,
+ _player_spawn_service,
+ _network_fishing,
+ _shop_interaction,
+ _player.inventory,
+ _player.wallet,
+ _player.fish_sale_service,
+ _save_manager,
+ fish_catalog,
+ sale_buyers,
+ _asset_reservations
+ )
+ _player_jobs.bind_authoritative_services(_network_fishing, _network_sale)
+ _network_shop.setup(
+ _network_session,
+ _player_spawn_service,
+ _network_fishing,
+ _shop_interaction,
+ _player.wallet,
+ _player.bag,
+ item_catalog,
+ _player.fishing_upgrades,
+ _player.cooler_capacity,
+ _player.art_unlocks,
+ _save_manager,
+ _asset_reservations
+ )
_fishing_spot.setup(
_player,
_player.inventory,
_player.collection_log,
+ _player.experience,
_player.bag,
_player.hotbar,
item_catalog,
_player.fishing_upgrades,
_player.item_effects,
- _player.cooler_capacity
+ _network_item_use,
+ _player.cooler_capacity,
+ _network_session,
+ _network_fishing,
+ _world_time,
+ _world_weather,
)
+ if dedicated:
+ return
_game_ui.setup(
_player,
_player.inventory,
_player.collection_log,
+ _player.experience,
_player.wallet,
_player.fish_sale_service,
pelican_buyer_profile,
@@ -106,8 +644,50 @@ func _ready() -> void:
_player.fishing_upgrades,
_shop_interaction,
_player.item_effects,
- _player.cooler_capacity
+ _player.cooler_capacity,
+ _network_session,
+ _network_sale,
+ _network_shop,
+ _network_chat,
+ _network_profile,
+ _player_spawn_service,
+ _network_mail,
+ _asset_reservations,
+ _network_profile_service,
+ _network_player_list,
+ _discovery,
+ _settings_manager,
+ _network_surface_drawing,
+ _player.art_unlocks,
+ _world_time,
+ _world_weather,
+ _player_jobs,
+ _test_world.get_world_environment(),
+ _test_world.get_sun(),
)
+ _game_ui.setup_data_and_identity(
+ _data_root,
+ _identity_backups,
+ _player_identity,
+ _host_identity,
+ _network_session,
+ _interface_fonts,
+ )
+ _game_ui.setup_controller_mapping(_controller_mapping_manager)
+ _data_root.conflict_detected.connect(_on_portable_conflict)
+ _data_root.status_changed.connect(_on_data_root_status)
+ if (
+ PortableFileGuard.has_syncthing_conflict(
+ _data_root.root_path.path_join("player")
+ )
+ or PortableFileGuard.has_syncthing_conflict(
+ _data_root.root_path.path_join("social")
+ )
+ ):
+ call_deferred(
+ "_on_data_root_status",
+ "Syncthing conflict copies were found. Review the data folder.",
+ )
_water_recovery.setup(
_player,
_fishing_spot,
@@ -116,24 +696,58 @@ func _ready() -> void:
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points()
)
- if not _settings_manager.settings_changed.is_connected(
- _apply_runtime_settings
- ):
- _settings_manager.settings_changed.connect(_apply_runtime_settings)
- _settings_manager.load_settings()
+ _ui_pixelation.effective_pixel_size_changed.connect(
+ _on_effective_ui_pixel_size_changed
+ )
+ _game_ui.pixelation_settings_visibility_changed.connect(
+ _pixelation_reset.set_settings_open
+ )
+ _game_ui.crisp_reset_focus_requested.connect(
+ _pixelation_reset.focus_reset_button
+ )
+ _game_ui.interactive_pointer_ui_changed.connect(
+ _ui_pixelation.set_interactive_ui_open
+ )
+ _game_ui.passive_pointer_ui_changed.connect(
+ _ui_pixelation.set_passive_pointer_ui_enabled
+ )
+ _game_ui.player_menu_backdrop_visibility_changed.connect(
+ _set_player_menu_backdrop_visible
+ )
+ _game_ui.shop_backdrop_visibility_changed.connect(
+ _set_shop_backdrop_visible
+ )
+ _pixelation_reset.return_to_settings_requested.connect(
+ _game_ui.focus_open_settings_back_button
+ )
+ _pixelation_reset.reset_requested.connect(_reset_pixelation)
_apply_runtime_settings(_settings_manager.current_settings)
_set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
- title_screen.gameplay_requested.connect(_on_gameplay_requested)
+ title_screen.new_game_requested.connect(_on_new_game_requested)
+ title_screen.continue_game_requested.connect(_on_continue_game_requested)
title_screen.quit_requested.connect(_on_quit_requested)
- title_screen.setup(_save_manager, _settings_manager)
+ title_screen.setup(
+ _save_manager,
+ _settings_manager,
+ _network_session,
+ _saved_servers,
+ _server_trust,
+ _discovery,
+ )
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
pause_menu.setup(
_player,
_save_manager,
_settings_manager,
- _fishing_spot
+ _fishing_spot,
+ _network_session,
+ _saved_servers,
+ _server_trust,
+ _discovery,
)
+ title_screen.join_game_requested.connect(_on_title_join_game_requested)
+ pause_menu.join_game_requested.connect(_on_pause_join_game_requested)
pause_menu.return_to_title_requested.connect(
_on_return_to_title_requested
)
@@ -144,28 +758,361 @@ func _ready() -> void:
pause_menu.menu_visibility_changed.connect(
_game_ui.set_system_menu_open
)
- _player.hotbar.selected_slot_changed.connect(
+ _player.hotbar.selected_assignment_changed.connect(
_on_active_hotbar_item_changed
)
+ _player.bag.contents_changed.connect(_refresh_active_hotbar_item)
_fishing_spot.ready_for_equipment_refresh.connect(
_refresh_active_hotbar_item
)
_water_recovery.recovery_starting.connect(
_on_water_recovery_starting
)
- _on_active_hotbar_item_changed(
- _player.hotbar.get_selected_slot(),
- _player.hotbar.get_selected_item_id()
+ _water_recovery.recovery_finished.connect(
+ _on_water_recovery_finished
)
- _show_title_music()
+ _water_recovery.local_respawn_completed.connect(
+ _on_local_respawn_completed
+ )
+ _refresh_active_hotbar_item()
+ _show_title_music(true)
+
+
+func _show_data_root_setup() -> void:
+ if _data_setup_dialog == null:
+ _data_setup_dialog = ConfirmationDialog.new()
+ _data_setup_dialog.title = "NETfishing player data"
+ _data_setup_dialog.ok_button_text = "Use This Folder"
+ _data_setup_dialog.cancel_button_text = "Quit"
+ _data_setup_dialog.confirmed.connect(_use_default_data_root)
+ _data_setup_dialog.canceled.connect(get_tree().quit)
+ _data_setup_choose_button = _data_setup_dialog.add_button(
+ "Choose Another Folder", false, "choose"
+ )
+ _data_setup_current_button = _data_setup_dialog.add_button(
+ "Keep Current Location", false, "current"
+ )
+ _data_setup_dialog.custom_action.connect(_on_data_setup_action)
+ _interface_fonts.apply_utility_theme(_data_setup_dialog)
+ add_child(_data_setup_dialog)
+ var default_path: String = _data_root.default_visible_path()
+ var legacy: bool = PortableDataMigration.legacy_files_present()
+ _data_setup_dialog.dialog_text = (
+ ("Move your existing NETfishing data to an easy-to-find folder."
+ if legacy else "Choose where NETfishing stores your player data.")
+ + "\n\n%s\n\nYou can choose a Syncthing folder."
+ % (default_path if not default_path.is_empty() else "Choose a folder")
+ )
+ _data_setup_dialog.ok_button_text = (
+ (
+ "Move to Documents/NETFISHING"
+ if legacy else "Use This Folder"
+ )
+ if not default_path.is_empty()
+ else "Keep Current Location"
+ )
+ _data_setup_current_button.visible = not default_path.is_empty()
+ if not _data_root.error_message.is_empty():
+ _data_setup_dialog.dialog_text = (
+ "Your NETfishing data folder is unavailable.\n\n%s"
+ % _data_root.error_message
+ )
+ _data_setup_dialog.popup_centered(Vector2i(640, 360))
+ _focus_data_setup_actions.call_deferred()
+
+
+func _focus_data_setup_actions() -> void:
+ if _data_setup_dialog == null or not _data_setup_dialog.visible:
+ return
+ var actions: Array[Button] = [
+ _data_setup_dialog.get_ok_button(),
+ _data_setup_choose_button,
+ ]
+ if _data_setup_current_button.visible:
+ actions.append(_data_setup_current_button)
+ actions.append(_data_setup_dialog.get_cancel_button())
+ for index: int in actions.size():
+ var action: Button = actions[index]
+ var previous: Button = actions[posmod(index - 1, actions.size())]
+ var next: Button = actions[(index + 1) % actions.size()]
+ action.focus_mode = Control.FOCUS_ALL
+ action.focus_neighbor_left = action.get_path_to(previous)
+ action.focus_neighbor_top = action.focus_neighbor_left
+ action.focus_neighbor_right = action.get_path_to(next)
+ action.focus_neighbor_bottom = action.focus_neighbor_right
+ _data_setup_dialog.get_ok_button().grab_focus()
+
+
+func _use_default_data_root() -> void:
+ var path: String = _data_root.default_visible_path()
+ if path.is_empty():
+ _activate_selected_data_path(
+ ProjectSettings.globalize_path(
+ PlayerDataRoot.APP_DATA_PORTABLE_PATH
+ ),
+ true,
+ )
+ return
+ _activate_selected_data_path(path)
+
+
+func _on_data_setup_action(action: StringName) -> void:
+ if action == &"choose":
+ _show_folder_picker()
+ elif action == &"current":
+ _activate_selected_data_path(
+ ProjectSettings.globalize_path(PlayerDataRoot.APP_DATA_PORTABLE_PATH), true
+ )
+
+
+func _show_folder_picker() -> void:
+ if _data_folder_dialog == null:
+ _data_folder_dialog = FileDialog.new()
+ _data_folder_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
+ _data_folder_dialog.access = FileDialog.ACCESS_FILESYSTEM
+ _data_folder_dialog.use_native_dialog = false
+ _data_folder_dialog.dir_selected.connect(_activate_selected_data_path)
+ _data_folder_dialog.canceled.connect(_on_data_folder_picker_canceled)
+ _interface_fonts.apply_utility_theme(_data_folder_dialog)
+ add_child(_data_folder_dialog)
+ if _data_folder_dialog.visible:
+ return
+ _data_folder_picker_generation += 1
+ var generation: int = _data_folder_picker_generation
+ _restore_data_setup_after_picker = (
+ _data_setup_dialog != null and _data_setup_dialog.visible
+ )
+ if _data_setup_dialog != null:
+ _data_setup_dialog.hide()
+ _open_folder_picker_after_modal.call_deferred(generation)
+
+
+func _open_folder_picker_after_modal(generation: int) -> void:
+ await get_tree().process_frame
+ if (
+ generation != _data_folder_picker_generation
+ or _data_folder_dialog == null
+ or _data_folder_dialog.visible
+ ):
+ return
+ _data_folder_dialog.current_dir = (
+ _data_root.default_visible_path().get_base_dir()
+ if not _data_root.default_visible_path().is_empty()
+ else OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
+ )
+ _interface_fonts.popup_file_dialog(_data_folder_dialog)
+
+
+func _on_data_folder_picker_canceled() -> void:
+ _data_folder_picker_generation += 1
+ var generation: int = _data_folder_picker_generation
+ var should_restore_setup: bool = _restore_data_setup_after_picker
+ _restore_data_setup_after_picker = false
+ if _data_folder_dialog != null:
+ _data_folder_dialog.hide()
+ if should_restore_setup:
+ _restore_data_setup_after_picker_after_modal.call_deferred(generation)
+
+
+func _restore_data_setup_after_picker_after_modal(generation: int) -> void:
+ await get_tree().process_frame
+ if generation != _data_folder_picker_generation:
+ return
+ _show_data_root_setup()
+
+
+func _activate_selected_data_path(path: String, app_data: bool = false) -> void:
+ _restore_data_setup_after_picker = false
+ var ok: bool = false
+ if PortableDataMigration.legacy_files_present():
+ var result: Dictionary = (
+ PortableDataMigration.adopt_legacy_app_data(_data_root)
+ if app_data
+ else PortableDataMigration.migrate_legacy_to(_data_root, path)
+ )
+ ok = bool(result.get("ok", false))
+ if not ok:
+ if bool(result.get("requires_existing_root_decision", false)):
+ _show_existing_root_choice(path)
+ return
+ _show_data_error(str(result.get("message", "Migration failed.")))
+ return
+ else:
+ ok = _data_root.select_new_root(path, app_data)
+ if not ok:
+ _show_data_error(_data_root.error_message)
+ return
+ _data_setup_dialog.hide()
+ _configure_portable_stores()
+ _initialize_after_data_root()
+
+
+func _show_existing_root_choice(path: String) -> void:
+ _pending_existing_root_path = path
+ var dialog: ConfirmationDialog = ConfirmationDialog.new()
+ dialog.title = "Existing NETfishing data"
+ dialog.ok_button_text = "Use Data Already in Selected Folder"
+ dialog.cancel_button_text = "Cancel"
+ dialog.dialog_text = (
+ "This folder already contains NETfishing player data.\n\n"
+ + "Choose which complete data set to use. Data is never merged automatically."
+ )
+ dialog.add_button(
+ "Replace with This Device's Data", false, "replace"
+ )
+ dialog.confirmed.connect(func() -> void:
+ if _data_root.use_existing_root(_pending_existing_root_path):
+ dialog.queue_free()
+ _data_setup_dialog.hide()
+ _configure_portable_stores()
+ _initialize_after_data_root()
+ else:
+ _show_data_error(_data_root.error_message)
+ )
+ dialog.custom_action.connect(func(action: StringName) -> void:
+ if action != &"replace":
+ return
+ var result: Dictionary = PortableDataMigration.replace_existing_with_legacy(
+ _data_root, _pending_existing_root_path
+ )
+ if bool(result.get("ok", false)):
+ dialog.queue_free()
+ _data_setup_dialog.hide()
+ _configure_portable_stores()
+ _initialize_after_data_root()
+ else:
+ _show_data_error(str(result.get("message", "Migration failed.")))
+ )
+ _interface_fonts.apply_utility_theme(dialog)
+ add_child(dialog)
+ dialog.popup_centered(Vector2i(680, 360))
+
+
+func _show_data_error(message: String) -> void:
+ if _data_setup_dialog != null:
+ _data_setup_dialog.dialog_text = message
+ _data_setup_dialog.popup_centered(Vector2i(640, 300))
+
+
+func _on_portable_conflict(message: String, _path: String) -> void:
+ var dialog: AcceptDialog = AcceptDialog.new()
+ _interface_fonts.apply_utility_theme(dialog)
+ dialog.title = "Player data conflict"
+ dialog.dialog_text = (
+ message
+ + "\n\nDo not play the same profile on two devices at the same time."
+ )
+ dialog.add_button("Open Data Folder", false, "open")
+ dialog.custom_action.connect(func(action: StringName) -> void:
+ if action == &"open":
+ _data_root.open_folder()
+ )
+ dialog.confirmed.connect(dialog.queue_free)
+ _game_ui.add_child(dialog)
+ dialog.popup_centered(Vector2i(560, 300))
+
+
+func _on_data_root_status(message: String) -> void:
+ if "Syncthing conflict" not in message:
+ return
+ var dialog: AcceptDialog = AcceptDialog.new()
+ _interface_fonts.apply_utility_theme(dialog)
+ dialog.title = "Synced data needs review"
+ dialog.dialog_text = message
+ dialog.add_button("Open Data Folder", false, "open")
+ dialog.custom_action.connect(func(action: StringName) -> void:
+ if action == &"open":
+ _data_root.open_folder()
+ )
+ dialog.confirmed.connect(dialog.queue_free)
+ _game_ui.add_child(dialog)
+ dialog.popup_centered(Vector2i(560, 260))
+
+
+func _on_server_trust_required(
+ endpoint: String,
+ expected_fingerprint: String,
+ received_fingerprint: String,
+ is_changed: bool,
+) -> void:
+ if _server_trust_dialog == null:
+ _server_trust_dialog = ConfirmationDialog.new()
+ _server_trust_dialog.title = "Server identity"
+ _server_trust_dialog.ok_button_text = "Trust & Connect"
+ _server_trust_dialog.cancel_button_text = "Cancel"
+ _server_trust_dialog.confirmed.connect(_confirm_server_trust)
+ _server_trust_dialog.canceled.connect(
+ _network_session.resolve_server_trust.bind(false)
+ )
+ _game_ui.add_child(_server_trust_dialog)
+ _pending_trust_changed = is_changed
+ if is_changed:
+ _server_trust_dialog.dialog_text = (
+ "Server identity changed.\n\n"
+ + "This may mean the server was reinstalled, moved, or is being "
+ + "impersonated.\n\nExpected:\n%s\n\nReceived:\n%s"
+ % [
+ NetworkIdentityCrypto.format_fingerprint(expected_fingerprint),
+ NetworkIdentityCrypto.format_fingerprint(received_fingerprint),
+ ]
+ )
+ _server_trust_dialog.ok_button_text = "Trust New Identity"
+ else:
+ _server_trust_dialog.dialog_text = (
+ "First time connecting to this server.\n\n%s\n\nHost identity:\n%s"
+ % [
+ endpoint,
+ NetworkIdentityCrypto.format_fingerprint(received_fingerprint),
+ ]
+ )
+ _server_trust_dialog.ok_button_text = "Trust & Connect"
+ _server_trust_dialog.popup_centered(Vector2i(560, 360))
+
+
+func _confirm_server_trust() -> void:
+ if _pending_trust_changed:
+ _pending_trust_changed = false
+ _server_trust_dialog.dialog_text = (
+ "Replace the saved server identity pin?\n\n"
+ + "Only continue if you expected this server identity to change."
+ )
+ _server_trust_dialog.ok_button_text = "Replace Pin"
+ _server_trust_dialog.popup_centered(Vector2i(520, 260))
+ return
+ if _server_trust_dialog != null:
+ _server_trust_dialog.hide()
+ _network_session.resolve_server_trust(true)
+
+
+func _on_peer_identity_observed(_peer_id: int, status: String) -> void:
+ if status != "New identity using a familiar name":
+ return
+ if _identity_notice_dialog == null:
+ _identity_notice_dialog = AcceptDialog.new()
+ _identity_notice_dialog.title = "Player identity"
+ _game_ui.add_child(_identity_notice_dialog)
+ _identity_notice_dialog.dialog_text = (
+ "New identity using a familiar name.\n\n"
+ + "This may be a different player using the same display name."
+ )
+ _identity_notice_dialog.popup_centered(Vector2i(480, 240))
func _input(event: InputEvent) -> void:
+ if _handle_data_root_controller_input(event):
+ get_viewport().set_input_as_handled()
+ return
+ if _game_ui.is_controller_mapping_capturing():
+ return
if (
- not event.is_action_pressed("ui_cancel")
+ not (
+ event.is_action_pressed("ui_cancel")
+ or event.is_action_pressed("open_system_menu")
+ )
or (event is InputEventKey and event.echo)
):
return
+ var pause_open_requested: bool = _is_pause_open_request(event)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
if title_screen.visible or not _gameplay_started:
return
@@ -184,7 +1131,8 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
return
if (
- not _water_recovery.is_recovery_active()
+ pause_open_requested
+ and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_system_menu()
):
_game_ui.close_player_menu_for_game_menu()
@@ -192,6 +1140,71 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
+func _handle_data_root_controller_input(event: InputEvent) -> bool:
+ var button_event := event as InputEventJoypadButton
+ if button_event == null or not button_event.pressed:
+ return false
+ var setup_visible: bool = (
+ _data_setup_dialog != null and _data_setup_dialog.visible
+ )
+ var picker_visible: bool = (
+ _data_folder_dialog != null and _data_folder_dialog.visible
+ )
+ if not setup_visible and not picker_visible:
+ return false
+ var use_mapping: bool = _controller_mapping_manager != null
+ var accept_pressed: bool = (
+ _controller_mapping_manager.event_matches_role(
+ event,
+ ControllerMappingManagerType.ROLE_A,
+ )
+ if use_mapping
+ else (
+ button_event.button_index == JOY_BUTTON_A
+ or event.is_action_pressed("ui_accept")
+ )
+ )
+ var cancel_pressed: bool = (
+ _controller_mapping_manager.event_matches_role(
+ event,
+ ControllerMappingManagerType.ROLE_B,
+ )
+ if use_mapping
+ else (
+ button_event.button_index == JOY_BUTTON_B
+ or event.is_action_pressed("ui_cancel")
+ )
+ )
+ if cancel_pressed:
+ if picker_visible:
+ _on_data_folder_picker_canceled()
+ else:
+ _data_setup_dialog.get_cancel_button().pressed.emit()
+ return true
+ if not accept_pressed:
+ return false
+ var focused: Control = (
+ _data_folder_dialog.gui_get_focus_owner()
+ if picker_visible
+ else _data_setup_dialog.gui_get_focus_owner()
+ )
+ var focused_button := focused as BaseButton
+ if focused_button != null and not focused_button.disabled:
+ focused_button.pressed.emit()
+ return true
+ if setup_visible:
+ _data_setup_dialog.get_ok_button().pressed.emit()
+ return true
+ return false
+
+
+func _is_pause_open_request(event: InputEvent) -> bool:
+ return (
+ event.is_action_pressed("open_system_menu")
+ or event is InputEventKey
+ )
+
+
func _unhandled_input(event: InputEvent) -> void:
if (
not _gameplay_started
@@ -207,15 +1220,44 @@ func _unhandled_input(event: InputEvent) -> void:
):
_game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled()
+ elif event is InputEventJoypadButton and _player != null:
+ _player.hotbar.clear_slot(_player.hotbar.get_selected_slot())
+ get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
- _game_ui.set_shop_prompt_visible(_can_show_shop_prompt())
+ if _dedicated_runtime:
+ return
+ var show_shop_prompt := _can_show_shop_prompt()
+ var shop_prompt_anchor := (
+ _shop_interaction.get_prompt_anchor_position()
+ if _shop_interaction != null
+ else Vector3.ZERO
+ )
+ _game_ui.set_shop_prompt_visible(
+ show_shop_prompt,
+ shop_prompt_anchor,
+ )
func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
if settings == null:
return
+ var requested_window_mode: DisplayServer.WindowMode = (
+ DisplayServer.WINDOW_MODE_FULLSCREEN
+ if settings.fullscreen_enabled
+ else DisplayServer.WINDOW_MODE_WINDOWED
+ )
+ if DisplayServer.window_get_mode() != requested_window_mode:
+ DisplayServer.window_set_mode(requested_window_mode)
+ _apply_world_pixelation(settings.world_pixel_size)
+ _ui_pixelation.set_pixel_size(settings.ui_pixel_size)
+ _ui_pixelation.set_on_screen_keyboard_enabled(
+ settings.on_screen_keyboard_enabled
+ )
+ _game_ui.get_title_screen().set_world_pixelation(
+ settings.world_pixel_size
+ )
_player.apply_camera_settings(
settings.mouse_camera_sensitivity,
settings.controller_camera_sensitivity,
@@ -225,23 +1267,250 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
settings.auto_click_enabled,
settings.auto_click_interval
)
+ _game_ui.set_edge_docks(
+ settings.chat_dock_right,
+ settings.paint_dock_right,
+ settings.chat_mobile_mode,
+ )
+
+
+func _apply_world_pixelation(pixel_size: int) -> void:
+ var root_viewport: Viewport = get_viewport()
+ root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST
+ root_viewport.scaling_3d_scale = (
+ 0.75
+ if OS.get_environment("NETFISHING_LOW_END") == "1"
+ else 1.0
+ )
+ root_viewport.msaa_3d = Viewport.MSAA_DISABLED
+ root_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
+ root_viewport.use_taa = false
+ _world_pixelation.set_pixel_size(pixel_size)
+
+
+func _on_effective_ui_pixel_size_changed(
+ _requested_pixel_size: int,
+ effective_pixel_size: int,
+) -> void:
+ _game_ui.set_effective_ui_pixel_size(effective_pixel_size)
+
+
+func _reset_pixelation() -> void:
+ if _settings_manager.apply_pixelation(
+ PlayerSettingsType.DEFAULT_WORLD_PIXEL_SIZE,
+ PlayerSettingsType.DEFAULT_UI_PIXEL_SIZE
+ ):
+ _game_ui.refresh_open_settings_panel()
func _set_gameplay_active(active: bool) -> void:
+ var was_gameplay_started: bool = _gameplay_started
_gameplay_started = active
+ _shoreline_ambience.set_active(active)
+ _rain_ambience.set_active(active)
+ if active and not was_gameplay_started:
+ var current_hour: float = _world_time.get_time_hours()
+ _dusk_music_played_for_natural_day = (
+ current_hour >= WorldTimeServiceType.DUSK_START_HOUR
+ or current_hour < WorldTimeServiceType.DAWN_START_HOUR
+ )
+ elif not active:
+ _dusk_music.stop()
+ _title_background.visible = not active
+ _world_pixelation.set_gameplay_active(active)
+ _ui_pixelation.set_gameplay_active(active)
if not active:
_player.item_effects.reset_all()
_player.set_movement_enabled(active)
_player.set_camera_input_enabled(active)
+ _player.set_camera_active(active)
_fishing_spot.set_gameplay_input_enabled(active)
_water_recovery.set_recovery_enabled(active)
_game_ui.set_gameplay_ui_enabled(active)
_save_manager.set_autosave_enabled(active)
+ if active:
+ _player_jobs.begin_progression_session()
+ _refresh_active_hotbar_item()
+ else:
+ _player_jobs.end_progression_session()
+ _set_player_menu_backdrop_visible(false)
+ _set_shop_backdrop_visible(false)
-func _on_gameplay_requested() -> void:
+func _on_natural_time_advanced(advanced_hours: float) -> void:
+ if not _gameplay_started or advanced_hours <= 0.0:
+ return
+ var current_hour: float = _world_time.get_time_hours()
+ var previous_hour: float = fposmod(
+ current_hour - advanced_hours,
+ WorldTimeServiceType.HOURS_PER_DAY,
+ )
+ if _natural_interval_crosses_hour(
+ previous_hour,
+ advanced_hours,
+ WorldTimeServiceType.DAWN_START_HOUR,
+ ):
+ _dusk_music_played_for_natural_day = false
+ if (
+ not _dusk_music_played_for_natural_day
+ and _natural_interval_crosses_hour(
+ previous_hour,
+ advanced_hours,
+ WorldTimeServiceType.DUSK_START_HOUR,
+ )
+ ):
+ _dusk_music_played_for_natural_day = true
+ _dusk_music.play(0.0)
+
+
+static func _natural_interval_crosses_hour(
+ previous_hour: float,
+ advanced_hours: float,
+ target_hour: float,
+) -> bool:
+ if advanced_hours >= WorldTimeServiceType.HOURS_PER_DAY:
+ return true
+ var distance_to_target: float = fposmod(
+ target_hour - previous_hour,
+ WorldTimeServiceType.HOURS_PER_DAY,
+ )
+ return (
+ distance_to_target > 0.0
+ and distance_to_target
+ <= advanced_hours + TIME_CROSSING_EPSILON_HOURS
+ )
+
+
+func _set_player_menu_backdrop_visible(requested_visible: bool) -> void:
+ if _player_menu_backdrop_tween != null:
+ _player_menu_backdrop_tween.kill()
+ _player_menu_backdrop_tween = null
+ var should_show: bool = requested_visible and _gameplay_started
+ if should_show:
+ var was_visible: bool = _player_menu_backdrop.visible
+ _player_menu_backdrop.visible = true
+ if not was_visible:
+ _player_menu_backdrop.modulate.a = 0.0
+ _player_menu_backdrop_tween = create_tween()
+ _player_menu_backdrop_tween.tween_property(
+ _player_menu_backdrop,
+ "modulate:a",
+ 1.0,
+ UIMotion.PLAYER_MENU_ENTER_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ return
+ if not _player_menu_backdrop.visible:
+ return
+ _player_menu_backdrop_tween = create_tween()
+ _player_menu_backdrop_tween.tween_property(
+ _player_menu_backdrop,
+ "modulate:a",
+ 0.0,
+ UIMotion.PLAYER_MENU_EXIT_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ _player_menu_backdrop_tween.finished.connect(
+ func() -> void:
+ _player_menu_backdrop.visible = false
+ _player_menu_backdrop.modulate.a = 1.0
+ _player_menu_backdrop_tween = null
+ )
+
+
+func _set_shop_backdrop_visible(requested_visible: bool) -> void:
+ if _shop_backdrop_tween != null:
+ _shop_backdrop_tween.kill()
+ _shop_backdrop_tween = null
+ var should_show: bool = requested_visible and _gameplay_started
+ if should_show:
+ var was_visible: bool = _shop_backdrop.visible
+ _shop_backdrop.visible = true
+ if not was_visible:
+ _shop_backdrop.modulate.a = 0.0
+ _shop_backdrop_tween = create_tween()
+ _shop_backdrop_tween.tween_property(
+ _shop_backdrop,
+ "modulate:a",
+ 1.0,
+ UIMotion.PLAYER_MENU_ENTER_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ return
+ if not _shop_backdrop.visible:
+ return
+ _shop_backdrop_tween = create_tween()
+ _shop_backdrop_tween.tween_property(
+ _shop_backdrop,
+ "modulate:a",
+ 0.0,
+ UIMotion.PLAYER_MENU_EXIT_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ _shop_backdrop_tween.finished.connect(
+ func() -> void:
+ _shop_backdrop.visible = false
+ _shop_backdrop.modulate.a = 1.0
+ _shop_backdrop_tween = null
+ )
+
+
+func _resize_native_overlays() -> void:
+ if not is_node_ready():
+ return
+ _player_menu_backdrop.position = Vector2.ZERO
+ _player_menu_backdrop.size = Vector2(get_window().size)
+ _shop_backdrop.position = Vector2.ZERO
+ _shop_backdrop.size = Vector2(get_window().size)
+
+
+func _on_new_game_requested() -> void:
if _gameplay_started or _quit_in_progress:
return
+ if not _prepare_private_host():
+ return
+ if (
+ not _save_manager.delete_progression_save()
+ or not _save_manager.initialize_new_game()
+ ):
+ _network_session.disconnect_session("New Game setup failed.")
+ _game_ui.get_title_screen().report_network_error(
+ "Could not initialize local progression. Existing data was preserved where possible."
+ )
+ return
+ _enter_gameplay()
+
+
+func _on_continue_game_requested() -> void:
+ if _gameplay_started or _quit_in_progress:
+ return
+ if not _prepare_private_host():
+ return
+ if not _save_manager.load_player_data():
+ _network_session.disconnect_session("Continue failed.")
+ _game_ui.get_title_screen().report_network_error(
+ "Failed to load save. The original was preserved."
+ )
+ return
+ _enter_gameplay()
+
+
+func _prepare_private_host() -> bool:
+ _join_requested_from_title = false
+ _join_requested_from_pause = false
+ if _network_session.state in [
+ NetworkSessionType.State.CONNECTION_FAILED,
+ NetworkSessionType.State.SERVER_LOST,
+ ]:
+ _network_session.reset_failure()
+ if not _network_session.start_private_host(
+ NetworkSessionType.DEFAULT_PORT,
+ NetworkSessionType.DEFAULT_PRIVATE_HOST_PORT_ATTEMPTS,
+ ):
+ _game_ui.get_title_screen().report_network_error(
+ "Could not start the private multiplayer session."
+ )
+ return false
+ return true
+
+
+func _enter_gameplay() -> void:
_fade_out_title_music()
_game_ui.get_title_screen().hide()
_set_gameplay_active(true)
@@ -251,10 +1520,183 @@ func _on_return_to_title_requested() -> void:
if _quit_in_progress:
return
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
+ if not _save_manager.save_world_time_checkpoint():
+ pause_menu.report_network_error(
+ "Could not save progression before returning to title."
+ )
+ return
pause_menu.close_for_title_transition()
+ _network_session.disconnect_session("Returned to title.")
_set_gameplay_active(false)
_game_ui.get_title_screen().reopen()
- _show_title_music()
+ _show_title_music(true)
+
+
+func _on_title_join_game_requested(endpoint: String) -> void:
+ if _quit_in_progress or _gameplay_started:
+ return
+ if _network_session.state != NetworkSessionType.State.INACTIVE:
+ _network_session.disconnect_session("Preparing direct connection.")
+ _join_requested_from_title = true
+ _join_requested_from_pause = false
+ _pending_join_endpoint = endpoint
+ if not _network_session.join_direct(endpoint):
+ _join_requested_from_title = false
+
+
+func _on_pause_join_game_requested(endpoint: String) -> void:
+ if _quit_in_progress or not _gameplay_started:
+ return
+ if not _save_manager.save_world_time_checkpoint():
+ _game_ui.get_pause_menu().report_network_error(
+ "Could not save progression before leaving this session."
+ )
+ return
+ _join_requested_from_pause = true
+ _join_requested_from_title = false
+ _pending_join_endpoint = endpoint
+ _game_ui.get_pause_menu().close_for_title_transition()
+ var preserved_public_join: bool = (
+ _discovery.preserve_public_join_for_session_switch()
+ )
+ _network_session.disconnect_session("Connecting to another game.")
+ if not _network_session.join_direct(endpoint):
+ if preserved_public_join:
+ _discovery.cancel_pending_public_join()
+ _handle_failed_session_switch(
+ "Could not begin the direct connection."
+ )
+
+
+func _on_network_join_authenticated() -> void:
+ var connected_endpoint: ConnectionEndpoint = (
+ _network_session.get_current_endpoint()
+ )
+ var server_metadata: Dictionary = (
+ _network_session.get_last_server_metadata()
+ )
+ if connected_endpoint != null:
+ _saved_servers.record_successful_connection(
+ connected_endpoint,
+ int(server_metadata.get("max_players", 0)),
+ str(server_metadata.get("server_display_name", "")),
+ int(server_metadata.get("protocol_version", 0)),
+ int(server_metadata.get("player_count", 0)),
+ )
+ if _join_requested_from_title:
+ var inspection = _save_manager.inspect_save()
+ var progression_ready: bool = (
+ _save_manager.load_player_data()
+ if inspection.can_continue()
+ else _save_manager.initialize_new_game()
+ )
+ if not progression_ready:
+ _network_session.disconnect_session(
+ "Local progression could not be prepared."
+ )
+ _game_ui.get_title_screen().report_network_error(
+ "Your local progression could not be prepared."
+ )
+ _join_requested_from_title = false
+ return
+ _fade_out_title_music()
+ _game_ui.get_title_screen().hide()
+ _set_gameplay_active(true)
+ elif _join_requested_from_pause:
+ _set_gameplay_active(true)
+ _join_requested_from_title = false
+ _join_requested_from_pause = false
+ _pending_join_endpoint = ""
+
+
+func _on_network_connection_error(message: String) -> void:
+ var failed_endpoint: ConnectionEndpoint = (
+ _network_session.get_current_endpoint()
+ )
+ if failed_endpoint != null:
+ _saved_servers.record_connection_failure(
+ failed_endpoint,
+ _connection_result_code(message),
+ )
+ if _join_requested_from_title:
+ _game_ui.get_title_screen().report_network_error(message)
+ elif _join_requested_from_pause:
+ _handle_failed_session_switch(message)
+
+
+func _connection_result_code(message: String) -> String:
+ var lowered: String = message.to_lower()
+ if lowered.contains("protocol"):
+ return "PROTOCOL_MISMATCH"
+ if lowered.contains("full"):
+ return "SERVER_FULL"
+ if lowered.contains("cancel"):
+ return "CANCELLED"
+ if lowered.contains("timed out"):
+ return "TIMEOUT"
+ return "UNAVAILABLE"
+
+
+func _handle_failed_session_switch(message: String) -> void:
+ _join_requested_from_pause = false
+ _set_gameplay_active(false)
+ var title_screen: TitleScreenType = _game_ui.get_title_screen()
+ title_screen.reopen()
+ title_screen.open_join_game_page(_pending_join_endpoint)
+ title_screen.report_network_error(message)
+ _show_title_music(true)
+
+
+func _on_network_server_lost(message: String) -> void:
+ if not _gameplay_started:
+ return
+ _join_requested_from_title = false
+ _join_requested_from_pause = false
+ _pending_join_endpoint = ""
+ _set_gameplay_active(false)
+ var title_screen: TitleScreenType = _game_ui.get_title_screen()
+ title_screen.reopen_to_menu()
+ title_screen.report_network_error(message)
+ _show_title_music(true)
+
+
+func _on_local_respawn_completed(entry_position: Vector3) -> void:
+ if _network_session.is_host():
+ _network_session.publish_authoritative_teleport(
+ _player.get_network_peer_id()
+ )
+ elif _network_session.is_joined_client():
+ _network_session.request_safe_respawn(entry_position)
+
+
+func _on_remote_recovery_requested(
+ peer_id: int,
+ entry_position: Vector3,
+) -> void:
+ if not _network_session.is_host():
+ return
+ var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ return
+ _network_fishing.cancel_peer_attempt(
+ peer_id,
+ "Fishing attempt ended."
+ )
+ var target_position: Vector3 = _test_world.get_player_spawn_transform().origin
+ var nearest_distance: float = INF
+ for point: SafeRespawnPoint in _test_world.get_safe_respawn_points():
+ if point == null or not point.enabled:
+ continue
+ var distance: float = point.get_horizontal_distance_squared(
+ entry_position
+ )
+ if distance < nearest_distance:
+ nearest_distance = distance
+ target_position = point.global_position
+ target_position.y += _water_recovery.respawn_height_offset
+ avatar.global_position = target_position
+ avatar.velocity = Vector3.ZERO
+ _network_session.publish_authoritative_teleport(peer_id)
func _on_reset_progress_requested() -> void:
@@ -270,35 +1712,91 @@ func _on_reset_progress_requested() -> void:
pause_menu.close_for_title_transition()
_set_gameplay_active(false)
_game_ui.get_title_screen().reopen()
- _show_title_music()
+ _show_title_music(true)
func _on_water_recovery_starting() -> void:
+ _local_recovery_attempt_id = (
+ "recovery:%s"
+ % Crypto.new().generate_random_bytes(16).hex_encode()
+ )
+ _network_session.request_recovery_presentation(
+ true, _local_recovery_attempt_id
+ )
_game_ui.close_player_menu_for_water_recovery()
_game_ui.get_pause_menu().close_for_water_recovery()
_game_ui.get_fishing_shop().close_for_water_recovery()
+func _on_water_recovery_finished() -> void:
+ if _local_recovery_attempt_id.is_empty():
+ return
+ _network_session.request_recovery_presentation(
+ false, _local_recovery_attempt_id
+ )
+ _local_recovery_attempt_id = ""
+
+
+func _on_remote_recovery_presentation_changed(
+ peer_id: int,
+ active: bool,
+ _attempt_id: String,
+) -> void:
+ var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id)
+ if avatar == null or avatar == _player:
+ return
+ avatar.set_water_recovery_active(active)
+ avatar.set_remote_recovery_presentation(active)
+
+
func _on_active_hotbar_item_changed(
_slot_index: int,
- item_id: StringName,
+ kind: int,
+ identity: StringName,
) -> void:
if _fishing_spot.state != FishingSpotType.FishingState.READY:
return
+ var item_id: StringName = (
+ identity
+ if kind == PlayerHotbarType.AssignmentKind.ITEM
+ else StringName()
+ )
var item = item_catalog.get_item_by_id(item_id)
var active_is_rod: bool = (
item != null
and item.category == ItemDataType.Category.ROD
and _player.bag.owns_item(item_id)
)
- _player.set_active_item_is_rod(active_is_rod)
+ var active_is_art_kit: bool = (
+ item_id == ArtShopStockType.ART_KIT_ITEM_ID
+ and item != null
+ and _player.bag.owns_item(item_id)
+ )
+ _player.set_active_item_is_rod(active_is_rod, true)
+ _player.set_active_art_kit(
+ item.icon if item != null else null,
+ active_is_art_kit,
+ )
+ _game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit)
+ _network_item_use.submit_local_equipped(item_id, active_is_rod or (
+ item != null and _player.bag.owns_item(item_id)
+ ))
_fishing_spot.refresh_active_item_status()
func _refresh_active_hotbar_item() -> void:
+ var kind: int = (
+ _player.hotbar.get_selected_assignment_kind()
+ )
+ var identity: StringName = StringName()
+ if kind == PlayerHotbarType.AssignmentKind.ITEM:
+ identity = _player.hotbar.get_selected_item_id()
+ elif kind == PlayerHotbarType.AssignmentKind.FISH:
+ identity = _player.hotbar.get_selected_fish_catch_id()
_on_active_hotbar_item_changed(
_player.hotbar.get_selected_slot(),
- _player.hotbar.get_selected_item_id()
+ kind,
+ identity,
)
@@ -308,34 +1806,21 @@ func _on_quit_requested() -> void:
_quit_in_progress = true
_settings_manager.save_if_dirty()
if _gameplay_started:
- _save_manager.save_if_dirty()
- _fade_out_title_music(_finish_quit)
+ _save_manager.save_world_time_checkpoint()
+ _network_session.disconnect_session("Application closing.")
+ _title_music_requested = false
+ _replace_title_music_transition()
+ _finish_quit()
-func _show_title_music() -> void:
+func _show_title_music(restart_from_beginning: bool = false) -> void:
_title_music_requested = true
- var generation: int = _replace_title_music_transition()
- if not _title_music.playing:
- _title_music.volume_db = TITLE_MUSIC_SILENCE_DB
- _title_music.play()
- if is_equal_approx(_title_music.volume_db, title_music_volume_db):
- return
- if title_music_fade_in_seconds <= 0.0:
- _title_music.volume_db = title_music_volume_db
- return
- _title_music_tween = create_tween()
- _title_music_tween.set_trans(Tween.TRANS_CUBIC)
- _title_music_tween.set_ease(Tween.EASE_OUT)
- _title_music_tween.tween_property(
- _title_music,
- "volume_db",
- title_music_volume_db,
- title_music_fade_in_seconds
- )
- _title_music_tween.finished.connect(
- _on_title_music_faded_in.bind(generation),
- CONNECT_ONE_SHOT
- )
+ _replace_title_music_transition()
+ if restart_from_beginning:
+ _title_music.stop()
+ _title_music.volume_db = title_music_volume_db
+ if restart_from_beginning or not _title_music.playing:
+ _title_music.play(0.0)
func _fade_out_title_music(on_complete: Callable = Callable()) -> void:
@@ -372,16 +1857,6 @@ func _replace_title_music_transition() -> int:
return _title_music_transition_generation
-func _on_title_music_faded_in(generation: int) -> void:
- if (
- generation != _title_music_transition_generation
- or not _title_music_requested
- ):
- return
- _title_music_tween = null
- _title_music.volume_db = title_music_volume_db
-
-
func _complete_title_music_fade_out(
generation: int,
on_complete: Callable,
@@ -405,15 +1880,21 @@ func _finish_quit() -> void:
func _exit_tree() -> void:
+ if _dedicated_runtime and _network_session != null:
+ _network_session.disconnect_session("Dedicated server stopping.")
if _title_music_tween != null:
_title_music_tween.kill()
_title_music_tween = null
if is_instance_valid(_title_music):
_title_music.stop()
_title_music.stream = null
+ if is_instance_valid(_dusk_music):
+ _dusk_music.stop()
+ _dusk_music.stream = null
func _on_shop_range_changed(in_range: bool) -> void:
+ _game_ui.set_shop_npc_player_in_range(in_range)
if not in_range:
_game_ui.get_fishing_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_shop_prompt())
diff --git a/main/main.tscn b/main/main.tscn
index d8565ec..6b9f496 100644
--- a/main/main.tscn
+++ b/main/main.tscn
@@ -1,55 +1,447 @@
-[gd_scene load_steps=14 format=3]
+[gd_scene format=3 uid="uid://w6lvqm3bfhei"]
[ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
-[ext_resource type="Script" path="res://main/main.gd" id="3_main"]
+[ext_resource type="Script" uid="uid://c7u6jpr3f33t3" path="res://main/main.gd" id="3_main"]
[ext_resource type="PackedScene" path="res://fishing/fishing_spot.tscn" id="4_spot"]
[ext_resource type="PackedScene" path="res://ui/game_ui.tscn" id="5_ui"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="6_pool"]
+[ext_resource type="Resource" path="res://fish/pools/fish_catalog.tres" id="6_pool"]
[ext_resource type="Resource" path="res://economy/buyers/pelicans.tres" id="7_pelicans"]
-[ext_resource type="Script" path="res://world/water_recovery_controller.gd" id="8_recovery"]
-[ext_resource type="Script" path="res://save/player_save_manager.gd" id="9_save"]
-[ext_resource type="Script" path="res://settings/player_settings_manager.gd" id="10_settings"]
+[ext_resource type="Script" uid="uid://dvv0ublaoj4gr" path="res://world/water_recovery_controller.gd" id="8_recovery"]
+[ext_resource type="Script" uid="uid://dfr5m2x77d8ed" path="res://save/player_save_manager.gd" id="9_save"]
+[ext_resource type="Script" uid="uid://dwv3nvfke5xtu" path="res://settings/player_settings_manager.gd" id="10_settings"]
[ext_resource type="Resource" path="res://items/catalog/item_catalog.tres" id="11_items"]
[ext_resource type="Resource" path="res://economy/buyers/main_fishing_shop.tres" id="12_main_shop"]
-[ext_resource type="AudioStream" path="res://audio/music/title/as_in_four_wolves.ogg" id="13_title_music"]
+[ext_resource type="AudioStream" uid="uid://bgkfluumuen4m" path="res://audio/music/title/as_in_four_wolves.ogg" id="13_title_music"]
+[ext_resource type="Script" uid="uid://bdtb05vkiicd" path="res://ui/ui_pixelation_presenter.gd" id="14_pixelation"]
+[ext_resource type="PackedScene" path="res://ui/pixelation_reset_overlay.tscn" id="15_reset"]
+[ext_resource type="PackedScene" path="res://main/world_pixelation_postprocess.tscn" id="16_world_pixelation"]
+[ext_resource type="Script" uid="uid://u62a32ktf0y5" path="res://network/network_session.gd" id="17_network_session"]
+[ext_resource type="Script" uid="uid://db8ua5psoql0w" path="res://network/network_profile_preferences.gd" id="18_network_profile"]
+[ext_resource type="Script" uid="uid://b7j0er0u1nwy7" path="res://network/saved_server_store.gd" id="19_saved_servers"]
+[ext_resource type="Script" uid="uid://8w70d0nrf4a7" path="res://network/player_spawn_service.gd" id="20_spawn_service"]
+[ext_resource type="Script" uid="uid://b4v56htdn1y2l" path="res://network/network_fishing_service.gd" id="21_network_fishing"]
+[ext_resource type="Script" uid="uid://sqqy3anotq6n" path="res://network/network_sale_service.gd" id="22_network_sale"]
+[ext_resource type="Script" uid="uid://4ci4dbhyfc11" path="res://network/network_shop_service.gd" id="23_network_shop"]
+[ext_resource type="Script" uid="uid://dx21uu253nmpw" path="res://network/network_item_use_service.gd" id="24_network_item"]
+[ext_resource type="Script" uid="uid://b10jayoog5dc2" path="res://network/network_chat_service.gd" id="25_network_chat"]
+[ext_resource type="Script" uid="uid://t235k67v0dno" path="res://network/network_mail_service.gd" id="26_network_mail"]
+[ext_resource type="Script" uid="uid://bheplri3k6ch7" path="res://progression/player_asset_reservation_service.gd" id="27_reservations"]
+[ext_resource type="Script" uid="uid://ci07gm3e7r8p6" path="res://progression/player_appearance_store.gd" id="28_appearance"]
+[ext_resource type="Script" uid="uid://dc80t6nmksvm2" path="res://network/network_profile_service.gd" id="29_profile_service"]
+[ext_resource type="Script" uid="uid://cqlgolkbx2riq" path="res://network/player_identity_store.gd" id="30_player_identity"]
+[ext_resource type="Script" uid="uid://dtyoh0v77x52f" path="res://network/host_identity_store.gd" id="31_host_identity"]
+[ext_resource type="Script" uid="uid://dossvvvh4yuid" path="res://network/known_player_store.gd" id="32_known_players"]
+[ext_resource type="Script" uid="uid://cp2kam2btjkb0" path="res://network/server_trust_store.gd" id="33_server_trust"]
+[ext_resource type="Script" uid="uid://c3fnebvjotixg" path="res://network/player_relationship_store.gd" id="34_relationships"]
+[ext_resource type="Script" uid="uid://dmv6dqua5yl5a" path="res://network/host_ban_store.gd" id="35_bans"]
+[ext_resource type="Script" uid="uid://bkidkp7ggtc3q" path="res://network/network_player_list_service.gd" id="36_player_list"]
+[ext_resource type="Script" uid="uid://dpiv6wttry0j4" path="res://network/player_data_root.gd" id="37_data_root"]
+[ext_resource type="Script" uid="uid://bu5q4a8jfvxkf" path="res://network/identity_backup_service.gd" id="38_identity_backup"]
+[ext_resource type="Script" uid="uid://djmxy6jibi5y3" path="res://ui/interface_font_controller.gd" id="39_fonts"]
+[ext_resource type="Shader" uid="uid://c8s1g7tn771du" path="res://ui/title_water_background.gdshader" id="40_title_water"]
+[ext_resource type="Texture2D" uid="uid://f8a0cs0s7ler" path="res://art/patterns/pattern_tealdot.png" id="41_menu_pattern"]
+[ext_resource type="Shader" uid="uid://byw3iub66qvog" path="res://ui/player_menu_pattern.gdshader" id="42_menu_pattern"]
+[ext_resource type="Texture2D" uid="uid://b6xws1d2dnbnn" path="res://art/patterns/pattern_moneyfish.png" id="43_shop_pattern"]
+[ext_resource type="Script" uid="uid://m4n0bn38f34i" path="res://network/network_fish_showcase_service.gd" id="45_fish_showcase"]
+[ext_resource type="Script" uid="uid://bkiepgu8j5r6t" path="res://network/network_surface_drawing_service.gd" id="46_surface_drawing"]
+[ext_resource type="Script" uid="uid://5lceyulaglpf" path="res://world/world_time_service.gd" id="47_world_time"]
+[ext_resource type="Script" uid="uid://svirs8vbypad" path="res://world/world_time_visual_controller.gd" id="48_world_time_visuals"]
+[ext_resource type="Script" uid="uid://blig76aes25j" path="res://network/network_world_time_service.gd" id="49_network_world_time"]
+[ext_resource type="Script" uid="uid://v4c54isq6w74" path="res://world/world_weather_service.gd" id="50_world_weather"]
+[ext_resource type="Script" uid="uid://llj46fx0oco7" path="res://network/network_world_weather_service.gd" id="51_network_world_weather"]
+[ext_resource type="Script" uid="uid://daqtje84gqgu0" path="res://jobs/player_job_service.gd" id="52_player_jobs"]
+[ext_resource type="Script" uid="uid://drlyytr2o3uw8" path="res://network/network_job_service.gd" id="53_network_jobs"]
+[ext_resource type="Script" uid="uid://b0c3o2vy76fg3" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"]
+[ext_resource type="AudioStream" uid="uid://dck3tf8qkadea" path="res://audio/ambience/waves.wav" id="55_waves"]
+[ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"]
+[ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"]
+[ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"]
-[node name="Main" type="Node3D"]
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"]
+shader = ExtResource("40_title_water")
+shader_parameter/surface_color = Color(0.1, 0.45, 0.62, 1)
+shader_parameter/middle_color = Color(0.055, 0.285, 0.4, 1)
+shader_parameter/depth_color = Color(0.02, 0.16, 0.27, 1)
+shader_parameter/virtual_pixel_density = Vector2(320, 180)
+shader_parameter/color_step_count = 14.0
+shader_parameter/continuous_depth_blend = 0.12
+shader_parameter/wave_speed = 0.1
+shader_parameter/wave_scale = 2.4
+shader_parameter/wave_strength = 0.012
+shader_parameter/caustic_strength = 0.018
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_player_menu_pattern"]
+shader = ExtResource("42_menu_pattern")
+shader_parameter/pattern_texture = ExtResource("41_menu_pattern")
+shader_parameter/source_tile_size = Vector2(128, 128)
+shader_parameter/display_scale = 0.85
+shader_parameter/scroll_velocity_pixels = Vector2(-7, -5)
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_shop_pattern"]
+shader = ExtResource("42_menu_pattern")
+shader_parameter/pattern_texture = ExtResource("43_shop_pattern")
+shader_parameter/source_tile_size = Vector2(64, 64)
+shader_parameter/display_scale = 1.75
+shader_parameter/scroll_velocity_pixels = Vector2(-7, -5)
+
+[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_hifdc"]
+data = PackedVector3Array(-13.598646, -21.527378, 3.4402227, -13.598646, -21.04931, 3.716229, -13.64679, -21.345833, 3.1714065, -13.64679, -21.345833, 3.1714065, -13.598646, -21.04931, 3.716229, -13.598646, -21.04931, 3.1642165, -13.598646, -21.601322, 3.716229, -13.598646, -21.04931, 3.716229, -13.598646, -21.527378, 3.4402227, -13.598646, -21.04931, 3.1642165, -13.598646, -21.04931, 3.716229, -13.598646, -20.773304, 3.238161, -13.604641, -21.54901, 3.9200878, -13.598646, -21.04931, 3.716229, -13.598646, -21.601322, 3.716229, -13.598646, -20.773304, 3.238161, -13.598646, -21.04931, 3.716229, -13.598646, -20.571243, 3.4402227, -13.598646, -21.325317, 4.194297, -13.598646, -21.04931, 3.716229, -13.604641, -21.54901, 3.9200878, -13.598646, -20.571243, 3.4402227, -13.598646, -21.04931, 3.716229, -13.598646, -20.4973, 3.716229, -13.602205, -21.078318, 4.268241, -13.598646, -21.04931, 3.716229, -13.598646, -21.325317, 4.194297, -13.598646, -20.4973, 3.716229, -13.598646, -21.04931, 3.716229, -13.598646, -20.571243, 3.992235, -13.598646, -20.773304, 4.194297, -13.598646, -21.04931, 3.716229, -13.602205, -21.078318, 4.268241, -13.598646, -20.571243, 3.992235, -13.598646, -21.04931, 3.716229, -13.598646, -20.773304, 4.194297, -19.042458, -21.325317, 3.2381601, -19.042458, -21.04931, 3.716228, -19.042458, -21.527378, 3.4402218, -19.042458, -21.527378, 3.4402218, -19.042458, -21.04931, 3.716228, -19.042458, -21.601322, 3.716228, -19.042458, -21.04931, 3.1642156, -19.042458, -21.04931, 3.716228, -19.042458, -21.325317, 3.2381601, -19.042458, -21.601322, 3.716228, -19.042458, -21.04931, 3.716228, -19.042458, -21.527378, 3.992234, -19.042458, -20.773304, 3.2381601, -19.042458, -21.04931, 3.716228, -19.042458, -21.04931, 3.1642156, -19.042458, -21.527378, 3.992234, -19.042458, -21.04931, 3.716228, -19.042458, -21.325317, 4.194296, -18.784327, -20.60149, 3.4511306, -19.042458, -21.04931, 3.716228, -19.042458, -20.773304, 3.2381601, -19.042458, -21.325317, 4.194296, -19.042458, -21.04931, 3.716228, -19.042458, -21.04931, 4.26824, -19.042458, -20.4973, 3.716228, -19.042458, -21.04931, 3.716228, -18.784327, -20.60149, 3.4511306, -19.042458, -21.04931, 4.26824, -19.042458, -21.04931, 3.716228, -19.042458, -20.773304, 4.194296, -19.042458, -20.571243, 3.992234, -19.042458, -21.04931, 3.716228, -19.042458, -20.4973, 3.716228, -19.042458, -20.773304, 4.194296, -19.042458, -21.04931, 3.716228, -19.042458, -20.571243, 3.992234, -14.534702, -21.32513, 3.238533, -17.467253, -21.04931, 3.1645257, -17.467253, -21.32513, 3.2384703, -17.467253, -21.32513, 3.2384703, -19.084044, -21.586075, 3.4063175, -17.467253, -21.52707, 3.440408, -17.467253, -21.32513, 3.2384703, -19.084044, -21.35922, 3.1794631, -19.084044, -21.586075, 3.4063175, -17.467253, -21.04931, 3.1645257, -19.084044, -21.35922, 3.1794631, -17.467253, -21.32513, 3.2384703, -17.467253, -21.32513, 3.2384703, -17.467253, -21.52707, 3.440408, -14.516907, -21.527006, 3.4404085, -17.467253, -21.32513, 3.2384703, -14.516907, -21.527006, 3.4404085, -14.534702, -21.32513, 3.238533, -17.467253, -21.04931, 3.1645257, -19.084044, -21.04931, 3.0964072, -19.084044, -21.35922, 3.1794631, -13.605204, -21.379738, 3.1126475, -14.534702, -21.32513, 3.238533, -14.516907, -21.527006, 3.4404085, -13.605204, -21.379738, 3.1126475, -14.516907, -21.527006, 3.4404085, -13.55706, -21.586075, 3.4063187, -13.55706, -21.04931, 3.0964081, -14.534702, -21.32513, 3.238533, -13.605204, -21.379738, 3.1126475, -13.55706, -21.04931, 3.0964081, -14.519905, -21.04931, 3.1645882, -14.534702, -21.32513, 3.238533, -14.534702, -21.32513, 3.238533, -14.519905, -21.04931, 3.1645882, -17.467253, -21.04931, 3.1645257, -13.55706, -20.7394, 3.179464, -14.519905, -21.04931, 3.1645882, -13.55706, -21.04931, 3.0964081, -17.467253, -20.77349, 3.2384703, -19.084044, -21.04931, 3.0964072, -17.467253, -21.04931, 3.1645257, -17.467253, -20.77349, 3.2384703, -17.467253, -21.04931, 3.1645257, -14.519905, -21.04931, 3.1645882, -17.467253, -20.77349, 3.2384703, -19.084044, -20.7394, 3.1794631, -19.084044, -21.04931, 3.0964072, -17.467253, -20.571552, 3.440408, -19.084044, -20.7394, 3.1794631, -17.467253, -20.77349, 3.2384703, -17.467253, -20.571552, 3.440408, -18.82591, -20.542793, 3.4172263, -19.084044, -20.7394, 3.1794631, -17.467253, -20.77349, 3.2384703, -14.519905, -21.04931, 3.1645882, -14.509039, -20.77349, 3.238533, -17.467253, -20.571552, 3.440408, -17.467253, -20.77349, 3.2384703, -14.509039, -20.77349, 3.238533, -13.55706, -20.7394, 3.179464, -14.509039, -20.77349, 3.238533, -14.519905, -21.04931, 3.1645882, -17.467253, -20.571552, 3.440408, -14.509039, -20.77349, 3.238533, -14.509039, -20.571615, 3.4404085, -13.55706, -20.512547, 3.4063187, -14.509039, -20.77349, 3.238533, -13.55706, -20.7394, 3.179464, -13.55706, -20.512547, 3.4063187, -14.509039, -20.571615, 3.4404085, -14.509039, -20.77349, 3.238533, -17.467253, -21.52707, 3.440408, -14.509039, -21.60095, 3.7162287, -14.516907, -21.527006, 3.4404085, -13.55706, -21.586075, 3.4063187, -14.516907, -21.527006, 3.4404085, -14.509039, -21.60095, 3.7162287, -13.55706, -21.586075, 3.4063187, -14.509039, -21.60095, 3.7162287, -13.55706, -21.669132, 3.716229, -13.55706, -21.669132, 3.716229, -14.509039, -21.60095, 3.7162287, -14.511475, -21.527006, 3.992049, -13.55706, -21.669132, 3.716229, -14.511475, -21.527006, 3.992049, -13.563242, -21.607708, 3.9539921, -17.467253, -21.601013, 3.7162282, -14.511475, -21.527006, 3.992049, -14.509039, -21.60095, 3.7162287, -17.467253, -21.52707, 3.440408, -17.467253, -21.601013, 3.7162282, -14.509039, -21.60095, 3.7162287, -17.467253, -21.601013, 3.7162282, -17.467253, -21.52707, 3.9920485, -14.511475, -21.527006, 3.992049, -17.467253, -21.601013, 3.7162282, -19.084044, -21.586075, 4.0261383, -17.467253, -21.52707, 3.9920485, -17.467253, -21.601013, 3.7162282, -19.084044, -21.669132, 3.716228, -19.084044, -21.586075, 4.0261383, -17.467253, -21.52707, 3.440408, -19.084044, -21.669132, 3.716228, -17.467253, -21.601013, 3.7162282, -17.467253, -21.52707, 3.440408, -19.084044, -21.586075, 3.4063175, -19.084044, -21.669132, 3.716228, -17.467253, -21.52707, 3.9920485, -14.509039, -21.32513, 4.1939244, -14.511475, -21.527006, 3.992049, -13.563242, -21.607708, 3.9539921, -14.511475, -21.527006, 3.992049, -14.509039, -21.32513, 4.1939244, -13.563242, -21.607708, 3.9539921, -14.509039, -21.32513, 4.1939244, -13.557062, -21.35922, 4.2529936, -13.557062, -21.35922, 4.2529936, -14.509039, -21.32513, 4.1939244, -14.510912, -21.04931, 4.267869, -13.557062, -21.35922, 4.2529936, -14.510912, -21.04931, 4.267869, -13.560621, -21.078318, 4.3360496, -17.467253, -21.52707, 3.9920485, -17.467253, -21.32513, 4.193986, -14.509039, -21.32513, 4.1939244, -17.467253, -21.32513, 4.193986, -14.510912, -21.04931, 4.267869, -14.509039, -21.32513, 4.1939244, -17.467253, -21.52707, 3.9920485, -19.084044, -21.35922, 4.2529926, -17.467253, -21.32513, 4.193986, -17.467253, -21.52707, 3.9920485, -19.084044, -21.586075, 4.0261383, -19.084044, -21.35922, 4.2529926, -17.467253, -21.32513, 4.193986, -19.084044, -21.35922, 4.2529926, -19.084044, -21.04931, 4.3360486, -17.467253, -21.32513, 4.193986, -19.084044, -21.04931, 4.3360486, -17.467253, -21.04931, 4.2679305, -17.467253, -21.32513, 4.193986, -17.467253, -21.04931, 4.2679305, -14.510912, -21.04931, 4.267869, -17.467253, -21.04931, 4.2679305, -19.084044, -21.04931, 4.3360486, -19.084044, -20.7394, 4.2529926, -13.560621, -21.078318, 4.3360496, -14.510912, -21.04931, 4.267869, -14.510912, -20.795122, 4.1217775, -17.467253, -21.04931, 4.2679305, -14.510912, -20.795122, 4.1217775, -14.510912, -21.04931, 4.267869, -13.560621, -21.078318, 4.3360496, -14.510912, -20.795122, 4.1217775, -13.557062, -20.7394, 4.2529936, -17.467253, -21.04931, 4.2679305, -19.084044, -20.7394, 4.2529926, -17.467253, -20.77349, 4.193986, -17.467253, -21.04931, 4.2679305, -17.467253, -20.77349, 4.193986, -14.510912, -20.795122, 4.1217775, -17.467253, -20.77349, 4.193986, -19.084044, -20.7394, 4.2529926, -19.084044, -20.512547, 4.0261383, -17.467253, -20.77349, 4.193986, -19.084044, -20.512547, 4.0261383, -17.467253, -20.571552, 3.9920485, -17.467253, -20.77349, 4.193986, -17.467253, -20.571552, 3.9920485, -14.509039, -20.571615, 3.992049, -17.467253, -20.77349, 4.193986, -14.509039, -20.571615, 3.992049, -14.510912, -20.795122, 4.1217775, -13.557062, -20.7394, 4.2529936, -14.510912, -20.795122, 4.1217775, -14.509039, -20.571615, 3.992049, -13.557062, -20.7394, 4.2529936, -14.509039, -20.571615, 3.992049, -13.55706, -20.512547, 4.0261393, -17.467253, -20.571552, 3.9920485, -14.509039, -20.497732, 3.7162287, -14.509039, -20.571615, 3.992049, -13.55706, -20.512547, 4.0261393, -14.509039, -20.571615, 3.992049, -14.509039, -20.497732, 3.7162287, -13.55706, -20.512547, 4.0261393, -14.509039, -20.497732, 3.7162287, -13.55706, -20.42949, 3.716229, -13.55706, -20.42949, 3.716229, -14.509039, -20.497732, 3.7162287, -14.509039, -20.571615, 3.4404085, -13.55706, -20.42949, 3.716229, -14.509039, -20.571615, 3.4404085, -13.55706, -20.512547, 3.4063187, -17.467253, -20.497608, 3.7162282, -14.509039, -20.571615, 3.4404085, -14.509039, -20.497732, 3.7162287, -17.467253, -20.571552, 3.9920485, -17.467253, -20.497608, 3.7162282, -14.509039, -20.497732, 3.7162287, -17.467253, -20.497608, 3.7162282, -17.467253, -20.571552, 3.440408, -14.509039, -20.571615, 3.4404085, -17.467253, -20.497608, 3.7162282, -18.82591, -20.542793, 3.4172263, -17.467253, -20.571552, 3.440408, -17.467253, -20.497608, 3.7162282, -19.084044, -20.42949, 3.716228, -18.82591, -20.542793, 3.4172263, -17.467253, -20.571552, 3.9920485, -19.084044, -20.42949, 3.716228, -17.467253, -20.497608, 3.7162282, -17.467253, -20.571552, 3.9920485, -19.084044, -20.512547, 4.0261383, -19.084044, -20.42949, 3.716228, -19.084044, -21.35922, 3.1794631, -19.084044, -21.586075, 3.4063175, -19.084044, -21.586075, 3.4063175, -19.084044, -21.586075, 3.4063175, -19.084044, -21.586075, 3.4063175, -19.084044, -21.669132, 3.716228, -19.084044, -21.35922, 3.1794631, -19.084044, -21.35922, 3.1794631, -19.084044, -21.586075, 3.4063175, -19.084044, -21.04931, 3.0964072, -19.084044, -21.35922, 3.1794631, -19.084044, -21.35922, 3.1794631, -19.084044, -21.04931, 3.0964072, -19.084044, -21.04931, 3.0964072, -19.084044, -21.35922, 3.1794631, -19.084044, -21.586075, 3.4063175, -19.084044, -21.669132, 3.716228, -19.084044, -21.669132, 3.716228, -19.084044, -21.669132, 3.716228, -19.084044, -21.669132, 3.716228, -19.084044, -21.586075, 4.0261383, -19.084044, -21.669132, 3.716228, -19.084044, -21.586075, 4.0261383, -19.084044, -21.586075, 4.0261383, -19.084044, -21.586075, 4.0261383, -19.084044, -21.586075, 4.0261383, -19.084044, -21.35922, 4.2529926, -19.084044, -21.586075, 4.0261383, -19.084044, -21.35922, 4.2529926, -19.084044, -21.35922, 4.2529926, -19.084044, -21.35922, 4.2529926, -19.084044, -21.35922, 4.2529926, -19.084044, -21.04931, 4.3360486, -19.084044, -21.35922, 4.2529926, -19.084044, -21.04931, 4.3360486, -19.084044, -21.04931, 4.3360486, -19.084044, -21.04931, 4.3360486, -19.084044, -21.04931, 4.3360486, -19.084044, -20.7394, 4.2529926, -19.084044, -21.04931, 4.3360486, -19.084044, -20.7394, 4.2529926, -19.084044, -20.7394, 4.2529926, -19.084044, -20.7394, 4.2529926, -19.084044, -20.7394, 4.2529926, -19.084044, -20.512547, 4.0261383, -19.084044, -20.7394, 4.2529926, -19.084044, -20.512547, 4.0261383, -19.084044, -20.512547, 4.0261383, -19.084044, -20.512547, 4.0261383, -19.084044, -20.512547, 4.0261383, -19.084044, -20.42949, 3.716228, -19.084044, -20.512547, 4.0261383, -19.084044, -20.42949, 3.716228, -19.084044, -20.42949, 3.716228, -19.084044, -20.42949, 3.716228, -19.084044, -20.42949, 3.716228, -18.82591, -20.542793, 3.4172263, -19.084044, -20.42949, 3.716228, -18.82591, -20.542793, 3.4172263, -18.82591, -20.542793, 3.4172263, -18.82591, -20.542793, 3.4172263, -19.084044, -20.7394, 3.1794631, -19.084044, -20.7394, 3.1794631, -18.82591, -20.542793, 3.4172263, -18.82591, -20.542793, 3.4172263, -19.084044, -20.7394, 3.1794631, -19.084044, -20.7394, 3.1794631, -19.084044, -21.04931, 3.0964072, -19.084044, -21.04931, 3.0964072, -19.084044, -20.7394, 3.1794631, -19.084044, -20.7394, 3.1794631, -19.084044, -21.04931, 3.0964072, -13.55706, -21.586075, 3.4063187, -13.605204, -21.345833, 3.1714065, -13.605204, -21.379738, 3.1126475, -13.605204, -21.379738, 3.1126475, -13.605204, -21.345833, 3.1714065, -13.55706, -21.04931, 3.1642165, -13.55706, -21.586075, 3.4063187, -13.55706, -21.527378, 3.4402227, -13.605204, -21.345833, 3.1714065, -13.605204, -21.379738, 3.1126475, -13.55706, -21.04931, 3.1642165, -13.55706, -21.04931, 3.0964081, -13.55706, -21.669132, 3.716229, -13.55706, -21.527378, 3.4402227, -13.55706, -21.586075, 3.4063187, -13.55706, -21.04931, 3.0964081, -13.55706, -21.04931, 3.1642165, -13.55706, -20.773304, 3.238161, -13.55706, -21.669132, 3.716229, -13.55706, -21.601322, 3.716229, -13.55706, -21.527378, 3.4402227, -13.55706, -21.04931, 3.0964081, -13.55706, -20.773304, 3.238161, -13.55706, -20.7394, 3.179464, -13.563242, -21.607708, 3.9539921, -13.55706, -21.601322, 3.716229, -13.55706, -21.669132, 3.716229, -13.55706, -20.7394, 3.179464, -13.55706, -20.773304, 3.238161, -13.55706, -20.571243, 3.4402227, -13.563242, -21.607708, 3.9539921, -13.563242, -21.54901, 3.9200878, -13.55706, -21.601322, 3.716229, -13.55706, -20.7394, 3.179464, -13.55706, -20.571243, 3.4402227, -13.55706, -20.512547, 3.4063187, -13.557062, -21.35922, 4.2529936, -13.563242, -21.54901, 3.9200878, -13.563242, -21.607708, 3.9539921, -13.55706, -20.512547, 3.4063187, -13.55706, -20.571243, 3.4402227, -13.55706, -20.4973, 3.716229, -13.557062, -21.35922, 4.2529936, -13.55706, -21.325317, 4.194297, -13.563242, -21.54901, 3.9200878, -13.55706, -20.512547, 3.4063187, -13.55706, -20.4973, 3.716229, -13.55706, -20.42949, 3.716229, -13.560621, -21.078318, 4.3360496, -13.55706, -21.325317, 4.194297, -13.557062, -21.35922, 4.2529936, -13.55706, -20.42949, 3.716229, -13.55706, -20.4973, 3.716229, -13.55706, -20.571243, 3.9922352, -13.560621, -21.078318, 4.3360496, -13.560621, -21.078318, 4.268241, -13.55706, -21.325317, 4.194297, -13.55706, -20.42949, 3.716229, -13.55706, -20.571243, 3.9922352, -13.55706, -20.512547, 4.0261393, -13.557062, -20.7394, 4.2529936, -13.560621, -21.078318, 4.268241, -13.560621, -21.078318, 4.3360496, -13.55706, -20.512547, 4.0261393, -13.55706, -20.571243, 3.9922352, -13.55706, -20.773304, 4.194297, -13.557062, -20.7394, 4.2529936, -13.55706, -20.773304, 4.194297, -13.560621, -21.078318, 4.268241, -13.55706, -20.512547, 4.0261393, -13.55706, -20.773304, 4.194297, -13.557062, -20.7394, 4.2529936, -19.084044, -21.35922, 3.1794631, -19.084044, -21.527378, 3.4402218, -19.084044, -21.586075, 3.4063175, -19.084044, -21.586075, 3.4063175, -19.084044, -21.527378, 3.4402218, -19.084044, -21.601322, 3.716228, -19.084044, -21.35922, 3.1794631, -19.084044, -21.325317, 3.2381601, -19.084044, -21.527378, 3.4402218, -19.084044, -21.586075, 3.4063175, -19.084044, -21.601322, 3.716228, -19.084044, -21.669132, 3.716228, -19.084044, -21.04931, 3.0964072, -19.084044, -21.325317, 3.2381601, -19.084044, -21.35922, 3.1794631, -19.084044, -21.669132, 3.716228, -19.084044, -21.601322, 3.716228, -19.084044, -21.527378, 3.992234, -19.084044, -21.04931, 3.0964072, -19.084044, -21.04931, 3.1642156, -19.084044, -21.325317, 3.2381601, -19.084044, -21.669132, 3.716228, -19.084044, -21.527378, 3.992234, -19.084044, -21.586075, 4.0261383, -19.084044, -20.7394, 3.1794631, -19.084044, -21.04931, 3.1642156, -19.084044, -21.04931, 3.0964072, -19.084044, -21.586075, 4.0261383, -19.084044, -21.527378, 3.992234, -19.084044, -21.325317, 4.194296, -19.084044, -20.7394, 3.1794631, -19.084044, -20.773304, 3.2381601, -19.084044, -21.04931, 3.1642156, -19.084044, -21.586075, 4.0261383, -19.084044, -21.325317, 4.194296, -19.084044, -21.35922, 4.2529926, -18.82591, -20.542793, 3.4172263, -19.084044, -20.773304, 3.2381601, -19.084044, -20.7394, 3.1794631, -19.084044, -21.35922, 4.2529926, -19.084044, -21.325317, 4.194296, -19.084044, -21.04931, 4.26824, -18.82591, -20.542793, 3.4172263, -18.82591, -20.60149, 3.4511306, -19.084044, -20.773304, 3.2381601, -19.084044, -21.35922, 4.2529926, -19.084044, -21.04931, 4.26824, -19.084044, -21.04931, 4.3360486, -19.084044, -20.42949, 3.716228, -18.82591, -20.60149, 3.4511306, -18.82591, -20.542793, 3.4172263, -19.084044, -21.04931, 4.3360486, -19.084044, -21.04931, 4.26824, -19.084044, -20.773304, 4.194296, -19.084044, -20.42949, 3.716228, -19.084044, -20.4973, 3.716228, -18.82591, -20.60149, 3.4511306, -19.084044, -21.04931, 4.3360486, -19.084044, -20.773304, 4.194296, -19.084044, -20.7394, 4.2529926, -19.084044, -20.512547, 4.0261383, -19.084044, -20.4973, 3.716228, -19.084044, -20.42949, 3.716228, -19.084044, -20.7394, 4.2529926, -19.084044, -20.773304, 4.194296, -19.084044, -20.571243, 3.992234, -19.084044, -20.512547, 4.0261383, -19.084044, -20.571243, 3.992234, -19.084044, -20.4973, 3.716228, -19.084044, -20.7394, 4.2529926, -19.084044, -20.571243, 3.992234, -19.084044, -20.512547, 4.0261383, -13.55706, -21.527378, 3.4402227, -13.64679, -21.345833, 3.1714065, -13.605204, -21.345833, 3.1714065, -13.55706, -21.527378, 3.4402227, -13.598646, -21.527378, 3.4402227, -13.64679, -21.345833, 3.1714065, -13.55706, -21.601322, 3.716229, -13.598646, -21.527378, 3.4402227, -13.55706, -21.527378, 3.4402227, -13.55706, -21.601322, 3.716229, -13.598646, -21.601322, 3.716229, -13.598646, -21.527378, 3.4402227, -13.563242, -21.54901, 3.9200878, -13.598646, -21.601322, 3.716229, -13.55706, -21.601322, 3.716229, -13.563242, -21.54901, 3.9200878, -13.604641, -21.54901, 3.9200878, -13.598646, -21.601322, 3.716229, -13.55706, -21.325317, 4.194297, -13.604641, -21.54901, 3.9200878, -13.563242, -21.54901, 3.9200878, -13.55706, -21.325317, 4.194297, -13.598646, -21.325317, 4.194297, -13.604641, -21.54901, 3.9200878, -13.560621, -21.078318, 4.268241, -13.598646, -21.325317, 4.194297, -13.55706, -21.325317, 4.194297, -13.560621, -21.078318, 4.268241, -13.602205, -21.078318, 4.268241, -13.598646, -21.325317, 4.194297, -13.55706, -20.773304, 4.194297, -13.602205, -21.078318, 4.268241, -13.560621, -21.078318, 4.268241, -13.55706, -20.773304, 4.194297, -13.598646, -20.773304, 4.194297, -13.602205, -21.078318, 4.268241, -13.55706, -20.571243, 3.9922352, -13.598646, -20.773304, 4.194297, -13.55706, -20.773304, 4.194297, -13.55706, -20.571243, 3.9922352, -13.598646, -20.571243, 3.992235, -13.598646, -20.773304, 4.194297, -13.55706, -20.4973, 3.716229, -13.598646, -20.571243, 3.992235, -13.55706, -20.571243, 3.9922352, -13.55706, -20.4973, 3.716229, -13.598646, -20.4973, 3.716229, -13.598646, -20.571243, 3.992235, -13.55706, -20.571243, 3.4402227, -13.598646, -20.4973, 3.716229, -13.55706, -20.4973, 3.716229, -13.55706, -20.571243, 3.4402227, -13.598646, -20.571243, 3.4402227, -13.598646, -20.4973, 3.716229, -13.55706, -20.773304, 3.238161, -13.598646, -20.571243, 3.4402227, -13.55706, -20.571243, 3.4402227, -13.55706, -20.773304, 3.238161, -13.598646, -20.773304, 3.238161, -13.598646, -20.571243, 3.4402227, -13.55706, -21.04931, 3.1642165, -13.598646, -20.773304, 3.238161, -13.55706, -20.773304, 3.238161, -13.55706, -21.04931, 3.1642165, -13.598646, -21.04931, 3.1642165, -13.598646, -20.773304, 3.238161, -13.605204, -21.345833, 3.1714065, -13.598646, -21.04931, 3.1642165, -13.55706, -21.04931, 3.1642165, -13.605204, -21.345833, 3.1714065, -13.64679, -21.345833, 3.1714065, -13.598646, -21.04931, 3.1642165, -19.084044, -21.325317, 3.2381601, -19.042458, -21.527378, 3.4402218, -19.084044, -21.527378, 3.4402218, -19.084044, -21.325317, 3.2381601, -19.042458, -21.325317, 3.2381601, -19.042458, -21.527378, 3.4402218, -19.084044, -21.527378, 3.4402218, -19.042458, -21.601322, 3.716228, -19.084044, -21.601322, 3.716228, -19.084044, -21.527378, 3.4402218, -19.042458, -21.527378, 3.4402218, -19.042458, -21.601322, 3.716228, -19.084044, -21.601322, 3.716228, -19.042458, -21.527378, 3.992234, -19.084044, -21.527378, 3.992234, -19.084044, -21.601322, 3.716228, -19.042458, -21.601322, 3.716228, -19.042458, -21.527378, 3.992234, -19.084044, -21.527378, 3.992234, -19.042458, -21.325317, 4.194296, -19.084044, -21.325317, 4.194296, -19.084044, -21.527378, 3.992234, -19.042458, -21.527378, 3.992234, -19.042458, -21.325317, 4.194296, -19.084044, -21.325317, 4.194296, -19.042458, -21.04931, 4.26824, -19.084044, -21.04931, 4.26824, -19.084044, -21.325317, 4.194296, -19.042458, -21.325317, 4.194296, -19.042458, -21.04931, 4.26824, -19.084044, -21.04931, 4.26824, -19.042458, -21.04931, 4.26824, -19.042458, -20.773304, 4.194296, -19.084044, -21.04931, 4.26824, -19.042458, -20.773304, 4.194296, -19.084044, -20.773304, 4.194296, -19.084044, -20.773304, 4.194296, -19.042458, -20.571243, 3.992234, -19.084044, -20.571243, 3.992234, -19.084044, -20.773304, 4.194296, -19.042458, -20.773304, 4.194296, -19.042458, -20.571243, 3.992234, -19.084044, -20.571243, 3.992234, -19.042458, -20.4973, 3.716228, -19.084044, -20.4973, 3.716228, -19.084044, -20.571243, 3.992234, -19.042458, -20.571243, 3.992234, -19.042458, -20.4973, 3.716228, -19.084044, -20.4973, 3.716228, -18.784327, -20.60149, 3.4511306, -18.82591, -20.60149, 3.4511306, -19.084044, -20.4973, 3.716228, -19.042458, -20.4973, 3.716228, -18.784327, -20.60149, 3.4511306, -18.82591, -20.60149, 3.4511306, -19.042458, -20.773304, 3.2381601, -19.084044, -20.773304, 3.2381601, -18.82591, -20.60149, 3.4511306, -18.784327, -20.60149, 3.4511306, -19.042458, -20.773304, 3.2381601, -19.084044, -20.773304, 3.2381601, -19.042458, -21.04931, 3.1642156, -19.084044, -21.04931, 3.1642156, -19.084044, -20.773304, 3.2381601, -19.042458, -20.773304, 3.2381601, -19.042458, -21.04931, 3.1642156, -19.084044, -21.04931, 3.1642156, -19.042458, -21.04931, 3.1642156, -19.042458, -21.325317, 3.2381601, -19.084044, -21.04931, 3.1642156, -19.042458, -21.325317, 3.2381601, -19.084044, -21.325317, 3.2381601, -0.49230337, 3.2260702, -2.5408025, -0.2174604, 3.7041378, -2.5661154, -0.75557137, 3.4076157, -2.4682097, -0.75557137, 3.4076157, -2.4682097, -0.2174604, 3.7041378, -2.5661154, -0.7671461, 3.7041378, -2.5154896, -0.2174604, 3.1521254, -2.5661154, -0.2174604, 3.7041378, -2.5661154, -0.49230337, 3.2260702, -2.5408025, -0.7671461, 3.7041378, -2.5154896, -0.2174604, 3.7041378, -2.5661154, -0.6935134, 3.980144, -2.5222712, -0.01391077, 3.2044384, -2.5788422, -0.2174604, 3.7041378, -2.5661154, -0.2174604, 3.1521254, -2.5661154, -0.6935134, 3.980144, -2.5222712, -0.2174604, 3.7041378, -2.5661154, -0.49230337, 4.1822057, -2.5408025, 0.2585926, 3.4281316, -2.6099591, -0.2174604, 3.7041378, -2.5661154, -0.01391077, 3.2044384, -2.5788422, -0.49230337, 4.1822057, -2.5408025, -0.2174604, 3.7041378, -2.5661154, -0.2174604, 4.2561502, -2.5661154, 0.33255196, 3.6751301, -2.6131968, -0.2174604, 3.7041378, -2.5661154, 0.2585926, 3.4281316, -2.6099591, -0.2174604, 4.2561502, -2.5661154, -0.2174604, 3.7041378, -2.5661154, 0.057382584, 4.1822057, -2.5914283, 0.2585926, 3.980144, -2.6099591, -0.2174604, 3.7041378, -2.5661154, 0.33255196, 3.6751301, -2.6131968, 0.057382584, 4.1822057, -2.5914283, -0.2174604, 3.7041378, -2.5661154, 0.2585926, 3.980144, -2.6099591, -0.19425559, 3.4281316, 2.8985977, 0.2817974, 3.7041378, 2.8547535, 0.0069544315, 3.2260702, 2.8800664, 0.0069544315, 3.2260702, 2.8800664, 0.2817974, 3.7041378, 2.8547535, 0.2817974, 3.1521254, 2.8547535, -0.26788855, 3.7041378, 2.9053793, 0.2817974, 3.7041378, 2.8547535, -0.19425559, 3.4281316, 2.8985977, 0.2817974, 3.1521254, 2.8547535, 0.2817974, 3.7041378, 2.8547535, 0.55664027, 3.2260702, 2.8294406, -0.19425559, 3.980144, 2.8985977, 0.2817974, 3.7041378, 2.8547535, -0.26788855, 3.7041378, 2.9053793, 0.55664027, 3.2260702, 2.8294406, 0.2817974, 3.7041378, 2.8547535, 0.75785017, 3.4281316, 2.8109093, -0.0058562756, 4.1519585, 2.6220222, 0.2817974, 3.7041378, 2.8547535, -0.19425559, 3.980144, 2.8985977, 0.75785017, 3.4281316, 2.8109093, 0.2817974, 3.7041378, 2.8547535, 0.8314831, 3.7041378, 2.8041277, 0.2817974, 4.2561502, 2.8547535, 0.2817974, 3.7041378, 2.8547535, -0.0058562756, 4.1519585, 2.6220222, 0.8314831, 3.7041378, 2.8041277, 0.2817974, 3.7041378, 2.8547535, 0.75785017, 3.980144, 2.8109093, 0.55664027, 4.1822057, 2.8294406, 0.2817974, 3.7041378, 2.8547535, 0.2817974, 4.2561502, 2.8547535, 0.75785017, 3.980144, 2.8109093, 0.2817974, 3.7041378, 2.8547535, 0.55664027, 4.1822057, 2.8294406, -0.6072962, 3.4283175, -1.5901942, -0.41204357, 3.7041378, 1.3367851, -0.33841062, 3.4283175, 1.3300035, -0.33841062, 3.4283175, 1.3300035, -0.022993088, 3.1673732, 2.9245868, -0.1373241, 3.2263799, 1.3114836, -0.33841062, 3.4283175, 1.3300035, -0.24889135, 3.3942275, 2.9453917, -0.022993088, 3.1673732, 2.9245868, -0.41204357, 3.7041378, 1.3367851, -0.24889135, 3.3942275, 2.9453917, -0.33841062, 3.4283175, 1.3300035, -0.33841062, 3.4283175, 1.3300035, -0.1373241, 3.2263799, 1.3114836, -0.40790343, 3.2264419, -1.6264291, -0.33841062, 3.4283175, 1.3300035, -0.40790343, 3.2264419, -1.6264291, -0.6072962, 3.4283175, -1.5901942, -0.41204357, 3.7041378, 1.3367851, -0.33159733, 3.7041378, 2.9530087, -0.24889135, 3.3942275, 2.9453917, -0.81789684, 3.3737113, -2.5042315, -0.6072962, 3.4283175, -1.5901942, -0.40790343, 3.2264419, -1.6264291, -0.81789684, 3.3737113, -2.5042315, -0.40790343, 3.2264419, -1.6264291, -0.5298786, 3.1673732, -2.5791035, -0.83848286, 3.7041378, -2.5506816, -0.6072962, 3.4283175, -1.5901942, -0.81789684, 3.3737113, -2.5042315, -0.83848286, 3.7041378, -2.5506816, -0.6822865, 3.7041378, -1.5981488, -0.6072962, 3.4283175, -1.5901942, -0.6072962, 3.4283175, -1.5901942, -0.6822865, 3.7041378, -1.5981488, -0.41204357, 3.7041378, 1.3367851, -0.7557769, 4.014048, -2.5582986, -0.6822865, 3.7041378, -1.5981488, -0.83848286, 3.7041378, -2.5506816, -0.33841062, 3.979958, 1.3300035, -0.33159733, 3.7041378, 2.9530087, -0.41204357, 3.7041378, 1.3367851, -0.33841062, 3.979958, 1.3300035, -0.41204357, 3.7041378, 1.3367851, -0.6822865, 3.7041378, -1.5981488, -0.33841062, 3.979958, 1.3300035, -0.24889135, 4.014048, 2.9453917, -0.33159733, 3.7041378, 2.9530087, -0.1373241, 4.1818957, 1.3114836, -0.24889135, 4.014048, 2.9453917, -0.33841062, 3.979958, 1.3300035, -0.1373241, 4.1818957, 1.3114836, -0.035803795, 4.210655, 2.666542, -0.24889135, 4.014048, 2.9453917, -0.33841062, 3.979958, 1.3300035, -0.6822865, 3.7041378, -1.5981488, -0.6096499, 3.979958, -1.6157494, -0.1373241, 4.1818957, 1.3114836, -0.33841062, 3.979958, 1.3300035, -0.6096499, 3.979958, -1.6157494, -0.7557769, 4.014048, -2.5582986, -0.6096499, 3.979958, -1.6157494, -0.6822865, 3.7041378, -1.5981488, -0.1373241, 4.1818957, 1.3114836, -0.6096499, 3.979958, -1.6157494, -0.40862513, 4.1818337, -1.6342635, -0.5298786, 4.2409024, -2.5791035, -0.6096499, 3.979958, -1.6157494, -0.7557769, 4.014048, -2.5582986, -0.5298786, 4.2409024, -2.5791035, -0.40862513, 4.1818337, -1.6342635, -0.6096499, 3.979958, -1.6157494, -0.1373241, 3.2263799, 1.3114836, -0.13396716, 3.1524973, -1.6595592, -0.40790343, 3.2264419, -1.6264291, -0.5298786, 3.1673732, -2.5791035, -0.40790343, 3.2264419, -1.6264291, -0.13396716, 3.1524973, -1.6595592, -0.5298786, 3.1673732, -2.5791035, -0.13396716, 3.1524973, -1.6595592, -0.22127414, 3.0843172, -2.6075258, -0.22127414, 3.0843172, -2.6075258, -0.13396716, 3.1524973, -1.6595592, 0.14091396, 3.2264419, -1.68243, -0.22127414, 3.0843172, -2.6075258, 0.14091396, 3.2264419, -1.68243, 0.016053915, 3.1457415, -2.6231756, 0.13733363, 3.1524353, 1.2861879, 0.14091396, 3.2264419, -1.68243, -0.13396716, 3.1524973, -1.6595592, -0.1373241, 3.2263799, 1.3114836, 0.13733363, 3.1524353, 1.2861879, -0.13396716, 3.1524973, -1.6595592, 0.13733363, 3.1524353, 1.2861879, 0.4119916, 3.2263799, 1.2608922, 0.14091396, 3.2264419, -1.68243, 0.13733363, 3.1524353, 1.2861879, 0.5942155, 3.1673732, 2.867742, 0.4119916, 3.2263799, 1.2608922, 0.13733363, 3.1524353, 1.2861879, 0.28561115, 3.0843172, 2.8961644, 0.5942155, 3.1673732, 2.867742, -0.1373241, 3.2263799, 1.3114836, 0.28561115, 3.0843172, 2.8961644, 0.13733363, 3.1524353, 1.2861879, -0.1373241, 3.2263799, 1.3114836, -0.022993088, 3.1673732, 2.9245868, 0.28561115, 3.0843172, 2.8961644, 0.4119916, 3.2263799, 1.2608922, 0.34171534, 3.4283175, -1.7033694, 0.14091396, 3.2264419, -1.68243, 0.016053915, 3.1457415, -2.6231756, 0.14091396, 3.2264419, -1.68243, 0.34171534, 3.4283175, -1.7033694, 0.016053915, 3.1457415, -2.6231756, 0.34171534, 3.4283175, -1.7033694, 0.31322837, 3.3942275, -2.656753, 0.31322837, 3.3942275, -2.656753, 0.34171534, 3.4283175, -1.7033694, 0.4155202, 3.7041378, -1.7082856, 0.31322837, 3.3942275, -2.656753, 0.4155202, 3.7041378, -1.7082856, 0.39626074, 3.6751301, -2.6608262, 0.4119916, 3.2263799, 1.2608922, 0.613078, 3.4283175, 1.2423723, 0.34171534, 3.4283175, -1.7033694, 0.613078, 3.4283175, 1.2423723, 0.4155202, 3.7041378, -1.7082856, 0.34171534, 3.4283175, -1.7033694, 0.4119916, 3.2263799, 1.2608922, 0.8201138, 3.3942275, 2.8469372, 0.613078, 3.4283175, 1.2423723, 0.4119916, 3.2263799, 1.2608922, 0.5942155, 3.1673732, 2.867742, 0.8201138, 3.3942275, 2.8469372, 0.613078, 3.4283175, 1.2423723, 0.8201138, 3.3942275, 2.8469372, 0.90281975, 3.7041378, 2.8393202, 0.613078, 3.4283175, 1.2423723, 0.90281975, 3.7041378, 2.8393202, 0.68671095, 3.7041378, 1.2355907, 0.613078, 3.4283175, 1.2423723, 0.68671095, 3.7041378, 1.2355907, 0.4155202, 3.7041378, -1.7082856, 0.68671095, 3.7041378, 1.2355907, 0.90281975, 3.7041378, 2.8393202, 0.8201138, 4.014048, 2.8469372, 0.39626074, 3.6751301, -2.6608262, 0.4155202, 3.7041378, -1.7082856, 0.2700441, 3.9583263, -1.6948872, 0.68671095, 3.7041378, 1.2355907, 0.2700441, 3.9583263, -1.6948872, 0.4155202, 3.7041378, -1.7082856, 0.39626074, 3.6751301, -2.6608262, 0.2700441, 3.9583263, -1.6948872, 0.31322837, 4.014048, -2.656753, 0.68671095, 3.7041378, 1.2355907, 0.8201138, 4.014048, 2.8469372, 0.613078, 3.979958, 1.2423723, 0.68671095, 3.7041378, 1.2355907, 0.613078, 3.979958, 1.2423723, 0.2700441, 3.9583263, -1.6948872, 0.613078, 3.979958, 1.2423723, 0.8201138, 4.014048, 2.8469372, 0.5942155, 4.2409024, 2.867742, 0.613078, 3.979958, 1.2423723, 0.5942155, 4.2409024, 2.867742, 0.4119916, 4.1818957, 1.2608922, 0.613078, 3.979958, 1.2423723, 0.4119916, 4.1818957, 1.2608922, 0.14069057, 4.1818337, -1.684855, 0.613078, 3.979958, 1.2423723, 0.14069057, 4.1818337, -1.684855, 0.2700441, 3.9583263, -1.6948872, 0.31322837, 4.014048, -2.656753, 0.2700441, 3.9583263, -1.6948872, 0.14069057, 4.1818337, -1.684855, 0.31322837, 4.014048, -2.656753, 0.14069057, 4.1818337, -1.684855, 0.0873301, 4.2409024, -2.6359482, 0.4119916, 4.1818957, 1.2608922, -0.13396716, 4.2557163, -1.6595592, 0.14069057, 4.1818337, -1.684855, 0.0873301, 4.2409024, -2.6359482, 0.14069057, 4.1818337, -1.684855, -0.13396716, 4.2557163, -1.6595592, 0.0873301, 4.2409024, -2.6359482, -0.13396716, 4.2557163, -1.6595592, -0.22127414, 4.3239584, -2.6075258, -0.22127414, 4.3239584, -2.6075258, -0.13396716, 4.2557163, -1.6595592, -0.40862513, 4.1818337, -1.6342635, -0.22127414, 4.3239584, -2.6075258, -0.40862513, 4.1818337, -1.6342635, -0.5298786, 4.2409024, -2.5791035, 0.13733363, 4.2558403, 1.2861879, -0.40862513, 4.1818337, -1.6342635, -0.13396716, 4.2557163, -1.6595592, 0.4119916, 4.1818957, 1.2608922, 0.13733363, 4.2558403, 1.2861879, -0.13396716, 4.2557163, -1.6595592, 0.13733363, 4.2558403, 1.2861879, -0.1373241, 4.1818957, 1.3114836, -0.40862513, 4.1818337, -1.6342635, 0.13733363, 4.2558403, 1.2861879, -0.035803795, 4.210655, 2.666542, -0.1373241, 4.1818957, 1.3114836, 0.13733363, 4.2558403, 1.2861879, 0.28561115, 4.3239584, 2.8961644, -0.035803795, 4.210655, 2.666542, 0.4119916, 4.1818957, 1.2608922, 0.28561115, 4.3239584, 2.8961644, 0.13733363, 4.2558403, 1.2861879, 0.4119916, 4.1818957, 1.2608922, 0.5942155, 4.2409024, 2.867742, 0.28561115, 4.3239584, 2.8961644, -0.24889135, 3.3942275, 2.9453917, -0.022993088, 3.1673732, 2.9245868, -0.022993088, 3.1673732, 2.9245868, -0.022993088, 3.1673732, 2.9245868, -0.022993088, 3.1673732, 2.9245868, 0.28561115, 3.0843172, 2.8961644, -0.24889135, 3.3942275, 2.9453917, -0.24889135, 3.3942275, 2.9453917, -0.022993088, 3.1673732, 2.9245868, -0.33159733, 3.7041378, 2.9530087, -0.24889135, 3.3942275, 2.9453917, -0.24889135, 3.3942275, 2.9453917, -0.33159733, 3.7041378, 2.9530087, -0.33159733, 3.7041378, 2.9530087, -0.24889135, 3.3942275, 2.9453917, -0.022993088, 3.1673732, 2.9245868, 0.28561115, 3.0843172, 2.8961644, 0.28561115, 3.0843172, 2.8961644, 0.28561115, 3.0843172, 2.8961644, 0.28561115, 3.0843172, 2.8961644, 0.5942155, 3.1673732, 2.867742, 0.28561115, 3.0843172, 2.8961644, 0.5942155, 3.1673732, 2.867742, 0.5942155, 3.1673732, 2.867742, 0.5942155, 3.1673732, 2.867742, 0.5942155, 3.1673732, 2.867742, 0.8201138, 3.3942275, 2.8469372, 0.5942155, 3.1673732, 2.867742, 0.8201138, 3.3942275, 2.8469372, 0.8201138, 3.3942275, 2.8469372, 0.8201138, 3.3942275, 2.8469372, 0.8201138, 3.3942275, 2.8469372, 0.90281975, 3.7041378, 2.8393202, 0.8201138, 3.3942275, 2.8469372, 0.90281975, 3.7041378, 2.8393202, 0.90281975, 3.7041378, 2.8393202, 0.90281975, 3.7041378, 2.8393202, 0.90281975, 3.7041378, 2.8393202, 0.8201138, 4.014048, 2.8469372, 0.90281975, 3.7041378, 2.8393202, 0.8201138, 4.014048, 2.8469372, 0.8201138, 4.014048, 2.8469372, 0.8201138, 4.014048, 2.8469372, 0.8201138, 4.014048, 2.8469372, 0.5942155, 4.2409024, 2.867742, 0.8201138, 4.014048, 2.8469372, 0.5942155, 4.2409024, 2.867742, 0.5942155, 4.2409024, 2.867742, 0.5942155, 4.2409024, 2.867742, 0.5942155, 4.2409024, 2.867742, 0.28561115, 4.3239584, 2.8961644, 0.5942155, 4.2409024, 2.867742, 0.28561115, 4.3239584, 2.8961644, 0.28561115, 4.3239584, 2.8961644, 0.28561115, 4.3239584, 2.8961644, 0.28561115, 4.3239584, 2.8961644, -0.035803795, 4.210655, 2.666542, 0.28561115, 4.3239584, 2.8961644, -0.035803795, 4.210655, 2.666542, -0.035803795, 4.210655, 2.666542, -0.035803795, 4.210655, 2.666542, -0.24889135, 4.014048, 2.9453917, -0.24889135, 4.014048, 2.9453917, -0.035803795, 4.210655, 2.666542, -0.035803795, 4.210655, 2.666542, -0.24889135, 4.014048, 2.9453917, -0.24889135, 4.014048, 2.9453917, -0.33159733, 3.7041378, 2.9530087, -0.33159733, 3.7041378, 2.9530087, -0.24889135, 4.014048, 2.9453917, -0.24889135, 4.014048, 2.9453917, -0.33159733, 3.7041378, 2.9530087, -0.5298786, 3.1673732, -2.5791035, -0.75938535, 3.4076157, -2.5096202, -0.81789684, 3.3737113, -2.5042315, -0.81789684, 3.3737113, -2.5042315, -0.75938535, 3.4076157, -2.5096202, -0.7709601, 3.7041378, -2.5569, -0.5298786, 3.1673732, -2.5791035, -0.49611712, 3.2260702, -2.582213, -0.75938535, 3.4076157, -2.5096202, -0.81789684, 3.3737113, -2.5042315, -0.7709601, 3.7041378, -2.5569, -0.83848286, 3.7041378, -2.5506816, -0.22127414, 3.0843172, -2.6075258, -0.49611712, 3.2260702, -2.582213, -0.5298786, 3.1673732, -2.5791035, -0.83848286, 3.7041378, -2.5506816, -0.7709601, 3.7041378, -2.5569, -0.69732714, 3.980144, -2.5636816, -0.22127414, 3.0843172, -2.6075258, -0.22127414, 3.1521254, -2.6075258, -0.49611712, 3.2260702, -2.582213, -0.83848286, 3.7041378, -2.5506816, -0.69732714, 3.980144, -2.5636816, -0.7557769, 4.014048, -2.5582986, 0.016053915, 3.1457415, -2.6231756, -0.22127414, 3.1521254, -2.6075258, -0.22127414, 3.0843172, -2.6075258, -0.7557769, 4.014048, -2.5582986, -0.69732714, 3.980144, -2.5636816, -0.49611712, 4.1822057, -2.582213, 0.016053915, 3.1457415, -2.6231756, -0.017707348, 3.2044384, -2.6200662, -0.22127414, 3.1521254, -2.6075258, -0.7557769, 4.014048, -2.5582986, -0.49611712, 4.1822057, -2.582213, -0.5298786, 4.2409024, -2.5791035, 0.31322837, 3.3942275, -2.656753, -0.017707348, 3.2044384, -2.6200662, 0.016053915, 3.1457415, -2.6231756, -0.5298786, 4.2409024, -2.5791035, -0.49611712, 4.1822057, -2.582213, -0.22127414, 4.2561502, -2.6075258, 0.31322837, 3.3942275, -2.656753, 0.25477862, 3.4281316, -2.65137, -0.017707348, 3.2044384, -2.6200662, -0.5298786, 4.2409024, -2.5791035, -0.22127414, 4.2561502, -2.6075258, -0.22127414, 4.3239584, -2.6075258, 0.39626074, 3.6751301, -2.6608262, 0.25477862, 3.4281316, -2.65137, 0.31322837, 3.3942275, -2.656753, -0.22127414, 4.3239584, -2.6075258, -0.22127414, 4.2561502, -2.6075258, 0.05356884, 4.1822057, -2.6328387, 0.39626074, 3.6751301, -2.6608262, 0.32873797, 3.6751301, -2.6546073, 0.25477862, 3.4281316, -2.65137, -0.22127414, 4.3239584, -2.6075258, 0.05356884, 4.1822057, -2.6328387, 0.0873301, 4.2409024, -2.6359482, 0.31322837, 4.014048, -2.656753, 0.32873797, 3.6751301, -2.6546073, 0.39626074, 3.6751301, -2.6608262, 0.0873301, 4.2409024, -2.6359482, 0.05356884, 4.1822057, -2.6328387, 0.25477862, 3.980144, -2.65137, 0.31322837, 4.014048, -2.656753, 0.25477862, 3.980144, -2.65137, 0.32873797, 3.6751301, -2.6546073, 0.0873301, 4.2409024, -2.6359482, 0.25477862, 3.980144, -2.65137, 0.31322837, 4.014048, -2.656753, -0.24889135, 3.3942275, 2.9453917, 0.010768175, 3.2260702, 2.9214773, -0.022993088, 3.1673732, 2.9245868, -0.022993088, 3.1673732, 2.9245868, 0.010768175, 3.2260702, 2.9214773, 0.28561115, 3.1521254, 2.8961644, -0.24889135, 3.3942275, 2.9453917, -0.19044161, 3.4281316, 2.9400086, 0.010768175, 3.2260702, 2.9214773, -0.022993088, 3.1673732, 2.9245868, 0.28561115, 3.1521254, 2.8961644, 0.28561115, 3.0843172, 2.8961644, -0.33159733, 3.7041378, 2.9530087, -0.19044161, 3.4281316, 2.9400086, -0.24889135, 3.3942275, 2.9453917, 0.28561115, 3.0843172, 2.8961644, 0.28561115, 3.1521254, 2.8961644, 0.56045413, 3.2260702, 2.8708515, -0.33159733, 3.7041378, 2.9530087, -0.2640748, 3.7041378, 2.9467902, -0.19044161, 3.4281316, 2.9400086, 0.28561115, 3.0843172, 2.8961644, 0.56045413, 3.2260702, 2.8708515, 0.5942155, 3.1673732, 2.867742, -0.24889135, 4.014048, 2.9453917, -0.2640748, 3.7041378, 2.9467902, -0.33159733, 3.7041378, 2.9530087, 0.5942155, 3.1673732, 2.867742, 0.56045413, 3.2260702, 2.8708515, 0.76166415, 3.4281316, 2.8523202, -0.24889135, 4.014048, 2.9453917, -0.19044161, 3.980144, 2.9400086, -0.2640748, 3.7041378, 2.9467902, 0.5942155, 3.1673732, 2.867742, 0.76166415, 3.4281316, 2.8523202, 0.8201138, 3.3942275, 2.8469372, -0.035803795, 4.210655, 2.666542, -0.19044161, 3.980144, 2.9400086, -0.24889135, 4.014048, 2.9453917, 0.8201138, 3.3942275, 2.8469372, 0.76166415, 3.4281316, 2.8523202, 0.8352971, 3.7041378, 2.8455386, -0.035803795, 4.210655, 2.666542, -0.002042532, 4.1519585, 2.6634326, -0.19044161, 3.980144, 2.9400086, 0.8201138, 3.3942275, 2.8469372, 0.8352971, 3.7041378, 2.8455386, 0.90281975, 3.7041378, 2.8393202, 0.28561115, 4.3239584, 2.8961644, -0.002042532, 4.1519585, 2.6634326, -0.035803795, 4.210655, 2.666542, 0.90281975, 3.7041378, 2.8393202, 0.8352971, 3.7041378, 2.8455386, 0.76166415, 3.980144, 2.8523202, 0.28561115, 4.3239584, 2.8961644, 0.28561115, 4.2561502, 2.8961644, -0.002042532, 4.1519585, 2.6634326, 0.90281975, 3.7041378, 2.8393202, 0.76166415, 3.980144, 2.8523202, 0.8201138, 4.014048, 2.8469372, 0.5942155, 4.2409024, 2.867742, 0.28561115, 4.2561502, 2.8961644, 0.28561115, 4.3239584, 2.8961644, 0.8201138, 4.014048, 2.8469372, 0.76166415, 3.980144, 2.8523202, 0.56045413, 4.1822057, 2.8708515, 0.5942155, 4.2409024, 2.867742, 0.56045413, 4.1822057, 2.8708515, 0.28561115, 4.2561502, 2.8961644, 0.8201138, 4.014048, 2.8469372, 0.56045413, 4.1822057, 2.8708515, 0.5942155, 4.2409024, 2.867742, -0.49611712, 3.2260702, -2.582213, -0.75557137, 3.4076157, -2.4682097, -0.75938535, 3.4076157, -2.5096202, -0.49611712, 3.2260702, -2.582213, -0.49230337, 3.2260702, -2.5408025, -0.75557137, 3.4076157, -2.4682097, -0.22127414, 3.1521254, -2.6075258, -0.49230337, 3.2260702, -2.5408025, -0.49611712, 3.2260702, -2.582213, -0.22127414, 3.1521254, -2.6075258, -0.2174604, 3.1521254, -2.5661154, -0.49230337, 3.2260702, -2.5408025, -0.017707348, 3.2044384, -2.6200662, -0.2174604, 3.1521254, -2.5661154, -0.22127414, 3.1521254, -2.6075258, -0.017707348, 3.2044384, -2.6200662, -0.01391077, 3.2044384, -2.5788422, -0.2174604, 3.1521254, -2.5661154, 0.25477862, 3.4281316, -2.65137, -0.01391077, 3.2044384, -2.5788422, -0.017707348, 3.2044384, -2.6200662, 0.25477862, 3.4281316, -2.65137, 0.2585926, 3.4281316, -2.6099591, -0.01391077, 3.2044384, -2.5788422, 0.32873797, 3.6751301, -2.6546073, 0.2585926, 3.4281316, -2.6099591, 0.25477862, 3.4281316, -2.65137, 0.32873797, 3.6751301, -2.6546073, 0.33255196, 3.6751301, -2.6131968, 0.2585926, 3.4281316, -2.6099591, 0.25477862, 3.980144, -2.65137, 0.33255196, 3.6751301, -2.6131968, 0.32873797, 3.6751301, -2.6546073, 0.25477862, 3.980144, -2.65137, 0.2585926, 3.980144, -2.6099591, 0.33255196, 3.6751301, -2.6131968, 0.05356884, 4.1822057, -2.6328387, 0.2585926, 3.980144, -2.6099591, 0.25477862, 3.980144, -2.65137, 0.05356884, 4.1822057, -2.6328387, 0.057382584, 4.1822057, -2.5914283, 0.2585926, 3.980144, -2.6099591, -0.22127414, 4.2561502, -2.6075258, 0.057382584, 4.1822057, -2.5914283, 0.05356884, 4.1822057, -2.6328387, -0.22127414, 4.2561502, -2.6075258, -0.2174604, 4.2561502, -2.5661154, 0.057382584, 4.1822057, -2.5914283, -0.49611712, 4.1822057, -2.582213, -0.2174604, 4.2561502, -2.5661154, -0.22127414, 4.2561502, -2.6075258, -0.49611712, 4.1822057, -2.582213, -0.49230337, 4.1822057, -2.5408025, -0.2174604, 4.2561502, -2.5661154, -0.69732714, 3.980144, -2.5636816, -0.49230337, 4.1822057, -2.5408025, -0.49611712, 4.1822057, -2.582213, -0.69732714, 3.980144, -2.5636816, -0.6935134, 3.980144, -2.5222712, -0.49230337, 4.1822057, -2.5408025, -0.7709601, 3.7041378, -2.5569, -0.6935134, 3.980144, -2.5222712, -0.69732714, 3.980144, -2.5636816, -0.7709601, 3.7041378, -2.5569, -0.7671461, 3.7041378, -2.5154896, -0.6935134, 3.980144, -2.5222712, -0.75938535, 3.4076157, -2.5096202, -0.7671461, 3.7041378, -2.5154896, -0.7709601, 3.7041378, -2.5569, -0.75938535, 3.4076157, -2.5096202, -0.75557137, 3.4076157, -2.4682097, -0.7671461, 3.7041378, -2.5154896, -0.19044161, 3.4281316, 2.9400086, 0.0069544315, 3.2260702, 2.8800664, 0.010768175, 3.2260702, 2.9214773, -0.19044161, 3.4281316, 2.9400086, -0.19425559, 3.4281316, 2.8985977, 0.0069544315, 3.2260702, 2.8800664, 0.010768175, 3.2260702, 2.9214773, 0.2817974, 3.1521254, 2.8547535, 0.28561115, 3.1521254, 2.8961644, 0.010768175, 3.2260702, 2.9214773, 0.0069544315, 3.2260702, 2.8800664, 0.2817974, 3.1521254, 2.8547535, 0.28561115, 3.1521254, 2.8961644, 0.55664027, 3.2260702, 2.8294406, 0.56045413, 3.2260702, 2.8708515, 0.28561115, 3.1521254, 2.8961644, 0.2817974, 3.1521254, 2.8547535, 0.55664027, 3.2260702, 2.8294406, 0.56045413, 3.2260702, 2.8708515, 0.75785017, 3.4281316, 2.8109093, 0.76166415, 3.4281316, 2.8523202, 0.56045413, 3.2260702, 2.8708515, 0.55664027, 3.2260702, 2.8294406, 0.75785017, 3.4281316, 2.8109093, 0.76166415, 3.4281316, 2.8523202, 0.8314831, 3.7041378, 2.8041277, 0.8352971, 3.7041378, 2.8455386, 0.76166415, 3.4281316, 2.8523202, 0.75785017, 3.4281316, 2.8109093, 0.8314831, 3.7041378, 2.8041277, 0.8352971, 3.7041378, 2.8455386, 0.8314831, 3.7041378, 2.8041277, 0.75785017, 3.980144, 2.8109093, 0.8352971, 3.7041378, 2.8455386, 0.75785017, 3.980144, 2.8109093, 0.76166415, 3.980144, 2.8523202, 0.76166415, 3.980144, 2.8523202, 0.55664027, 4.1822057, 2.8294406, 0.56045413, 4.1822057, 2.8708515, 0.76166415, 3.980144, 2.8523202, 0.75785017, 3.980144, 2.8109093, 0.55664027, 4.1822057, 2.8294406, 0.56045413, 4.1822057, 2.8708515, 0.2817974, 4.2561502, 2.8547535, 0.28561115, 4.2561502, 2.8961644, 0.56045413, 4.1822057, 2.8708515, 0.55664027, 4.1822057, 2.8294406, 0.2817974, 4.2561502, 2.8547535, 0.28561115, 4.2561502, 2.8961644, -0.0058562756, 4.1519585, 2.6220222, -0.002042532, 4.1519585, 2.6634326, 0.28561115, 4.2561502, 2.8961644, 0.2817974, 4.2561502, 2.8547535, -0.0058562756, 4.1519585, 2.6220222, -0.002042532, 4.1519585, 2.6634326, -0.19425559, 3.980144, 2.8985977, -0.19044161, 3.980144, 2.9400086, -0.002042532, 4.1519585, 2.6634326, -0.0058562756, 4.1519585, 2.6220222, -0.19425559, 3.980144, 2.8985977, -0.19044161, 3.980144, 2.9400086, -0.26788855, 3.7041378, 2.9053793, -0.2640748, 3.7041378, 2.9467902, -0.19044161, 3.980144, 2.9400086, -0.19425559, 3.980144, 2.8985977, -0.26788855, 3.7041378, 2.9053793, -0.2640748, 3.7041378, 2.9467902, -0.26788855, 3.7041378, 2.9053793, -0.19425559, 3.4281316, 2.8985977, -0.2640748, 3.7041378, 2.9467902, -0.19425559, 3.4281316, 2.8985977, -0.19044161, 3.4281316, 2.9400086, -5.555945, -21.30843, 0.44781768, -3.54707, -21.129436, 0.3570392, -5.555945, -21.129436, 0.44781768, -5.555945, -21.30843, 0.44781768, -3.54707, -21.30843, 0.3570392, -3.54707, -21.129436, 0.3570392, -7.629112, -21.129436, 0.3570392, -5.555945, -21.30843, 0.44781768, -5.555945, -21.129436, 0.44781768, -7.629112, -21.129436, 0.3570392, -7.629112, -21.30843, 0.3570392, -5.555945, -21.30843, 0.44781768, -5.555945, -21.30843, 0.9271282, -3.54707, -21.30843, 0.3570392, -5.555945, -21.30843, 0.44781768, -5.555945, -21.30843, 0.9271282, -3.54707, -21.30843, 0.7632997, -3.54707, -21.30843, 0.3570392, -5.555945, -21.30843, 0.44781768, -7.629112, -21.30843, 0.91196275, -5.555945, -21.30843, 0.9271282, -5.555945, -21.30843, 0.44781768, -7.629112, -21.30843, 0.62980187, -7.629112, -21.30843, 0.91196275, -7.629112, -21.30843, 0.62980187, -5.555945, -21.30843, 0.44781768, -7.629112, -21.30843, 0.3570392, -3.54707, -21.30843, 0.7632997, -3.54707, -21.129436, 0.3570392, -3.54707, -21.30843, 0.3570392, -3.54707, -21.30843, 0.7632997, -3.54707, -21.129435, 0.7632997, -3.54707, -21.129436, 0.3570392, -7.629112, -21.129436, 0.5971217, -7.5507226, -21.183048, 0.6447537, -7.629112, -21.30843, 0.62980187, -5.555945, -21.129435, 0.80730057, -5.555945, -21.174292, 0.9271282, -6.0741305, -21.129435, 0.92328346, -5.555945, -21.129435, 0.80730057, -5.0537796, -21.129435, 0.8861177, -5.555945, -21.174292, 0.9271282, -5.555945, -21.129435, 0.80730057, -3.54707, -21.129435, 0.7632997, -5.0537796, -21.129435, 0.8861177, -5.555945, -21.129435, 0.80730057, -3.54707, -21.129436, 0.3570392, -3.54707, -21.129435, 0.7632997, -3.54707, -21.129436, 0.3570392, -5.555945, -21.129435, 0.80730057, -5.555945, -21.129436, 0.44781768, -6.0741305, -21.129435, 0.92328346, -5.555945, -21.129436, 0.44781768, -5.555945, -21.129435, 0.80730057, -5.555945, -21.129436, 0.44781768, -7.629112, -21.129436, 0.5971217, -7.629112, -21.129436, 0.3570392, -5.555945, -21.129436, 0.44781768, -7.2726197, -21.129435, 0.6462488, -7.629112, -21.129436, 0.5971217, -6.0741305, -21.129435, 0.92328346, -7.2726197, -21.129435, 0.6462488, -5.555945, -21.129436, 0.44781768, -7.5507226, -21.183048, 0.6447537, -7.629112, -21.129436, 0.5971217, -7.2726197, -21.129435, 0.6462488, -7.629112, -21.129435, 0.7325418, -7.5507226, -21.183048, 0.6447537, -7.2726197, -21.129435, 0.6462488, -7.2726197, -21.129435, 0.6462488, -6.0741305, -21.129435, 0.92328346, -7.629112, -21.129435, 0.7325418, -7.629112, -21.129435, 0.7325418, -6.0741305, -21.129435, 0.92328346, -7.629112, -21.129435, 0.91196275, -7.629112, -21.30843, 0.3570392, -7.629112, -21.129436, 0.5971217, -7.629112, -21.30843, 0.62980187, -7.629112, -21.30843, 0.3570392, -7.629112, -21.129436, 0.3570392, -7.629112, -21.129436, 0.5971217, -7.629112, -21.129435, 0.7325418, -7.629112, -21.30843, 0.91196275, -7.629112, -21.30843, 0.62980187, -7.629112, -21.129435, 0.7325418, -7.629112, -21.129435, 0.91196275, -7.629112, -21.30843, 0.91196275, -7.5507226, -21.183048, 0.6447537, -7.629112, -21.129435, 0.7325418, -7.629112, -21.30843, 0.62980187, -7.629112, -21.129435, 0.91196275, -5.555945, -21.30843, 0.9271282, -7.629112, -21.30843, 0.91196275, -5.555945, -21.30843, 0.9271282, -7.629112, -21.129435, 0.91196275, -6.0741305, -21.129435, 0.92328346, -5.555945, -21.30843, 0.9271282, -6.0741305, -21.129435, 0.92328346, -5.555945, -21.174292, 0.9271282, -5.555945, -21.174292, 0.9271282, -3.54707, -21.30843, 0.7632997, -5.555945, -21.30843, 0.9271282, -5.555945, -21.174292, 0.9271282, -3.54707, -21.129435, 0.7632997, -3.54707, -21.30843, 0.7632997, -3.54707, -21.129435, 0.7632997, -5.555945, -21.174292, 0.9271282, -5.0537796, -21.129435, 0.8861177, -3.988361, -21.129435, 2.1034038, -3.54707, -21.30843, 2.1130157, -3.988361, -21.30843, 2.1034038, -3.988361, -21.129435, 2.1034038, -3.54707, -21.129435, 2.1130157, -3.54707, -21.30843, 2.1130157, -3.988361, -21.30843, 2.1034038, -6.7189775, -21.129435, 2.110666, -3.988361, -21.129435, 2.1034038, -3.988361, -21.30843, 2.1034038, -7.629112, -21.17429, 2.1130157, -6.7189775, -21.129435, 2.110666, -7.629112, -21.17429, 2.1130157, -3.988361, -21.30843, 2.1034038, -7.629112, -21.30843, 2.1130157, -3.54707, -21.129435, 1.6317828, -3.988361, -21.129435, 2.1034038, -3.988361, -21.129435, 1.6206758, -6.7189775, -21.129435, 2.110666, -3.988361, -21.129435, 1.6206758, -3.988361, -21.129435, 2.1034038, -6.7189775, -21.129435, 2.110666, -7.629112, -21.129435, 1.7067552, -3.988361, -21.129435, 1.6206758, -7.629112, -21.129435, 1.7067552, -6.7189775, -21.129435, 2.110666, -7.629112, -21.129435, 2.0113437, -7.629112, -21.129435, 2.0113437, -6.7189775, -21.129435, 2.110666, -7.629112, -21.17429, 2.1130157, -3.988361, -21.129435, 2.1034038, -3.54707, -21.129435, 1.6317828, -3.78224, -21.129435, 1.8853219, -3.78224, -21.129435, 1.8853219, -3.54707, -21.129435, 1.6317828, -3.54707, -21.129435, 1.8257284, -3.54707, -21.129435, 1.8257284, -3.5872264, -21.197786, 1.8791275, -3.78224, -21.129435, 1.8853219, -3.5872264, -21.197786, 1.8791275, -3.54707, -21.129435, 1.959867, -3.78224, -21.129435, 1.8853219, -3.988361, -21.129435, 2.1034038, -3.78224, -21.129435, 1.8853219, -3.54707, -21.129435, 1.959867, -3.988361, -21.129435, 2.1034038, -3.54707, -21.129435, 1.959867, -3.54707, -21.129435, 2.1130157, -3.988361, -21.30843, 1.6206758, -3.54707, -21.129435, 1.6317828, -3.988361, -21.129435, 1.6206758, -3.988361, -21.30843, 1.6206758, -3.54707, -21.30843, 1.6317829, -3.54707, -21.129435, 1.6317828, -7.629112, -21.129435, 1.7067552, -3.988361, -21.30843, 1.6206758, -3.988361, -21.129435, 1.6206758, -7.629112, -21.129435, 1.7067552, -7.629112, -21.30843, 1.7067553, -3.988361, -21.30843, 1.6206758, -3.54707, -21.30843, 2.1130157, -3.988361, -21.30843, 1.6206758, -3.988361, -21.30843, 2.1034038, -3.988361, -21.30843, 1.6206758, -3.54707, -21.30843, 2.1130157, -3.54707, -21.30843, 1.8528552, -3.988361, -21.30843, 1.6206758, -3.54707, -21.30843, 1.8528552, -3.54707, -21.30843, 1.6317829, -7.629112, -21.30843, 1.7067553, -3.988361, -21.30843, 2.1034038, -3.988361, -21.30843, 1.6206758, -7.629112, -21.30843, 1.7067553, -7.629112, -21.30843, 2.1130157, -3.988361, -21.30843, 2.1034038, -3.54707, -21.129435, 1.959867, -3.5872264, -21.197786, 1.8791275, -3.54707, -21.30843, 1.8528552, -3.5872264, -21.197786, 1.8791275, -3.54707, -21.129435, 1.8257284, -3.54707, -21.30843, 1.8528552, -3.54707, -21.30843, 2.1130157, -3.54707, -21.129435, 1.959867, -3.54707, -21.30843, 1.8528552, -3.54707, -21.30843, 2.1130157, -3.54707, -21.129435, 2.1130157, -3.54707, -21.129435, 1.959867, -3.54707, -21.129435, 1.8257284, -3.54707, -21.30843, 1.6317829, -3.54707, -21.30843, 1.8528552, -3.54707, -21.129435, 1.8257284, -3.54707, -21.129435, 1.6317828, -3.54707, -21.30843, 1.6317829, -7.629112, -21.129435, 1.7067552, -7.629112, -21.30843, 2.1130157, -7.629112, -21.30843, 1.7067553, -7.629112, -21.30843, 2.1130157, -7.629112, -21.129435, 1.7067552, -7.629112, -21.129435, 2.0113437, -7.629112, -21.30843, 2.1130157, -7.629112, -21.129435, 2.0113437, -7.629112, -21.17429, 2.1130157, -3.9170194, -21.320818, -0.44629717, -3.8958735, -21.335342, -0.95978284, -3.8958735, -21.34047, -0.43775296, -3.9170194, -21.320818, -0.44629717, -3.9170194, -21.31612, -0.95081186, -3.8958735, -21.335342, -0.95978284, -3.9170194, -21.31612, -0.95081186, -3.8958735, -21.210388, -0.979434, -3.8958735, -21.335342, -0.95978284, -3.9170194, -21.31612, -0.95081186, -3.9170194, -21.215515, -0.9587152, -3.8958735, -21.210388, -0.979434, -3.8958735, -21.34047, -0.43775296, -3.9170194, -21.35478, -0.9687538, -3.9170194, -21.36012, -0.4289956, -3.8958735, -21.34047, -0.43775296, -3.8958735, -21.335342, -0.95978284, -3.9170194, -21.35478, -0.9687538, -3.8958735, -21.335342, -0.95978284, -3.9170194, -21.205263, -1.0001528, -3.9170194, -21.35478, -0.9687538, -3.8958735, -21.335342, -0.95978284, -3.8958735, -21.210388, -0.979434, -3.9170194, -21.205263, -1.0001528, -3.9170194, -21.36012, -0.4289956, -3.9385924, -21.335342, -0.95978284, -3.9385924, -21.34047, -0.43775296, -3.9170194, -21.36012, -0.4289956, -3.9170194, -21.35478, -0.9687538, -3.9385924, -21.335342, -0.95978284, -3.9170194, -21.35478, -0.9687538, -3.9385924, -21.210388, -0.979434, -3.9385924, -21.335342, -0.95978284, -3.9170194, -21.35478, -0.9687538, -3.9170194, -21.205263, -1.0001528, -3.9385924, -21.210388, -0.979434, -3.9385924, -21.34047, -0.43775296, -3.9170194, -21.31612, -0.95081186, -3.9170194, -21.320818, -0.44629717, -3.9385924, -21.34047, -0.43775296, -3.9385924, -21.335342, -0.95978284, -3.9170194, -21.31612, -0.95081186, -3.9385924, -21.335342, -0.95978284, -3.9170194, -21.215515, -0.9587152, -3.9170194, -21.31612, -0.95081186, -3.9385924, -21.335342, -0.95978284, -3.9385924, -21.210388, -0.979434, -3.9170194, -21.215515, -0.9587152, -3.9170194, -21.215515, -0.9587152, -3.8958735, -21.105513, -0.94718075, -3.8958735, -21.210388, -0.979434, -3.9170194, -21.215515, -0.9587152, -3.9170194, -21.126446, -0.9426954, -3.8958735, -21.105513, -0.94718075, -3.9170194, -21.126446, -0.9426954, -3.8958735, -21.10359, -0.44181156, -3.8958735, -21.105513, -0.94718075, -3.9170194, -21.126446, -0.9426954, -3.9170194, -21.12431, -0.44693804, -3.8958735, -21.10359, -0.44181156, -3.9170194, -21.12431, -0.44693804, -3.8958735, -21.219788, -0.39460683, -3.8958735, -21.10359, -0.44181156, -3.9170194, -21.12431, -0.44693804, -3.9170194, -21.22406, -0.4155395, -3.8958735, -21.219788, -0.39460683, -3.8958735, -21.210388, -0.979434, -3.9170194, -21.084795, -0.95188, -3.9170194, -21.205263, -1.0001528, -3.8958735, -21.210388, -0.979434, -3.8958735, -21.105513, -0.94718075, -3.9170194, -21.084795, -0.95188, -3.8958735, -21.105513, -0.94718075, -3.9170194, -21.082659, -0.43668532, -3.9170194, -21.084795, -0.95188, -3.8958735, -21.105513, -0.94718075, -3.8958735, -21.10359, -0.44181156, -3.9170194, -21.082659, -0.43668532, -3.8958735, -21.10359, -0.44181156, -3.9170194, -21.215515, -0.3736744, -3.9170194, -21.082659, -0.43668532, -3.8958735, -21.10359, -0.44181156, -3.8958735, -21.219788, -0.39460683, -3.9170194, -21.215515, -0.3736744, -3.9170194, -21.205263, -1.0001528, -3.9385924, -21.105513, -0.94718075, -3.9385924, -21.210388, -0.979434, -3.9170194, -21.205263, -1.0001528, -3.9170194, -21.084795, -0.95188, -3.9385924, -21.105513, -0.94718075, -3.9385924, -21.210388, -0.979434, -3.9170194, -21.126446, -0.9426954, -3.9170194, -21.215515, -0.9587152, -3.9385924, -21.210388, -0.979434, -3.9385924, -21.105513, -0.94718075, -3.9170194, -21.126446, -0.9426954, -3.9385924, -21.105513, -0.94718075, -3.9170194, -21.12431, -0.44693804, -3.9170194, -21.126446, -0.9426954, -3.9385924, -21.105513, -0.94718075, -3.9385924, -21.10359, -0.44181156, -3.9170194, -21.12431, -0.44693804, -3.9385924, -21.10359, -0.44181156, -3.9170194, -21.22406, -0.4155395, -3.9170194, -21.12431, -0.44693804, -3.9385924, -21.10359, -0.44181156, -3.9385924, -21.219788, -0.39460683, -3.9170194, -21.22406, -0.4155395, -3.9170194, -21.084795, -0.95188, -3.9385924, -21.10359, -0.44181156, -3.9385924, -21.105513, -0.94718075, -3.9170194, -21.084795, -0.95188, -3.9170194, -21.082659, -0.43668532, -3.9385924, -21.10359, -0.44181156, -3.9170194, -21.082659, -0.43668532, -3.9385924, -21.219788, -0.39460683, -3.9385924, -21.10359, -0.44181156, -3.9170194, -21.082659, -0.43668532, -3.9170194, -21.215515, -0.3736744, -3.9385924, -21.219788, -0.39460683, -3.9170194, -21.22406, -0.4155395, -3.8958735, -21.34047, -0.43775296, -3.8958735, -21.219788, -0.39460683, -3.9170194, -21.22406, -0.4155395, -3.9170194, -21.320818, -0.44629717, -3.8958735, -21.34047, -0.43775296, -3.8958735, -21.219788, -0.39460683, -3.9170194, -21.36012, -0.4289956, -3.9170194, -21.215515, -0.3736744, -3.8958735, -21.219788, -0.39460683, -3.8958735, -21.34047, -0.43775296, -3.9170194, -21.36012, -0.4289956, -3.9170194, -21.215515, -0.3736744, -3.9385924, -21.34047, -0.43775296, -3.9385924, -21.219788, -0.39460683, -3.9170194, -21.215515, -0.3736744, -3.9170194, -21.36012, -0.4289956, -3.9385924, -21.34047, -0.43775296, -3.9385924, -21.219788, -0.39460683, -3.9170194, -21.320818, -0.44629717, -3.9170194, -21.22406, -0.4155395, -3.9385924, -21.219788, -0.39460683, -3.9385924, -21.34047, -0.43775296, -3.9170194, -21.320818, -0.44629717, -7.464643, -21.40476, 5.450674, -7.3512235, -21.359264, 5.450674, -7.3512235, -21.40476, 5.450674, -7.464643, -21.40476, 5.450674, -7.464643, -21.359264, 5.450674, -7.3512235, -21.359264, 5.450674, -7.464643, -21.359264, 5.450674, -7.3512235, -21.359266, -1.0281336, -7.3512235, -21.359264, 5.450674, -7.464643, -21.359264, 5.450674, -7.464643, -21.359266, -1.0281336, -7.3512235, -21.359266, -1.0281336, -7.464643, -21.359266, -1.0281336, -7.3512235, -21.404762, -1.0281336, -7.3512235, -21.359266, -1.0281336, -7.464643, -21.359266, -1.0281336, -7.464643, -21.404762, -1.0281336, -7.3512235, -21.404762, -1.0281336, -7.464643, -21.404762, -1.0281336, -7.3512235, -21.40476, 5.450674, -7.3512235, -21.404762, -1.0281336, -7.464643, -21.404762, -1.0281336, -7.464643, -21.40476, 5.450674, -7.3512235, -21.40476, 5.450674, -7.3512235, -21.40476, 5.450674, -7.3512235, -21.359266, -1.0281336, -7.3512235, -21.404762, -1.0281336, -7.3512235, -21.40476, 5.450674, -7.3512235, -21.359264, 5.450674, -7.3512235, -21.359266, -1.0281336, -7.464643, -21.404762, -1.0281336, -7.464643, -21.359264, 5.450674, -7.464643, -21.40476, 5.450674, -7.464643, -21.404762, -1.0281336, -7.464643, -21.359266, -1.0281336, -7.464643, -21.359264, 5.450674, -6.9564967, -21.30843, -0.21347666, -3.4046016, -21.129436, -0.29165316, -6.9564967, -21.129436, -0.2134769, -6.9564967, -21.30843, -0.21347666, -3.4046016, -21.30843, -0.29165292, -3.4046016, -21.129436, -0.29165316, -7.7713675, -21.129436, -0.21689463, -6.9564967, -21.30843, -0.21347666, -6.9564967, -21.129436, -0.2134769, -7.7713675, -21.129436, -0.21689463, -7.7713675, -21.30843, -0.21689439, -6.9564967, -21.30843, -0.21347666, -6.9564967, -21.30843, 0.28014445, -3.4046016, -21.30843, -0.29165292, -6.9564967, -21.30843, -0.21347666, -6.9564967, -21.30843, 0.28014445, -3.4046016, -21.30843, 0.1893661, -3.4046016, -21.30843, -0.29165292, -7.7713675, -21.30843, -0.21689439, -6.9564967, -21.30843, 0.28014445, -6.9564967, -21.30843, -0.21347666, -7.7713675, -21.30843, -0.21689439, -7.7713675, -21.30843, 0.1893661, -6.9564967, -21.30843, 0.28014445, -7.7713675, -21.30843, -0.21689439, -7.7713675, -21.129436, 0.18936586, -7.7713675, -21.30843, 0.1893661, -7.7713675, -21.30843, -0.21689439, -7.7713675, -21.129436, -0.21689463, -7.7713675, -21.129436, 0.18936586, -6.9564967, -21.129436, 0.15668559, -6.9564967, -21.174292, 0.28014445, -7.1602683, -21.129436, 0.25750327, -6.9564967, -21.129436, 0.15668559, -6.0685763, -21.129436, 0.25750327, -6.9564967, -21.174292, 0.28014445, -6.9564967, -21.129436, 0.15668559, -4.0705953, -21.129436, 0.20624006, -6.0685763, -21.129436, 0.25750327, -7.1602683, -21.129436, 0.25750327, -6.9564967, -21.129436, -0.2134769, -6.9564967, -21.129436, 0.15668559, -7.1602683, -21.129436, 0.25750327, -7.7713675, -21.129436, -0.21689463, -6.9564967, -21.129436, -0.2134769, -7.7713675, -21.129436, -0.21689463, -7.1602683, -21.129436, 0.25750327, -7.7713675, -21.129436, 0.18936586, -3.4046016, -21.129436, -0.29165316, -6.9564967, -21.129436, 0.15668559, -6.9564967, -21.129436, -0.2134769, -6.9564967, -21.129436, 0.15668559, -3.4046016, -21.129436, -0.29165316, -4.0705953, -21.129436, 0.20624006, -4.0705953, -21.129436, 0.20624006, -3.4046016, -21.129436, -0.29165316, -3.4046016, -21.129436, 0.06911111, -4.0705953, -21.129436, 0.20624006, -3.4046016, -21.129436, 0.06911111, -3.4046016, -21.174292, 0.18936586, -7.7713675, -21.129436, 0.18936586, -6.9564967, -21.30843, 0.28014445, -7.7713675, -21.30843, 0.1893661, -6.9564967, -21.30843, 0.28014445, -7.7713675, -21.129436, 0.18936586, -7.1602683, -21.129436, 0.25750327, -6.9564967, -21.30843, 0.28014445, -7.1602683, -21.129436, 0.25750327, -6.9564967, -21.174292, 0.28014445, -6.9564967, -21.174292, 0.28014445, -3.4046016, -21.30843, 0.1893661, -6.9564967, -21.30843, 0.28014445, -6.9564967, -21.174292, 0.28014445, -4.0705953, -21.129436, 0.20624006, -3.4046016, -21.30843, 0.1893661, -3.4046016, -21.30843, 0.1893661, -4.0705953, -21.129436, 0.20624006, -3.4046016, -21.174292, 0.18936586, -4.0705953, -21.129436, 0.20624006, -6.9564967, -21.174292, 0.28014445, -6.0685763, -21.129436, 0.25750327, -3.4046016, -21.174292, 0.18936586, -3.4046016, -21.30843, -0.29165292, -3.4046016, -21.30843, 0.1893661, -3.4046016, -21.30843, -0.29165292, -3.4046016, -21.174292, 0.18936586, -3.4046016, -21.129436, 0.06911111, -3.4046016, -21.30843, -0.29165292, -3.4046016, -21.129436, 0.06911111, -3.4046016, -21.129436, -0.29165316, -7.7320657, -21.3975, -1.0330465, -7.351437, -19.082968, -0.9484632, -7.4360213, -21.3975, -1.0330465, -7.7320657, -21.3975, -1.0330465, -7.81665, -19.082968, -0.9484632, -7.351437, -19.082968, -0.9484632, -7.81665, -19.082968, -0.9484632, -7.351437, -19.082968, -1.4134626, -7.351437, -19.082968, -0.9484632, -7.81665, -19.082968, -0.9484632, -7.81665, -19.082968, -1.4134626, -7.351437, -19.082968, -1.4134626, -7.81665, -19.082968, -1.4134626, -7.4360213, -21.3975, -1.3288774, -7.351437, -19.082968, -1.4134626, -7.81665, -19.082968, -1.4134626, -7.7320657, -21.3975, -1.3288774, -7.4360213, -21.3975, -1.3288774, -7.7320657, -21.3975, -1.3288774, -7.4360213, -21.3975, -1.0330465, -7.4360213, -21.3975, -1.3288774, -7.7320657, -21.3975, -1.3288774, -7.7320657, -21.3975, -1.0330465, -7.4360213, -21.3975, -1.0330465, -7.4360213, -21.3975, -1.0330465, -7.351437, -19.082968, -1.4134626, -7.4360213, -21.3975, -1.3288774, -7.4360213, -21.3975, -1.0330465, -7.351437, -19.082968, -0.9484632, -7.351437, -19.082968, -1.4134626, -7.7320657, -21.3975, -1.3288774, -7.81665, -19.082968, -0.9484632, -7.7320657, -21.3975, -1.0330465, -7.7320657, -21.3975, -1.3288774, -7.81665, -19.082968, -1.4134626, -7.81665, -19.082968, -0.9484632, -3.988361, -21.129436, -0.4571905, -3.54707, -21.30843, -0.4477923, -3.988361, -21.30843, -0.45719028, -3.988361, -21.129436, -0.4571905, -3.54707, -21.129436, -0.44779253, -3.54707, -21.30843, -0.4477923, -3.988361, -21.30843, -0.45719028, -6.7189775, -21.129436, -0.4501419, -3.988361, -21.129436, -0.4571905, -3.988361, -21.30843, -0.45719028, -7.629112, -21.174292, -0.44779253, -6.7189775, -21.129436, -0.4501419, -7.629112, -21.174292, -0.44779253, -3.988361, -21.30843, -0.45719028, -7.629112, -21.30843, -0.4477923, -3.54707, -21.129436, -0.92881155, -3.988361, -21.129436, -0.4571905, -3.988361, -21.129436, -0.9401324, -6.7189775, -21.129436, -0.4501419, -3.988361, -21.129436, -0.9401324, -3.988361, -21.129436, -0.4571905, -6.7189775, -21.129436, -0.4501419, -7.629112, -21.129436, -0.8540528, -3.988361, -21.129436, -0.9401324, -7.629112, -21.129436, -0.8540528, -6.7189775, -21.129436, -0.4501419, -7.629112, -21.129436, -0.5492506, -7.629112, -21.129436, -0.5492506, -6.7189775, -21.129436, -0.4501419, -7.629112, -21.174292, -0.44779253, -3.988361, -21.129436, -0.4571905, -3.54707, -21.129436, -0.92881155, -3.78224, -21.129436, -0.6754861, -3.78224, -21.129436, -0.6754861, -3.54707, -21.129436, -0.92881155, -3.54707, -21.129436, -0.7350795, -3.54707, -21.129436, -0.7350795, -3.5872264, -21.197786, -0.68168044, -3.78224, -21.129436, -0.6754861, -3.5872264, -21.197786, -0.68168044, -3.54707, -21.129436, -0.6009412, -3.78224, -21.129436, -0.6754861, -3.988361, -21.129436, -0.4571905, -3.78224, -21.129436, -0.6754861, -3.54707, -21.129436, -0.6009412, -3.988361, -21.129436, -0.4571905, -3.54707, -21.129436, -0.6009412, -3.54707, -21.129436, -0.44779253, -3.988361, -21.30843, -0.94013214, -3.54707, -21.129436, -0.92881155, -3.988361, -21.129436, -0.9401324, -3.988361, -21.30843, -0.94013214, -3.54707, -21.30843, -0.9288113, -3.54707, -21.129436, -0.92881155, -7.629112, -21.129436, -0.8540528, -3.988361, -21.30843, -0.94013214, -3.988361, -21.129436, -0.9401324, -7.629112, -21.129436, -0.8540528, -7.629112, -21.30843, -0.85405254, -3.988361, -21.30843, -0.94013214, -3.54707, -21.30843, -0.4477923, -3.988361, -21.30843, -0.94013214, -3.988361, -21.30843, -0.45719028, -3.988361, -21.30843, -0.94013214, -3.54707, -21.30843, -0.4477923, -3.54707, -21.30843, -0.7079525, -3.988361, -21.30843, -0.94013214, -3.54707, -21.30843, -0.7079525, -3.54707, -21.30843, -0.9288113, -7.629112, -21.30843, -0.85405254, -3.988361, -21.30843, -0.45719028, -3.988361, -21.30843, -0.94013214, -7.629112, -21.30843, -0.85405254, -7.629112, -21.30843, -0.4477923, -3.988361, -21.30843, -0.45719028, -3.54707, -21.129436, -0.6009412, -3.5872264, -21.197786, -0.68168044, -3.54707, -21.30843, -0.7079525, -3.5872264, -21.197786, -0.68168044, -3.54707, -21.129436, -0.7350795, -3.54707, -21.30843, -0.7079525, -3.54707, -21.30843, -0.4477923, -3.54707, -21.129436, -0.6009412, -3.54707, -21.30843, -0.7079525, -3.54707, -21.30843, -0.4477923, -3.54707, -21.129436, -0.44779253, -3.54707, -21.129436, -0.6009412, -3.54707, -21.129436, -0.7350795, -3.54707, -21.30843, -0.9288113, -3.54707, -21.30843, -0.7079525, -3.54707, -21.129436, -0.7350795, -3.54707, -21.129436, -0.92881155, -3.54707, -21.30843, -0.9288113, -7.629112, -21.129436, -0.8540528, -7.629112, -21.30843, -0.4477923, -7.629112, -21.30843, -0.85405254, -7.629112, -21.30843, -0.4477923, -7.629112, -21.129436, -0.8540528, -7.629112, -21.129436, -0.5492506, -7.629112, -21.30843, -0.4477923, -7.629112, -21.129436, -0.5492506, -7.629112, -21.174292, -0.44779253, -3.8766494, -21.129435, 2.6811824, -3.4046016, -21.308428, 2.6905806, -3.8766494, -21.308428, 2.6811824, -3.8766494, -21.129435, 2.6811824, -3.4046016, -21.129435, 2.6905804, -3.4046016, -21.308428, 2.6905806, -3.8766494, -21.308428, 2.6811824, -6.797795, -21.129435, 2.688231, -3.8766494, -21.129435, 2.6811824, -3.8766494, -21.308428, 2.6811824, -7.7713675, -21.17429, 2.6905804, -6.797795, -21.129435, 2.688231, -7.7713675, -21.17429, 2.6905804, -3.8766494, -21.308428, 2.6811824, -7.7713675, -21.308428, 2.6905806, -3.4046016, -21.129435, 2.2095613, -3.8766494, -21.129435, 2.6811824, -3.8766494, -21.129435, 2.1984541, -6.797795, -21.129435, 2.688231, -3.8766494, -21.129435, 2.1984541, -3.8766494, -21.129435, 2.6811824, -6.797795, -21.129435, 2.688231, -7.7713675, -21.129435, 2.28432, -3.8766494, -21.129435, 2.1984541, -7.7713675, -21.129435, 2.28432, -6.797795, -21.129435, 2.688231, -7.7713675, -21.129435, 2.5891223, -7.7713675, -21.129435, 2.5891223, -6.797795, -21.129435, 2.688231, -7.7713675, -21.17429, 2.6905804, -3.8766494, -21.129435, 2.6811824, -3.4046016, -21.129435, 2.2095613, -3.656218, -21.129435, 2.4631002, -3.656218, -21.129435, 2.4631002, -3.4046016, -21.129435, 2.2095613, -3.4046016, -21.129435, 2.4032931, -3.4046016, -21.129435, 2.4032931, -3.4477482, -21.197786, 2.4566922, -3.656218, -21.129435, 2.4631002, -3.4477482, -21.197786, 2.4566922, -3.4046016, -21.129435, 2.5376453, -3.656218, -21.129435, 2.4631002, -3.8766494, -21.129435, 2.6811824, -3.656218, -21.129435, 2.4631002, -3.4046016, -21.129435, 2.5376453, -3.8766494, -21.129435, 2.6811824, -3.4046016, -21.129435, 2.5376453, -3.4046016, -21.129435, 2.6905804, -3.8766494, -21.30843, 2.1984544, -3.4046016, -21.129435, 2.2095613, -3.8766494, -21.129435, 2.1984541, -3.8766494, -21.30843, 2.1984544, -3.4046016, -21.30843, 2.2095613, -3.4046016, -21.129435, 2.2095613, -7.7713675, -21.129435, 2.28432, -3.8766494, -21.30843, 2.1984544, -3.8766494, -21.129435, 2.1984541, -7.7713675, -21.129435, 2.28432, -7.7713675, -21.30843, 2.28432, -3.8766494, -21.30843, 2.1984544, -3.4046016, -21.308428, 2.6905806, -3.8766494, -21.30843, 2.1984544, -3.8766494, -21.308428, 2.6811824, -3.8766494, -21.30843, 2.1984544, -3.4046016, -21.308428, 2.6905806, -3.4046016, -21.308428, 2.4306335, -3.8766494, -21.30843, 2.1984544, -3.4046016, -21.308428, 2.4306335, -3.4046016, -21.30843, 2.2095613, -7.7713675, -21.30843, 2.28432, -3.8766494, -21.308428, 2.6811824, -3.8766494, -21.30843, 2.1984544, -7.7713675, -21.30843, 2.28432, -7.7713675, -21.308428, 2.6905806, -3.8766494, -21.308428, 2.6811824, -3.4046016, -21.129435, 2.5376453, -3.4477482, -21.197786, 2.4566922, -3.4046016, -21.308428, 2.4306335, -3.4477482, -21.197786, 2.4566922, -3.4046016, -21.129435, 2.4032931, -3.4046016, -21.308428, 2.4306335, -3.4046016, -21.308428, 2.6905806, -3.4046016, -21.129435, 2.5376453, -3.4046016, -21.308428, 2.4306335, -3.4046016, -21.308428, 2.6905806, -3.4046016, -21.129435, 2.6905804, -3.4046016, -21.129435, 2.5376453, -3.4046016, -21.129435, 2.4032931, -3.4046016, -21.30843, 2.2095613, -3.4046016, -21.308428, 2.4306335, -3.4046016, -21.129435, 2.4032931, -3.4046016, -21.129435, 2.2095613, -3.4046016, -21.30843, 2.2095613, -7.7713675, -21.129435, 2.28432, -7.7713675, -21.308428, 2.6905806, -7.7713675, -21.30843, 2.28432, -7.7713675, -21.308428, 2.6905806, -7.7713675, -21.129435, 2.28432, -7.7713675, -21.129435, 2.5891223, -7.7713675, -21.308428, 2.6905806, -7.7713675, -21.129435, 2.5891223, -7.7713675, -21.17429, 2.6905804, -5.5538087, -21.308428, 2.944333, -3.4046016, -21.129435, 2.853768, -5.5538087, -21.129435, 2.944333, -5.5538087, -21.308428, 2.944333, -3.4046016, -21.308428, 2.8537683, -3.4046016, -21.129435, 2.853768, -7.7713675, -21.129435, 2.853768, -5.5538087, -21.308428, 2.944333, -5.5538087, -21.129435, 2.944333, -7.7713675, -21.129435, 2.853768, -7.7713675, -21.308428, 2.8537683, -5.5538087, -21.308428, 2.944333, -5.5538087, -21.308428, 3.4236436, -3.4046016, -21.308428, 2.8537683, -5.5538087, -21.308428, 2.944333, -5.5538087, -21.308428, 3.4236436, -3.4046016, -21.308428, 3.2600288, -3.4046016, -21.308428, 2.8537683, -5.5538087, -21.308428, 2.944333, -7.7713675, -21.308428, 3.4084783, -5.5538087, -21.308428, 3.4236436, -5.5538087, -21.308428, 2.944333, -7.7713675, -21.308428, 3.126531, -7.7713675, -21.308428, 3.4084783, -7.7713675, -21.308428, 3.126531, -5.5538087, -21.308428, 2.944333, -7.7713675, -21.308428, 2.8537683, -3.4046016, -21.308428, 3.2600288, -3.4046016, -21.129435, 2.853768, -3.4046016, -21.308428, 2.8537683, -3.4046016, -21.308428, 3.2600288, -3.4046016, -21.129435, 3.2600288, -3.4046016, -21.129435, 2.853768, -7.7713675, -21.129435, 3.0938506, -7.687638, -21.183048, 3.1414826, -7.7713675, -21.308428, 3.126531, -5.5538087, -21.129435, 3.3038158, -5.5538087, -21.17429, 3.4236436, -6.108092, -21.129435, 3.4197989, -5.5538087, -21.129435, 3.3038158, -5.0164003, -21.129435, 3.3828466, -5.5538087, -21.17429, 3.4236436, -5.5538087, -21.129435, 3.3038158, -3.4046016, -21.129435, 3.2600288, -5.0164003, -21.129435, 3.3828466, -5.5538087, -21.129435, 3.3038158, -3.4046016, -21.129435, 2.853768, -3.4046016, -21.129435, 3.2600288, -3.4046016, -21.129435, 2.853768, -5.5538087, -21.129435, 3.3038158, -5.5538087, -21.129435, 2.944333, -6.108092, -21.129435, 3.4197989, -5.5538087, -21.129435, 2.944333, -5.5538087, -21.129435, 3.3038158, -5.5538087, -21.129435, 2.944333, -7.7713675, -21.129435, 3.0938506, -7.7713675, -21.129435, 2.853768, -5.5538087, -21.129435, 2.944333, -7.3903117, -21.129435, 3.142764, -7.7713675, -21.129435, 3.0938506, -6.108092, -21.129435, 3.4197989, -7.3903117, -21.129435, 3.142764, -5.5538087, -21.129435, 2.944333, -7.687638, -21.183048, 3.1414826, -7.7713675, -21.129435, 3.0938506, -7.3903117, -21.129435, 3.142764, -7.7713675, -21.129435, 3.2290573, -7.687638, -21.183048, 3.1414826, -7.3903117, -21.129435, 3.142764, -7.3903117, -21.129435, 3.142764, -6.108092, -21.129435, 3.4197989, -7.7713675, -21.129435, 3.2290573, -7.7713675, -21.129435, 3.2290573, -6.108092, -21.129435, 3.4197989, -7.7713675, -21.129435, 3.4084783, -7.7713675, -21.308428, 2.8537683, -7.7713675, -21.129435, 3.0938506, -7.7713675, -21.308428, 3.126531, -7.7713675, -21.308428, 2.8537683, -7.7713675, -21.129435, 2.853768, -7.7713675, -21.129435, 3.0938506, -7.7713675, -21.129435, 3.2290573, -7.7713675, -21.308428, 3.4084783, -7.7713675, -21.308428, 3.126531, -7.7713675, -21.129435, 3.2290573, -7.7713675, -21.129435, 3.4084783, -7.7713675, -21.308428, 3.4084783, -7.687638, -21.183048, 3.1414826, -7.7713675, -21.129435, 3.2290573, -7.7713675, -21.308428, 3.126531, -7.7713675, -21.129435, 3.4084783, -5.5538087, -21.308428, 3.4236436, -7.7713675, -21.308428, 3.4084783, -5.5538087, -21.308428, 3.4236436, -7.7713675, -21.129435, 3.4084783, -6.108092, -21.129435, 3.4197989, -5.5538087, -21.308428, 3.4236436, -6.108092, -21.129435, 3.4197989, -5.5538087, -21.17429, 3.4236436, -5.5538087, -21.17429, 3.4236436, -3.4046016, -21.308428, 3.2600288, -5.5538087, -21.308428, 3.4236436, -5.5538087, -21.17429, 3.4236436, -3.4046016, -21.129435, 3.2600288, -3.4046016, -21.308428, 3.2600288, -3.4046016, -21.129435, 3.2600288, -5.5538087, -21.17429, 3.4236436, -5.0164003, -21.129435, 3.3828466, -3.988361, -21.129435, 4.050549, -3.54707, -21.308428, 4.0599475, -3.988361, -21.308428, 4.050549, -3.988361, -21.129435, 4.050549, -3.54707, -21.129435, 4.0599475, -3.54707, -21.308428, 4.0599475, -3.988361, -21.308428, 4.050549, -6.7189775, -21.129435, 4.057598, -3.988361, -21.129435, 4.050549, -3.988361, -21.308428, 4.050549, -7.629112, -21.17429, 4.0599475, -6.7189775, -21.129435, 4.057598, -7.629112, -21.17429, 4.0599475, -3.988361, -21.308428, 4.050549, -7.629112, -21.308428, 4.0599475, -3.54707, -21.129435, 3.5789282, -3.988361, -21.129435, 4.050549, -3.988361, -21.129435, 3.567821, -6.7189775, -21.129435, 4.057598, -3.988361, -21.129435, 3.567821, -3.988361, -21.129435, 4.050549, -6.7189775, -21.129435, 4.057598, -7.629112, -21.129435, 3.653687, -3.988361, -21.129435, 3.567821, -7.629112, -21.129435, 3.653687, -6.7189775, -21.129435, 4.057598, -7.629112, -21.129435, 3.958489, -7.629112, -21.129435, 3.958489, -6.7189775, -21.129435, 4.057598, -7.629112, -21.17429, 4.0599475, -3.988361, -21.129435, 4.050549, -3.54707, -21.129435, 3.5789282, -3.78224, -21.129435, 3.8322535, -3.78224, -21.129435, 3.8322535, -3.54707, -21.129435, 3.5789282, -3.54707, -21.129435, 3.7726603, -3.54707, -21.129435, 3.7726603, -3.5872264, -21.197784, 3.8260593, -3.78224, -21.129435, 3.8322535, -3.5872264, -21.197784, 3.8260593, -3.54707, -21.129435, 3.9070125, -3.78224, -21.129435, 3.8322535, -3.988361, -21.129435, 4.050549, -3.78224, -21.129435, 3.8322535, -3.54707, -21.129435, 3.9070125, -3.988361, -21.129435, 4.050549, -3.54707, -21.129435, 3.9070125, -3.54707, -21.129435, 4.0599475, -3.988361, -21.308428, 3.567821, -3.54707, -21.129435, 3.5789282, -3.988361, -21.129435, 3.567821, -3.988361, -21.308428, 3.567821, -3.54707, -21.308428, 3.5789282, -3.54707, -21.129435, 3.5789282, -7.629112, -21.129435, 3.653687, -3.988361, -21.308428, 3.567821, -3.988361, -21.129435, 3.567821, -7.629112, -21.129435, 3.653687, -7.629112, -21.308428, 3.653687, -3.988361, -21.308428, 3.567821, -3.54707, -21.308428, 4.0599475, -3.988361, -21.308428, 3.567821, -3.988361, -21.308428, 4.050549, -3.988361, -21.308428, 3.567821, -3.54707, -21.308428, 4.0599475, -3.54707, -21.308428, 3.799787, -3.988361, -21.308428, 3.567821, -3.54707, -21.308428, 3.799787, -3.54707, -21.308428, 3.5789282, -7.629112, -21.308428, 3.653687, -3.988361, -21.308428, 4.050549, -3.988361, -21.308428, 3.567821, -7.629112, -21.308428, 3.653687, -7.629112, -21.308428, 4.0599475, -3.988361, -21.308428, 4.050549, -3.54707, -21.129435, 3.9070125, -3.5872264, -21.197784, 3.8260593, -3.54707, -21.308428, 3.799787, -3.5872264, -21.197784, 3.8260593, -3.54707, -21.129435, 3.7726603, -3.54707, -21.308428, 3.799787, -3.54707, -21.308428, 4.0599475, -3.54707, -21.129435, 3.9070125, -3.54707, -21.308428, 3.799787, -3.54707, -21.308428, 4.0599475, -3.54707, -21.129435, 4.0599475, -3.54707, -21.129435, 3.9070125, -3.54707, -21.129435, 3.7726603, -3.54707, -21.308428, 3.5789282, -3.54707, -21.308428, 3.799787, -3.54707, -21.129435, 3.7726603, -3.54707, -21.129435, 3.5789282, -3.54707, -21.308428, 3.5789282, -7.629112, -21.129435, 3.653687, -7.629112, -21.308428, 4.0599475, -7.629112, -21.308428, 3.653687, -7.629112, -21.308428, 4.0599475, -7.629112, -21.129435, 3.653687, -7.629112, -21.129435, 3.958489, -7.629112, -21.308428, 4.0599475, -7.629112, -21.129435, 3.958489, -7.629112, -21.17429, 4.0599475, -3.950554, -21.3975, -1.0022886, -3.6320815, -19.082968, -1.0409505, -3.5934205, -21.3975, -1.0022886, -3.950554, -21.3975, -1.0022886, -3.911893, -19.082968, -1.0409505, -3.6320815, -19.082968, -1.0409505, -3.911893, -19.082968, -1.0409505, -3.6320815, -19.082968, -1.3209753, -3.6320815, -19.082968, -1.0409505, -3.911893, -19.082968, -1.0409505, -3.911893, -19.082968, -1.3209753, -3.6320815, -19.082968, -1.3209753, -3.911893, -19.082968, -1.3209753, -3.5934205, -21.3975, -1.3596354, -3.6320815, -19.082968, -1.3209753, -3.911893, -19.082968, -1.3209753, -3.950554, -21.3975, -1.3596354, -3.5934205, -21.3975, -1.3596354, -3.950554, -21.3975, -1.3596354, -3.5934205, -21.3975, -1.0022886, -3.5934205, -21.3975, -1.3596354, -3.950554, -21.3975, -1.3596354, -3.950554, -21.3975, -1.0022886, -3.5934205, -21.3975, -1.0022886, -3.5934205, -21.3975, -1.0022886, -3.6320815, -19.082968, -1.3209753, -3.5934205, -21.3975, -1.3596354, -3.5934205, -21.3975, -1.0022886, -3.6320815, -19.082968, -1.0409505, -3.6320815, -19.082968, -1.3209753, -3.950554, -21.3975, -1.3596354, -3.911893, -19.082968, -1.0409505, -3.950554, -21.3975, -1.0022886, -3.950554, -21.3975, -1.3596354, -3.911893, -19.082968, -1.3209753, -3.911893, -19.082968, -1.0409505, -7.8010573, -21.397497, 5.803108, -7.4439244, -19.082966, 5.8031073, -7.4439244, -21.397497, 5.803108, -7.8010573, -21.397497, 5.803108, -7.8010573, -19.082966, 5.8031073, -7.4439244, -19.082966, 5.8031073, -7.8010573, -21.397497, 5.4459753, -7.4439244, -21.397497, 5.803108, -7.4439244, -21.397497, 5.4459753, -7.8010573, -21.397497, 5.4459753, -7.8010573, -21.397497, 5.803108, -7.4439244, -21.397497, 5.803108, -7.4439244, -21.397497, 5.803108, -7.4439244, -19.082966, 5.4459743, -7.4439244, -21.397497, 5.4459753, -7.4439244, -21.397497, 5.803108, -7.4439244, -19.082966, 5.8031073, -7.4439244, -19.082966, 5.4459743, -7.8010573, -19.661598, 5.4459743, -7.7117743, -19.082966, 5.4459743, -7.8010573, -19.082966, 5.5352573, -7.8010573, -21.397497, 5.803108, -7.8010573, -19.661598, 5.4459743, -7.8010573, -19.082966, 5.5352573, -7.8010573, -19.661598, 5.4459743, -7.8010573, -21.397497, 5.803108, -7.8010573, -21.397497, 5.4459753, -7.8010573, -21.397497, 5.803108, -7.8010573, -19.082966, 5.5352573, -7.8010573, -19.082966, 5.8031073, -7.8010573, -19.082966, 5.5352573, -7.4439244, -19.082966, 5.8031073, -7.8010573, -19.082966, 5.8031073, -7.4439244, -19.082966, 5.8031073, -7.8010573, -19.082966, 5.5352573, -7.7117743, -19.082966, 5.4459743, -7.4439244, -19.082966, 5.8031073, -7.7117743, -19.082966, 5.4459743, -7.4439244, -19.082966, 5.4459743, -7.8010573, -21.397497, 5.4459753, -7.7117743, -19.082966, 5.4459743, -7.8010573, -19.661598, 5.4459743, -7.7117743, -19.082966, 5.4459743, -7.8010573, -21.397497, 5.4459753, -7.4439244, -21.397497, 5.4459753, -7.7117743, -19.082966, 5.4459743, -7.4439244, -21.397497, 5.4459753, -7.4439244, -19.082966, 5.4459743, -5.555945, -21.308428, 4.955558, -3.54707, -21.129435, 4.8647785, -5.555945, -21.129435, 4.9555573, -5.555945, -21.308428, 4.955558, -3.54707, -21.308428, 4.864779, -3.54707, -21.129435, 4.8647785, -7.629112, -21.129435, 4.8647785, -5.555945, -21.308428, 4.955558, -5.555945, -21.129435, 4.9555573, -7.629112, -21.129435, 4.8647785, -7.629112, -21.308428, 4.864779, -5.555945, -21.308428, 4.955558, -5.555945, -21.308428, 5.434868, -3.54707, -21.308428, 4.864779, -5.555945, -21.308428, 4.955558, -5.555945, -21.308428, 5.434868, -3.54707, -21.308428, 5.2710395, -3.54707, -21.308428, 4.864779, -5.555945, -21.308428, 4.955558, -7.629112, -21.308428, 5.4197025, -5.555945, -21.308428, 5.434868, -5.555945, -21.308428, 4.955558, -7.629112, -21.308428, 5.137542, -7.629112, -21.308428, 5.4197025, -7.629112, -21.308428, 5.137542, -5.555945, -21.308428, 4.955558, -7.629112, -21.308428, 4.864779, -3.54707, -21.308428, 5.2710395, -3.54707, -21.129435, 4.8647785, -3.54707, -21.308428, 4.864779, -3.54707, -21.308428, 5.2710395, -3.54707, -21.129435, 5.271039, -3.54707, -21.129435, 4.8647785, -7.629112, -21.129435, 5.1048613, -7.5507226, -21.183046, 5.1524935, -7.629112, -21.308428, 5.137542, -5.555945, -21.129435, 5.31504, -5.555945, -21.17429, 5.434868, -6.0741305, -21.129435, 5.431023, -5.555945, -21.129435, 5.31504, -5.0537796, -21.129435, 5.3940706, -5.555945, -21.17429, 5.434868, -5.555945, -21.129435, 5.31504, -3.54707, -21.129435, 5.271039, -5.0537796, -21.129435, 5.3940706, -5.555945, -21.129435, 5.31504, -3.54707, -21.129435, 4.8647785, -3.54707, -21.129435, 5.271039, -3.54707, -21.129435, 4.8647785, -5.555945, -21.129435, 5.31504, -5.555945, -21.129435, 4.9555573, -6.0741305, -21.129435, 5.431023, -5.555945, -21.129435, 4.9555573, -5.555945, -21.129435, 5.31504, -5.555945, -21.129435, 4.9555573, -7.629112, -21.129435, 5.1048613, -7.629112, -21.129435, 4.8647785, -5.555945, -21.129435, 4.9555573, -7.2726197, -21.129435, 5.1539884, -7.629112, -21.129435, 5.1048613, -6.0741305, -21.129435, 5.431023, -7.2726197, -21.129435, 5.1539884, -5.555945, -21.129435, 4.9555573, -7.5507226, -21.183046, 5.1524935, -7.629112, -21.129435, 5.1048613, -7.2726197, -21.129435, 5.1539884, -7.629112, -21.129435, 5.240281, -7.5507226, -21.183046, 5.1524935, -7.2726197, -21.129435, 5.1539884, -7.2726197, -21.129435, 5.1539884, -6.0741305, -21.129435, 5.431023, -7.629112, -21.129435, 5.240281, -7.629112, -21.129435, 5.240281, -6.0741305, -21.129435, 5.431023, -7.629112, -21.129435, 5.4197025, -7.629112, -21.308428, 4.864779, -7.629112, -21.129435, 5.1048613, -7.629112, -21.308428, 5.137542, -7.629112, -21.308428, 4.864779, -7.629112, -21.129435, 4.8647785, -7.629112, -21.129435, 5.1048613, -7.629112, -21.129435, 5.240281, -7.629112, -21.308428, 5.4197025, -7.629112, -21.308428, 5.137542, -7.629112, -21.129435, 5.240281, -7.629112, -21.129435, 5.4197025, -7.629112, -21.308428, 5.4197025, -7.5507226, -21.183046, 5.1524935, -7.629112, -21.129435, 5.240281, -7.629112, -21.308428, 5.137542, -7.629112, -21.129435, 5.4197025, -5.555945, -21.308428, 5.434868, -7.629112, -21.308428, 5.4197025, -5.555945, -21.308428, 5.434868, -7.629112, -21.129435, 5.4197025, -6.0741305, -21.129435, 5.431023, -5.555945, -21.308428, 5.434868, -6.0741305, -21.129435, 5.431023, -5.555945, -21.17429, 5.434868, -5.555945, -21.17429, 5.434868, -3.54707, -21.308428, 5.2710395, -5.555945, -21.308428, 5.434868, -5.555945, -21.17429, 5.434868, -3.54707, -21.129435, 5.271039, -3.54707, -21.308428, 5.2710395, -3.54707, -21.129435, 5.271039, -5.555945, -21.17429, 5.434868, -5.0537796, -21.129435, 5.3940706, -6.926807, -21.30843, 1.0420431, -3.4520202, -21.129435, 0.96386683, -6.926807, -21.129435, 1.0420431, -6.926807, -21.30843, 1.0420431, -3.4520202, -21.30843, 0.96386683, -3.4520202, -21.129435, 0.96386683, -7.723949, -21.129435, 1.0386255, -6.926807, -21.30843, 1.0420431, -6.926807, -21.129435, 1.0420431, -7.723949, -21.129435, 1.0386255, -7.723949, -21.30843, 1.0386255, -6.926807, -21.30843, 1.0420431, -6.926807, -21.30843, 1.5358781, -3.4520202, -21.30843, 0.96386683, -6.926807, -21.30843, 1.0420431, -6.926807, -21.30843, 1.5358781, -3.4520202, -21.30843, 1.444886, -3.4520202, -21.30843, 0.96386683, -7.723949, -21.30843, 1.0386255, -6.926807, -21.30843, 1.5358781, -6.926807, -21.30843, 1.0420431, -7.723949, -21.30843, 1.0386255, -7.723949, -21.30843, 1.444886, -6.926807, -21.30843, 1.5358781, -7.723949, -21.30843, 1.0386255, -7.723949, -21.129435, 1.444886, -7.723949, -21.30843, 1.444886, -7.723949, -21.30843, 1.0386255, -7.723949, -21.129435, 1.0386255, -7.723949, -21.129435, 1.444886, -6.926807, -21.129435, 1.4124193, -6.926807, -21.17429, 1.5358781, -7.1260924, -21.129435, 1.5130231, -6.926807, -21.129435, 1.4124193, -6.0581107, -21.129435, 1.5130231, -6.926807, -21.17429, 1.5358781, -6.926807, -21.129435, 1.4124193, -4.1037025, -21.129435, 1.4619737, -6.0581107, -21.129435, 1.5130231, -7.1260924, -21.129435, 1.5130231, -6.926807, -21.129435, 1.0420431, -6.926807, -21.129435, 1.4124193, -7.1260924, -21.129435, 1.5130231, -7.723949, -21.129435, 1.0386255, -6.926807, -21.129435, 1.0420431, -7.723949, -21.129435, 1.0386255, -7.1260924, -21.129435, 1.5130231, -7.723949, -21.129435, 1.444886, -3.4520202, -21.129435, 0.96386683, -6.926807, -21.129435, 1.4124193, -6.926807, -21.129435, 1.0420431, -6.926807, -21.129435, 1.4124193, -3.4520202, -21.129435, 0.96386683, -4.1037025, -21.129435, 1.4619737, -4.1037025, -21.129435, 1.4619737, -3.4520202, -21.129435, 0.96386683, -3.4520202, -21.129435, 1.3246312, -4.1037025, -21.129435, 1.4619737, -3.4520202, -21.129435, 1.3246312, -3.4520202, -21.17429, 1.444886, -7.723949, -21.129435, 1.444886, -6.926807, -21.30843, 1.5358781, -7.723949, -21.30843, 1.444886, -6.926807, -21.30843, 1.5358781, -7.723949, -21.129435, 1.444886, -7.1260924, -21.129435, 1.5130231, -6.926807, -21.30843, 1.5358781, -7.1260924, -21.129435, 1.5130231, -6.926807, -21.17429, 1.5358781, -6.926807, -21.17429, 1.5358781, -3.4520202, -21.30843, 1.444886, -6.926807, -21.30843, 1.5358781, -6.926807, -21.17429, 1.5358781, -4.1037025, -21.129435, 1.4619737, -3.4520202, -21.30843, 1.444886, -3.4520202, -21.30843, 1.444886, -4.1037025, -21.129435, 1.4619737, -3.4520202, -21.17429, 1.444886, -4.1037025, -21.129435, 1.4619737, -6.926807, -21.17429, 1.5358781, -6.0581107, -21.129435, 1.5130231, -3.4520202, -21.17429, 1.444886, -3.4520202, -21.30843, 0.96386683, -3.4520202, -21.30843, 1.444886, -3.4520202, -21.30843, 0.96386683, -3.4520202, -21.17429, 1.444886, -3.4520202, -21.129435, 1.3246312, -3.4520202, -21.30843, 0.96386683, -3.4520202, -21.129435, 1.3246312, -3.4520202, -21.129435, 0.96386683, -6.9564967, -21.308428, 4.294263, -3.4046016, -21.129435, 4.2160864, -6.9564967, -21.129435, 4.294263, -6.9564967, -21.308428, 4.294263, -3.4046016, -21.308428, 4.2160864, -3.4046016, -21.129435, 4.2160864, -7.7713675, -21.129435, 4.290845, -6.9564967, -21.308428, 4.294263, -6.9564967, -21.129435, 4.294263, -7.7713675, -21.129435, 4.290845, -7.7713675, -21.308428, 4.2908454, -6.9564967, -21.308428, 4.294263, -6.9564967, -21.308428, 4.787884, -3.4046016, -21.308428, 4.2160864, -6.9564967, -21.308428, 4.294263, -6.9564967, -21.308428, 4.787884, -3.4046016, -21.308428, 4.6971054, -3.4046016, -21.308428, 4.2160864, -7.7713675, -21.308428, 4.2908454, -6.9564967, -21.308428, 4.787884, -6.9564967, -21.308428, 4.294263, -7.7713675, -21.308428, 4.2908454, -7.7713675, -21.308428, 4.6971054, -6.9564967, -21.308428, 4.787884, -7.7713675, -21.308428, 4.2908454, -7.7713675, -21.129435, 4.6971054, -7.7713675, -21.308428, 4.6971054, -7.7713675, -21.308428, 4.2908454, -7.7713675, -21.129435, 4.290845, -7.7713675, -21.129435, 4.6971054, -6.9564967, -21.129435, 4.6646385, -6.9564967, -21.17429, 4.7878838, -7.1602683, -21.129435, 4.7652426, -6.9564967, -21.129435, 4.6646385, -6.0685763, -21.129435, 4.7652426, -6.9564967, -21.17429, 4.7878838, -6.9564967, -21.129435, 4.6646385, -4.0705953, -21.129435, 4.7141933, -6.0685763, -21.129435, 4.7652426, -7.1602683, -21.129435, 4.7652426, -6.9564967, -21.129435, 4.294263, -6.9564967, -21.129435, 4.6646385, -7.1602683, -21.129435, 4.7652426, -7.7713675, -21.129435, 4.290845, -6.9564967, -21.129435, 4.294263, -7.7713675, -21.129435, 4.290845, -7.1602683, -21.129435, 4.7652426, -7.7713675, -21.129435, 4.6971054, -3.4046016, -21.129435, 4.2160864, -6.9564967, -21.129435, 4.6646385, -6.9564967, -21.129435, 4.294263, -6.9564967, -21.129435, 4.6646385, -3.4046016, -21.129435, 4.2160864, -4.0705953, -21.129435, 4.7141933, -4.0705953, -21.129435, 4.7141933, -3.4046016, -21.129435, 4.2160864, -3.4046016, -21.129435, 4.576851, -4.0705953, -21.129435, 4.7141933, -3.4046016, -21.129435, 4.576851, -3.4046016, -21.17429, 4.6971054, -7.7713675, -21.129435, 4.6971054, -6.9564967, -21.308428, 4.787884, -7.7713675, -21.308428, 4.6971054, -6.9564967, -21.308428, 4.787884, -7.7713675, -21.129435, 4.6971054, -7.1602683, -21.129435, 4.7652426, -6.9564967, -21.308428, 4.787884, -7.1602683, -21.129435, 4.7652426, -6.9564967, -21.17429, 4.7878838, -6.9564967, -21.17429, 4.7878838, -3.4046016, -21.308428, 4.6971054, -6.9564967, -21.308428, 4.787884, -6.9564967, -21.17429, 4.7878838, -4.0705953, -21.129435, 4.7141933, -3.4046016, -21.308428, 4.6971054, -3.4046016, -21.308428, 4.6971054, -4.0705953, -21.129435, 4.7141933, -3.4046016, -21.17429, 4.6971054, -4.0705953, -21.129435, 4.7141933, -6.9564967, -21.17429, 4.7878838, -6.0685763, -21.129435, 4.7652426, -3.4046016, -21.17429, 4.6971054, -3.4046016, -21.308428, 4.2160864, -3.4046016, -21.308428, 4.6971054, -3.4046016, -21.308428, 4.2160864, -3.4046016, -21.17429, 4.6971054, -3.4046016, -21.129435, 4.576851, -3.4046016, -21.308428, 4.2160864, -3.4046016, -21.129435, 4.576851, -3.4046016, -21.129435, 4.2160864, -3.8851933, -21.397497, 5.744369, -3.5280604, -19.082966, 5.744368, -3.5280604, -21.397497, 5.744369, -3.8851933, -21.397497, 5.744369, -3.8851933, -19.351458, 5.7443686, -3.5280604, -19.082966, 5.744368, -3.8851933, -19.351458, 5.7443686, -3.5280604, -19.082966, 5.387021, -3.5280604, -19.082966, 5.744368, -3.8851933, -19.351458, 5.7443686, -3.8851933, -19.082966, 5.387021, -3.5280604, -19.082966, 5.387021, -3.8851933, -19.082966, 5.387021, -3.5280604, -21.397497, 5.387022, -3.5280604, -19.082966, 5.387021, -3.8851933, -19.082966, 5.387021, -3.8851933, -21.397497, 5.387022, -3.5280604, -21.397497, 5.387022, -3.8851933, -21.397497, 5.387022, -3.5280604, -21.397497, 5.744369, -3.5280604, -21.397497, 5.387022, -3.8851933, -21.397497, 5.387022, -3.8851933, -21.397497, 5.744369, -3.5280604, -21.397497, 5.744369, -3.5280604, -21.397497, 5.744369, -3.5280604, -19.082966, 5.387021, -3.5280604, -21.397497, 5.387022, -3.5280604, -21.397497, 5.744369, -3.5280604, -19.082966, 5.744368, -3.5280604, -19.082966, 5.387021, -3.8851933, -21.397497, 5.387022, -3.8851933, -19.351458, 5.7443686, -3.8851933, -21.397497, 5.744369, -3.8851933, -21.397497, 5.387022, -3.8851933, -19.082966, 5.387021, -3.8851933, -19.351458, 5.7443686, -3.8144932, -21.40476, 5.450674, -3.7010732, -21.359264, 5.450674, -3.7010732, -21.40476, 5.450674, -3.8144932, -21.40476, 5.450674, -3.8144932, -21.359264, 5.450674, -3.7010732, -21.359264, 5.450674, -3.8144932, -21.359264, 5.450674, -3.7010732, -21.359266, -1.0281336, -3.7010732, -21.359264, 5.450674, -3.8144932, -21.359264, 5.450674, -3.8144932, -21.359266, -1.0281336, -3.7010732, -21.359266, -1.0281336, -3.8144932, -21.359266, -1.0281336, -3.7010732, -21.404762, -1.0281336, -3.7010732, -21.359266, -1.0281336, -3.8144932, -21.359266, -1.0281336, -3.8144932, -21.404762, -1.0281336, -3.7010732, -21.404762, -1.0281336, -3.8144932, -21.404762, -1.0281336, -3.7010732, -21.40476, 5.450674, -3.7010732, -21.404762, -1.0281336, -3.8144932, -21.404762, -1.0281336, -3.8144932, -21.40476, 5.450674, -3.7010732, -21.40476, 5.450674, -3.7010732, -21.40476, 5.450674, -3.7010732, -21.359266, -1.0281336, -3.7010732, -21.404762, -1.0281336, -3.7010732, -21.40476, 5.450674, -3.7010732, -21.359264, 5.450674, -3.7010732, -21.359266, -1.0281336, -3.8144932, -21.404762, -1.0281336, -3.8144932, -21.359264, 5.450674, -3.8144932, -21.40476, 5.450674, -3.8144932, -21.404762, -1.0281336, -3.8144932, -21.359266, -1.0281336, -3.8144932, -21.359264, 5.450674, -7.4172244, -21.333633, 0.22268713, -7.393088, -21.35008, -0.25406027, -7.393088, -21.355635, 0.23059022, -7.4172244, -21.333633, 0.22268713, -7.4172244, -21.328295, -0.24551606, -7.393088, -21.35008, -0.25406027, -7.4172244, -21.328295, -0.24551606, -7.393088, -21.208893, -0.27221608, -7.393088, -21.35008, -0.25406027, -7.4172244, -21.328295, -0.24551606, -7.4172244, -21.21466, -0.25277877, -7.393088, -21.208893, -0.27221608, -7.393088, -21.355635, 0.23059022, -7.4172244, -21.371868, -0.26239038, -7.4172244, -21.377848, 0.2384932, -7.393088, -21.355635, 0.23059022, -7.393088, -21.35008, -0.25406027, -7.4172244, -21.371868, -0.26239038, -7.393088, -21.35008, -0.25406027, -7.4172244, -21.20334, -0.29122615, -7.4172244, -21.371868, -0.26239038, -7.393088, -21.35008, -0.25406027, -7.393088, -21.208893, -0.27221608, -7.4172244, -21.20334, -0.29122615, -7.4172244, -21.377848, 0.2384932, -7.441361, -21.35008, -0.25406027, -7.441361, -21.355635, 0.23059022, -7.4172244, -21.377848, 0.2384932, -7.4172244, -21.371868, -0.26239038, -7.441361, -21.35008, -0.25406027, -7.4172244, -21.371868, -0.26239038, -7.441361, -21.208893, -0.27221608, -7.441361, -21.35008, -0.25406027, -7.4172244, -21.371868, -0.26239038, -7.4172244, -21.20334, -0.29122615, -7.441361, -21.208893, -0.27221608, -7.441361, -21.355635, 0.23059022, -7.4172244, -21.328295, -0.24551606, -7.4172244, -21.333633, 0.22268713, -7.441361, -21.355635, 0.23059022, -7.441361, -21.35008, -0.25406027, -7.4172244, -21.328295, -0.24551606, -7.441361, -21.35008, -0.25406027, -7.4172244, -21.21466, -0.25277877, -7.4172244, -21.328295, -0.24551606, -7.441361, -21.35008, -0.25406027, -7.441361, -21.208893, -0.27221608, -7.4172244, -21.21466, -0.25277877, -7.4172244, -21.21466, -0.25277877, -7.393088, -21.090775, -0.24231243, -7.393088, -21.208893, -0.27221608, -7.4172244, -21.21466, -0.25277877, -7.4172244, -21.114271, -0.23804045, -7.393088, -21.090775, -0.24231243, -7.4172244, -21.114271, -0.23804045, -7.393088, -21.088425, 0.22674549, -7.393088, -21.090775, -0.24231243, -7.4172244, -21.114271, -0.23804045, -7.4172244, -21.111708, 0.22204626, -7.393088, -21.088425, 0.22674549, -7.4172244, -21.111708, 0.22204626, -7.393088, -21.219574, 0.2705326, -7.393088, -21.088425, 0.22674549, -7.4172244, -21.111708, 0.22204626, -7.4172244, -21.224485, 0.2510954, -7.393088, -21.219574, 0.2705326, -7.393088, -21.208893, -0.27221608, -7.4172244, -21.06728, -0.24658465, -7.4172244, -21.20334, -0.29122615, -7.393088, -21.208893, -0.27221608, -7.393088, -21.090775, -0.24231243, -7.4172244, -21.06728, -0.24658465, -7.393088, -21.090775, -0.24231243, -7.4172244, -21.06493, 0.23144436, -7.4172244, -21.06728, -0.24658465, -7.393088, -21.090775, -0.24231243, -7.393088, -21.088425, 0.22674549, -7.4172244, -21.06493, 0.23144436, -7.393088, -21.088425, 0.22674549, -7.4172244, -21.214874, 0.28996992, -7.4172244, -21.06493, 0.23144436, -7.393088, -21.088425, 0.22674549, -7.393088, -21.219574, 0.2705326, -7.4172244, -21.214874, 0.28996992, -7.4172244, -21.20334, -0.29122615, -7.441361, -21.090775, -0.24231243, -7.441361, -21.208893, -0.27221608, -7.4172244, -21.20334, -0.29122615, -7.4172244, -21.06728, -0.24658465, -7.441361, -21.090775, -0.24231243, -7.441361, -21.208893, -0.27221608, -7.4172244, -21.114271, -0.23804045, -7.4172244, -21.21466, -0.25277877, -7.441361, -21.208893, -0.27221608, -7.441361, -21.090775, -0.24231243, -7.4172244, -21.114271, -0.23804045, -7.441361, -21.090775, -0.24231243, -7.4172244, -21.111708, 0.22204626, -7.4172244, -21.114271, -0.23804045, -7.441361, -21.090775, -0.24231243, -7.441361, -21.088425, 0.22674549, -7.4172244, -21.111708, 0.22204626, -7.441361, -21.088425, 0.22674549, -7.4172244, -21.224485, 0.2510954, -7.4172244, -21.111708, 0.22204626, -7.441361, -21.088425, 0.22674549, -7.441361, -21.219574, 0.2705326, -7.4172244, -21.224485, 0.2510954, -7.4172244, -21.06728, -0.24658465, -7.441361, -21.088425, 0.22674549, -7.441361, -21.090775, -0.24231243, -7.4172244, -21.06728, -0.24658465, -7.4172244, -21.06493, 0.23144436, -7.441361, -21.088425, 0.22674549, -7.4172244, -21.06493, 0.23144436, -7.441361, -21.219574, 0.2705326, -7.441361, -21.088425, 0.22674549, -7.4172244, -21.06493, 0.23144436, -7.4172244, -21.214874, 0.28996992, -7.441361, -21.219574, 0.2705326, -7.4172244, -21.224485, 0.2510954, -7.393088, -21.355635, 0.23059022, -7.393088, -21.219574, 0.2705326, -7.4172244, -21.224485, 0.2510954, -7.4172244, -21.333633, 0.22268713, -7.393088, -21.355635, 0.23059022, -7.393088, -21.219574, 0.2705326, -7.4172244, -21.377848, 0.2384932, -7.4172244, -21.214874, 0.28996992, -7.393088, -21.219574, 0.2705326, -7.393088, -21.355635, 0.23059022, -7.4172244, -21.377848, 0.2384932, -7.4172244, -21.214874, 0.28996992, -7.441361, -21.355635, 0.23059022, -7.441361, -21.219574, 0.2705326, -7.4172244, -21.214874, 0.28996992, -7.4172244, -21.377848, 0.2384932, -7.441361, -21.355635, 0.23059022, -7.441361, -21.219574, 0.2705326, -7.4172244, -21.333633, 0.22268713, -7.4172244, -21.224485, 0.2510954, -7.441361, -21.219574, 0.2705326, -7.441361, -21.355635, 0.23059022, -7.4172244, -21.333633, 0.22268713, -3.8775039, -21.320818, 0.20923042, -3.8561444, -21.335342, -0.30446887, -3.8561444, -21.34047, 0.21777427, -3.8775039, -21.320818, 0.20923042, -3.8775039, -21.316118, -0.29528427, -3.8561444, -21.335342, -0.30446887, -3.8775039, -21.316118, -0.29528427, -3.8561444, -21.210388, -0.32390642, -3.8561444, -21.335342, -0.30446887, -3.8775039, -21.316118, -0.29528427, -3.8775039, -21.215515, -0.3031876, -3.8561444, -21.210388, -0.32390642, -3.8561444, -21.34047, 0.21777427, -3.8775039, -21.35478, -0.31322622, -3.8775039, -21.36012, 0.22631812, -3.8561444, -21.34047, 0.21777427, -3.8561444, -21.335342, -0.30446887, -3.8775039, -21.35478, -0.31322622, -3.8561444, -21.335342, -0.30446887, -3.8775039, -21.205263, -0.34462523, -3.8775039, -21.35478, -0.31322622, -3.8561444, -21.335342, -0.30446887, -3.8561444, -21.210388, -0.32390642, -3.8775039, -21.205263, -0.34462523, -3.8775039, -21.36012, 0.22631812, -3.8988638, -21.335342, -0.30446887, -3.8988638, -21.34047, 0.21777427, -3.8775039, -21.36012, 0.22631812, -3.8775039, -21.35478, -0.31322622, -3.8988638, -21.335342, -0.30446887, -3.8775039, -21.35478, -0.31322622, -3.8988638, -21.210388, -0.32390642, -3.8988638, -21.335342, -0.30446887, -3.8775039, -21.35478, -0.31322622, -3.8775039, -21.205263, -0.34462523, -3.8988638, -21.210388, -0.32390642, -3.8988638, -21.34047, 0.21777427, -3.8775039, -21.316118, -0.29528427, -3.8775039, -21.320818, 0.20923042, -3.8988638, -21.34047, 0.21777427, -3.8988638, -21.335342, -0.30446887, -3.8775039, -21.316118, -0.29528427, -3.8988638, -21.335342, -0.30446887, -3.8775039, -21.215515, -0.3031876, -3.8775039, -21.316118, -0.29528427, -3.8988638, -21.335342, -0.30446887, -3.8988638, -21.210388, -0.32390642, -3.8775039, -21.215515, -0.3031876, -3.8775039, -21.215515, -0.3031876, -3.8561444, -21.105513, -0.29165316, -3.8561444, -21.210388, -0.32390642, -3.8775039, -21.215515, -0.3031876, -3.8775039, -21.126446, -0.2871678, -3.8561444, -21.105513, -0.29165316, -3.8775039, -21.126446, -0.2871678, -3.8561444, -21.10359, 0.2135024, -3.8561444, -21.105513, -0.29165316, -3.8775039, -21.126446, -0.2871678, -3.8775039, -21.12431, 0.20858967, -3.8561444, -21.10359, 0.2135024, -3.8775039, -21.12431, 0.20858967, -3.8561444, -21.219788, 0.26070714, -3.8561444, -21.10359, 0.2135024, -3.8775039, -21.12431, 0.20858967, -3.8775039, -21.224058, 0.23977482, -3.8561444, -21.219788, 0.26070714, -3.8561444, -21.210388, -0.32390642, -3.8775039, -21.084795, -0.2963524, -3.8775039, -21.205263, -0.34462523, -3.8561444, -21.210388, -0.32390642, -3.8561444, -21.105513, -0.29165316, -3.8775039, -21.084795, -0.2963524, -3.8561444, -21.105513, -0.29165316, -3.8775039, -21.082659, 0.21862864, -3.8775039, -21.084795, -0.2963524, -3.8561444, -21.105513, -0.29165316, -3.8561444, -21.10359, 0.2135024, -3.8775039, -21.082659, 0.21862864, -3.8561444, -21.10359, 0.2135024, -3.8775039, -21.215515, 0.2816397, -3.8775039, -21.082659, 0.21862864, -3.8561444, -21.10359, 0.2135024, -3.8561444, -21.219788, 0.26070714, -3.8775039, -21.215515, 0.2816397, -3.8775039, -21.205263, -0.34462523, -3.8988638, -21.105513, -0.29165316, -3.8988638, -21.210388, -0.32390642, -3.8775039, -21.205263, -0.34462523, -3.8775039, -21.084795, -0.2963524, -3.8988638, -21.105513, -0.29165316, -3.8988638, -21.210388, -0.32390642, -3.8775039, -21.126446, -0.2871678, -3.8775039, -21.215515, -0.3031876, -3.8988638, -21.210388, -0.32390642, -3.8988638, -21.105513, -0.29165316, -3.8775039, -21.126446, -0.2871678, -3.8988638, -21.105513, -0.29165316, -3.8775039, -21.12431, 0.20858967, -3.8775039, -21.126446, -0.2871678, -3.8988638, -21.105513, -0.29165316, -3.8988638, -21.10359, 0.2135024, -3.8775039, -21.12431, 0.20858967, -3.8988638, -21.10359, 0.2135024, -3.8775039, -21.224058, 0.23977482, -3.8775039, -21.12431, 0.20858967, -3.8988638, -21.10359, 0.2135024, -3.8988638, -21.219788, 0.26070714, -3.8775039, -21.224058, 0.23977482, -3.8775039, -21.084795, -0.2963524, -3.8988638, -21.10359, 0.2135024, -3.8988638, -21.105513, -0.29165316, -3.8775039, -21.084795, -0.2963524, -3.8775039, -21.082659, 0.21862864, -3.8988638, -21.10359, 0.2135024, -3.8775039, -21.082659, 0.21862864, -3.8988638, -21.219788, 0.26070714, -3.8988638, -21.10359, 0.2135024, -3.8775039, -21.082659, 0.21862864, -3.8775039, -21.215515, 0.2816397, -3.8988638, -21.219788, 0.26070714, -3.8775039, -21.224058, 0.23977482, -3.8561444, -21.34047, 0.21777427, -3.8561444, -21.219788, 0.26070714, -3.8775039, -21.224058, 0.23977482, -3.8775039, -21.320818, 0.20923042, -3.8561444, -21.34047, 0.21777427, -3.8561444, -21.219788, 0.26070714, -3.8775039, -21.36012, 0.22631812, -3.8775039, -21.215515, 0.2816397, -3.8561444, -21.219788, 0.26070714, -3.8561444, -21.34047, 0.21777427, -3.8775039, -21.36012, 0.22631812, -3.8775039, -21.215515, 0.2816397, -3.8988638, -21.34047, 0.21777427, -3.8988638, -21.219788, 0.26070714, -3.8775039, -21.215515, 0.2816397, -3.8775039, -21.36012, 0.22631812, -3.8988638, -21.34047, 0.21777427, -3.8988638, -21.219788, 0.26070714, -3.8775039, -21.320818, 0.20923042, -3.8775039, -21.224058, 0.23977482, -3.8988638, -21.219788, 0.26070714, -3.8988638, -21.34047, 0.21777427, -3.8775039, -21.320818, 0.20923042, -7.34631, -21.333633, 0.9091861, -7.3221745, -21.35008, 0.3158151, -7.3221745, -21.355635, 0.9190115, -7.34631, -21.333633, 0.9091861, -7.34631, -21.328295, 0.3260677, -7.3221745, -21.35008, 0.3158151, -7.34631, -21.328295, 0.3260677, -7.3221745, -21.208893, 0.2931739, -7.3221745, -21.35008, 0.3158151, -7.34631, -21.328295, 0.3260677, -7.34631, -21.21466, 0.3170967, -7.3221745, -21.208893, 0.2931739, -7.3221745, -21.355635, 0.9190115, -7.34631, -21.371868, 0.30534887, -7.34631, -21.377848, 0.92883694, -7.3221745, -21.355635, 0.9190115, -7.3221745, -21.35008, 0.3158151, -7.34631, -21.371868, 0.30534887, -7.3221745, -21.35008, 0.3158151, -7.34631, -21.20334, 0.2692511, -7.34631, -21.371868, 0.30534887, -7.3221745, -21.35008, 0.3158151, -7.3221745, -21.208893, 0.2931739, -7.34631, -21.20334, 0.2692511, -7.34631, -21.377848, 0.92883694, -7.3704467, -21.35008, 0.3158151, -7.3704467, -21.355635, 0.9190115, -7.34631, -21.377848, 0.92883694, -7.34631, -21.371868, 0.30534887, -7.3704467, -21.35008, 0.3158151, -7.34631, -21.371868, 0.30534887, -7.3704467, -21.208893, 0.2931739, -7.3704467, -21.35008, 0.3158151, -7.34631, -21.371868, 0.30534887, -7.34631, -21.20334, 0.2692511, -7.3704467, -21.208893, 0.2931739, -7.3704467, -21.355635, 0.9190115, -7.34631, -21.328295, 0.3260677, -7.34631, -21.333633, 0.9091861, -7.3704467, -21.355635, 0.9190115, -7.3704467, -21.35008, 0.3158151, -7.34631, -21.328295, 0.3260677, -7.3704467, -21.35008, 0.3158151, -7.34631, -21.21466, 0.3170967, -7.34631, -21.328295, 0.3260677, -7.3704467, -21.35008, 0.3158151, -7.3704467, -21.208893, 0.2931739, -7.34631, -21.21466, 0.3170967, -7.34631, -21.21466, 0.3170967, -7.3221745, -21.090775, 0.33033967, -7.3221745, -21.208893, 0.2931739, -7.34631, -21.21466, 0.3170967, -7.34631, -21.114271, 0.33567965, -7.3221745, -21.090775, 0.33033967, -7.34631, -21.114271, 0.33567965, -7.3221745, -21.088425, 0.91409886, -7.3221745, -21.090775, 0.33033967, -7.34631, -21.114271, 0.33567965, -7.34631, -21.111708, 0.90833163, -7.3221745, -21.088425, 0.91409886, -7.34631, -21.111708, 0.90833163, -7.3221745, -21.219574, 0.96877944, -7.3221745, -21.088425, 0.91409886, -7.34631, -21.111708, 0.90833163, -7.34631, -21.224485, 0.9444295, -7.3221745, -21.219574, 0.96877944, -7.3221745, -21.208893, 0.2931739, -7.34631, -21.06728, 0.32499957, -7.34631, -21.20334, 0.2692511, -7.3221745, -21.208893, 0.2931739, -7.3221745, -21.090775, 0.33033967, -7.34631, -21.06728, 0.32499957, -7.3221745, -21.090775, 0.33033967, -7.34631, -21.06493, 0.92007935, -7.34631, -21.06728, 0.32499957, -7.3221745, -21.090775, 0.33033967, -7.3221745, -21.088425, 0.91409886, -7.34631, -21.06493, 0.92007935, -7.3221745, -21.088425, 0.91409886, -7.34631, -21.214874, 0.99291587, -7.34631, -21.06493, 0.92007935, -7.3221745, -21.088425, 0.91409886, -7.3221745, -21.219574, 0.96877944, -7.34631, -21.214874, 0.99291587, -7.34631, -21.20334, 0.2692511, -7.3704467, -21.090775, 0.33033967, -7.3704467, -21.208893, 0.2931739, -7.34631, -21.20334, 0.2692511, -7.34631, -21.06728, 0.32499957, -7.3704467, -21.090775, 0.33033967, -7.3704467, -21.208893, 0.2931739, -7.34631, -21.114271, 0.33567965, -7.34631, -21.21466, 0.3170967, -7.3704467, -21.208893, 0.2931739, -7.3704467, -21.090775, 0.33033967, -7.34631, -21.114271, 0.33567965, -7.3704467, -21.090775, 0.33033967, -7.34631, -21.111708, 0.90833163, -7.34631, -21.114271, 0.33567965, -7.3704467, -21.090775, 0.33033967, -7.3704467, -21.088425, 0.91409886, -7.34631, -21.111708, 0.90833163, -7.3704467, -21.088425, 0.91409886, -7.34631, -21.224485, 0.9444295, -7.34631, -21.111708, 0.90833163, -7.3704467, -21.088425, 0.91409886, -7.3704467, -21.219574, 0.96877944, -7.34631, -21.224485, 0.9444295, -7.34631, -21.06728, 0.32499957, -7.3704467, -21.088425, 0.91409886, -7.3704467, -21.090775, 0.33033967, -7.34631, -21.06728, 0.32499957, -7.34631, -21.06493, 0.92007935, -7.3704467, -21.088425, 0.91409886, -7.34631, -21.06493, 0.92007935, -7.3704467, -21.219574, 0.96877944, -7.3704467, -21.088425, 0.91409886, -7.34631, -21.06493, 0.92007935, -7.34631, -21.214874, 0.99291587, -7.3704467, -21.219574, 0.96877944, -7.34631, -21.224485, 0.9444295, -7.3221745, -21.355635, 0.9190115, -7.3221745, -21.219574, 0.96877944, -7.34631, -21.224485, 0.9444295, -7.34631, -21.333633, 0.9091861, -7.3221745, -21.355635, 0.9190115, -7.3221745, -21.219574, 0.96877944, -7.34631, -21.377848, 0.92883694, -7.34631, -21.214874, 0.99291587, -7.3221745, -21.219574, 0.96877944, -7.3221745, -21.355635, 0.9190115, -7.34631, -21.377848, 0.92883694, -7.34631, -21.214874, 0.99291587, -7.3704467, -21.355635, 0.9190115, -7.3704467, -21.219574, 0.96877944, -7.34631, -21.214874, 0.99291587, -7.34631, -21.377848, 0.92883694, -7.3704467, -21.355635, 0.9190115, -7.3704467, -21.219574, 0.96877944, -7.34631, -21.333633, 0.9091861, -7.34631, -21.224485, 0.9444295, -7.3704467, -21.219574, 0.96877944, -7.3704467, -21.355635, 0.9190115, -7.34631, -21.333633, 0.9091861, -7.433244, -21.333633, 2.1138701, -7.4093213, -21.35008, 1.6759974, -7.4093213, -21.355633, 2.1211324, -7.433244, -21.333633, 2.1138701, -7.433244, -21.328293, 1.6839005, -7.4093213, -21.35008, 1.6759974, -7.4093213, -21.355633, 2.1211324, -7.433244, -21.371868, 1.6683079, -7.433244, -21.377848, 2.1283946, -7.4093213, -21.355633, 2.1211324, -7.4093213, -21.35008, 1.6759974, -7.433244, -21.371868, 1.6683079, -7.433244, -21.377848, 2.1283946, -7.457381, -21.35008, 1.6759974, -7.457381, -21.355633, 2.1211324, -7.433244, -21.377848, 2.1283946, -7.433244, -21.371868, 1.6683079, -7.457381, -21.35008, 1.6759974, -7.457381, -21.355633, 2.1211324, -7.433244, -21.328293, 1.6839005, -7.433244, -21.333633, 2.1138701, -7.457381, -21.355633, 2.1211324, -7.457381, -21.35008, 1.6759974, -7.433244, -21.328293, 1.6839005, -7.433244, -21.328293, 1.6839005, -7.4093213, -21.208893, 1.6595504, -7.4093213, -21.35008, 1.6759974, -7.433244, -21.328293, 1.6839005, -7.433244, -21.21466, 1.6770654, -7.4093213, -21.208893, 1.6595504, -7.4093213, -21.35008, 1.6759974, -7.433244, -21.203339, 1.6418219, -7.433244, -21.371868, 1.6683079, -7.4093213, -21.35008, 1.6759974, -7.4093213, -21.208893, 1.6595504, -7.433244, -21.203339, 1.6418219, -7.433244, -21.371868, 1.6683079, -7.457381, -21.208893, 1.6595504, -7.457381, -21.35008, 1.6759974, -7.433244, -21.371868, 1.6683079, -7.433244, -21.203339, 1.6418219, -7.457381, -21.208893, 1.6595504, -7.457381, -21.35008, 1.6759974, -7.433244, -21.21466, 1.6770654, -7.433244, -21.328293, 1.6839005, -7.457381, -21.35008, 1.6759974, -7.457381, -21.208893, 1.6595504, -7.433244, -21.21466, 1.6770654, -7.433244, -21.21466, 1.6770654, -7.4093213, -21.090775, 1.6868907, -7.4093213, -21.208893, 1.6595504, -7.433244, -21.21466, 1.6770654, -7.433244, -21.11427, 1.6907355, -7.4093213, -21.090775, 1.6868907, -7.4093213, -21.208893, 1.6595504, -7.433244, -21.067278, 1.6828324, -7.433244, -21.203339, 1.6418219, -7.4093213, -21.208893, 1.6595504, -7.4093213, -21.090775, 1.6868907, -7.433244, -21.067278, 1.6828324, -7.433244, -21.203339, 1.6418219, -7.457381, -21.090775, 1.6868907, -7.457381, -21.208893, 1.6595504, -7.433244, -21.203339, 1.6418219, -7.433244, -21.067278, 1.6828324, -7.457381, -21.090775, 1.6868907, -7.457381, -21.208893, 1.6595504, -7.433244, -21.11427, 1.6907355, -7.433244, -21.21466, 1.6770654, -7.457381, -21.208893, 1.6595504, -7.457381, -21.090775, 1.6868907, -7.433244, -21.11427, 1.6907355, -7.433244, -21.11427, 1.6907355, -7.4093213, -21.088425, 2.1175013, -7.4093213, -21.090775, 1.6868907, -7.433244, -21.11427, 1.6907355, -7.433244, -21.111706, 2.1132293, -7.4093213, -21.088425, 2.1175013, -7.433244, -21.111706, 2.1132293, -7.4093213, -21.219572, 2.157871, -7.4093213, -21.088425, 2.1175013, -7.433244, -21.111706, 2.1132293, -7.433244, -21.224485, 2.1399288, -7.4093213, -21.219572, 2.157871, -7.4093213, -21.090775, 1.6868907, -7.433244, -21.064928, 2.121773, -7.433244, -21.067278, 1.6828324, -7.4093213, -21.090775, 1.6868907, -7.4093213, -21.088425, 2.1175013, -7.433244, -21.064928, 2.121773, -7.4093213, -21.088425, 2.1175013, -7.433244, -21.214874, 2.1755996, -7.433244, -21.064928, 2.121773, -7.4093213, -21.088425, 2.1175013, -7.4093213, -21.219572, 2.157871, -7.433244, -21.214874, 2.1755996, -7.433244, -21.067278, 1.6828324, -7.457381, -21.088425, 2.1175013, -7.457381, -21.090775, 1.6868907, -7.433244, -21.067278, 1.6828324, -7.433244, -21.064928, 2.121773, -7.457381, -21.088425, 2.1175013, -7.433244, -21.064928, 2.121773, -7.457381, -21.219572, 2.157871, -7.457381, -21.088425, 2.1175013, -7.433244, -21.064928, 2.121773, -7.433244, -21.214874, 2.1755996, -7.457381, -21.219572, 2.157871, -7.457381, -21.090775, 1.6868907, -7.433244, -21.111706, 2.1132293, -7.433244, -21.11427, 1.6907355, -7.457381, -21.090775, 1.6868907, -7.457381, -21.088425, 2.1175013, -7.433244, -21.111706, 2.1132293, -7.457381, -21.088425, 2.1175013, -7.433244, -21.224485, 2.1399288, -7.433244, -21.111706, 2.1132293, -7.457381, -21.088425, 2.1175013, -7.457381, -21.219572, 2.157871, -7.433244, -21.224485, 2.1399288, -7.433244, -21.224485, 2.1399288, -7.4093213, -21.355633, 2.1211324, -7.4093213, -21.219572, 2.157871, -7.433244, -21.224485, 2.1399288, -7.433244, -21.333633, 2.1138701, -7.4093213, -21.355633, 2.1211324, -7.4093213, -21.219572, 2.157871, -7.433244, -21.377848, 2.1283946, -7.433244, -21.214874, 2.1755996, -7.4093213, -21.219572, 2.157871, -7.4093213, -21.355633, 2.1211324, -7.433244, -21.377848, 2.1283946, -7.433244, -21.214874, 2.1755996, -7.457381, -21.355633, 2.1211324, -7.457381, -21.219572, 2.157871, -7.433244, -21.214874, 2.1755996, -7.433244, -21.377848, 2.1283946, -7.457381, -21.355633, 2.1211324, -7.457381, -21.219572, 2.157871, -7.433244, -21.333633, 2.1138701, -7.433244, -21.224485, 2.1399288, -7.457381, -21.219572, 2.157871, -7.457381, -21.355633, 2.1211324, -7.433244, -21.333633, 2.1138701, -7.433244, -21.333633, 3.4082646, -7.4093213, -21.35008, 2.8360398, -7.4093213, -21.355633, 3.4176629, -7.433244, -21.333633, 3.4082646, -7.433244, -21.328293, 2.8460789, -7.4093213, -21.35008, 2.8360398, -7.433244, -21.328293, 2.8460789, -7.4093213, -21.208893, 2.8142529, -7.4093213, -21.35008, 2.8360398, -7.433244, -21.328293, 2.8460789, -7.433244, -21.21466, 2.837535, -7.4093213, -21.208893, 2.8142529, -7.4093213, -21.355633, 3.4176629, -7.433244, -21.371866, 2.8260007, -7.433244, -21.377848, 3.4272747, -7.4093213, -21.355633, 3.4176629, -7.4093213, -21.35008, 2.8360398, -7.433244, -21.371866, 2.8260007, -7.4093213, -21.35008, 2.8360398, -7.433244, -21.203339, 2.7911844, -7.433244, -21.371866, 2.8260007, -7.4093213, -21.35008, 2.8360398, -7.4093213, -21.208893, 2.8142529, -7.433244, -21.203339, 2.7911844, -7.433244, -21.377848, 3.4272747, -7.457381, -21.35008, 2.8360398, -7.457381, -21.355633, 3.4176629, -7.433244, -21.377848, 3.4272747, -7.433244, -21.371866, 2.8260007, -7.457381, -21.35008, 2.8360398, -7.433244, -21.371866, 2.8260007, -7.457381, -21.208893, 2.8142529, -7.457381, -21.35008, 2.8360398, -7.433244, -21.371866, 2.8260007, -7.433244, -21.203339, 2.7911844, -7.457381, -21.208893, 2.8142529, -7.457381, -21.355633, 3.4176629, -7.433244, -21.328293, 2.8460789, -7.433244, -21.333633, 3.4082646, -7.457381, -21.355633, 3.4176629, -7.457381, -21.35008, 2.8360398, -7.433244, -21.328293, 2.8460789, -7.457381, -21.35008, 2.8360398, -7.433244, -21.21466, 2.837535, -7.433244, -21.328293, 2.8460789, -7.457381, -21.35008, 2.8360398, -7.457381, -21.208893, 2.8142529, -7.433244, -21.21466, 2.837535, -7.433244, -21.21466, 2.837535, -7.4093213, -21.090775, 2.850137, -7.4093213, -21.208893, 2.8142529, -7.433244, -21.21466, 2.837535, -7.433244, -21.11427, 2.8552632, -7.4093213, -21.090775, 2.850137, -7.433244, -21.11427, 2.8552632, -7.4093213, -21.088425, 3.4129639, -7.4093213, -21.090775, 2.850137, -7.433244, -21.11427, 2.8552632, -7.433244, -21.111706, 3.4071965, -7.4093213, -21.088425, 3.4129639, -7.433244, -21.111706, 3.4071965, -7.4093213, -21.219572, 3.4655085, -7.4093213, -21.088425, 3.4129639, -7.433244, -21.111706, 3.4071965, -7.433244, -21.224485, 3.4422264, -7.4093213, -21.219572, 3.4655085, -7.4093213, -21.208893, 2.8142529, -7.433244, -21.067278, 2.8450108, -7.433244, -21.203339, 2.7911844, -7.4093213, -21.208893, 2.8142529, -7.4093213, -21.090775, 2.850137, -7.433244, -21.067278, 2.8450108, -7.4093213, -21.090775, 2.850137, -7.433244, -21.064928, 3.4187307, -7.433244, -21.067278, 2.8450108, -7.4093213, -21.090775, 2.850137, -7.4093213, -21.088425, 3.4129639, -7.433244, -21.064928, 3.4187307, -7.4093213, -21.088425, 3.4129639, -7.433244, -21.214872, 3.4887905, -7.433244, -21.064928, 3.4187307, -7.4093213, -21.088425, 3.4129639, -7.4093213, -21.219572, 3.4655085, -7.433244, -21.214872, 3.4887905, -7.433244, -21.203339, 2.7911844, -7.457381, -21.090775, 2.850137, -7.457381, -21.208893, 2.8142529, -7.433244, -21.203339, 2.7911844, -7.433244, -21.067278, 2.8450108, -7.457381, -21.090775, 2.850137, -7.457381, -21.208893, 2.8142529, -7.433244, -21.11427, 2.8552632, -7.433244, -21.21466, 2.837535, -7.457381, -21.208893, 2.8142529, -7.457381, -21.090775, 2.850137, -7.433244, -21.11427, 2.8552632, -7.457381, -21.090775, 2.850137, -7.433244, -21.111706, 3.4071965, -7.433244, -21.11427, 2.8552632, -7.457381, -21.090775, 2.850137, -7.457381, -21.088425, 3.4129639, -7.433244, -21.111706, 3.4071965, -7.457381, -21.088425, 3.4129639, -7.433244, -21.224485, 3.4422264, -7.433244, -21.111706, 3.4071965, -7.457381, -21.088425, 3.4129639, -7.457381, -21.219572, 3.4655085, -7.433244, -21.224485, 3.4422264, -7.433244, -21.067278, 2.8450108, -7.457381, -21.088425, 3.4129639, -7.457381, -21.090775, 2.850137, -7.433244, -21.067278, 2.8450108, -7.433244, -21.064928, 3.4187307, -7.457381, -21.088425, 3.4129639, -7.433244, -21.064928, 3.4187307, -7.457381, -21.219572, 3.4655085, -7.457381, -21.088425, 3.4129639, -7.433244, -21.064928, 3.4187307, -7.433244, -21.214872, 3.4887905, -7.457381, -21.219572, 3.4655085, -7.433244, -21.224485, 3.4422264, -7.4093213, -21.355633, 3.4176629, -7.4093213, -21.219572, 3.4655085, -7.433244, -21.224485, 3.4422264, -7.433244, -21.333633, 3.4082646, -7.4093213, -21.355633, 3.4176629, -7.4093213, -21.219572, 3.4655085, -7.433244, -21.377848, 3.4272747, -7.433244, -21.214872, 3.4887905, -7.4093213, -21.219572, 3.4655085, -7.4093213, -21.355633, 3.4176629, -7.433244, -21.377848, 3.4272747, -7.433244, -21.214872, 3.4887905, -7.457381, -21.355633, 3.4176629, -7.457381, -21.219572, 3.4655085, -7.433244, -21.214872, 3.4887905, -7.433244, -21.377848, 3.4272747, -7.457381, -21.355633, 3.4176629, -7.457381, -21.219572, 3.4655085, -7.433244, -21.333633, 3.4082646, -7.433244, -21.224485, 3.4422264, -7.457381, -21.219572, 3.4655085, -7.457381, -21.355633, 3.4176629, -7.433244, -21.333633, 3.4082646, -3.7589583, -21.320816, 4.036238, -3.7375984, -21.335342, 3.5225387, -3.7375984, -21.340467, 4.044782, -3.7589583, -21.320816, 4.036238, -3.7589583, -21.316118, 3.5317235, -3.7375984, -21.335342, 3.5225387, -3.7589583, -21.316118, 3.5317235, -3.7375984, -21.210388, 3.5031013, -3.7375984, -21.335342, 3.5225387, -3.7589583, -21.316118, 3.5317235, -3.7589583, -21.215513, 3.5238204, -3.7375984, -21.210388, 3.5031013, -3.7375984, -21.340467, 4.044782, -3.7589583, -21.354778, 3.5137813, -3.7589583, -21.360119, 4.0533257, -3.7375984, -21.340467, 4.044782, -3.7375984, -21.335342, 3.5225387, -3.7589583, -21.354778, 3.5137813, -3.7375984, -21.335342, 3.5225387, -3.7589583, -21.205261, 3.4823828, -3.7589583, -21.354778, 3.5137813, -3.7375984, -21.335342, 3.5225387, -3.7375984, -21.210388, 3.5031013, -3.7589583, -21.205261, 3.4823828, -3.7589583, -21.360119, 4.0533257, -3.7803178, -21.335342, 3.5225387, -3.7803178, -21.340467, 4.044782, -3.7589583, -21.360119, 4.0533257, -3.7589583, -21.354778, 3.5137813, -3.7803178, -21.335342, 3.5225387, -3.7589583, -21.354778, 3.5137813, -3.7803178, -21.210388, 3.5031013, -3.7803178, -21.335342, 3.5225387, -3.7589583, -21.354778, 3.5137813, -3.7589583, -21.205261, 3.4823828, -3.7803178, -21.210388, 3.5031013, -3.7803178, -21.340467, 4.044782, -3.7589583, -21.316118, 3.5317235, -3.7589583, -21.320816, 4.036238, -3.7803178, -21.340467, 4.044782, -3.7803178, -21.335342, 3.5225387, -3.7589583, -21.316118, 3.5317235, -3.7803178, -21.335342, 3.5225387, -3.7589583, -21.215513, 3.5238204, -3.7589583, -21.316118, 3.5317235, -3.7803178, -21.335342, 3.5225387, -3.7803178, -21.210388, 3.5031013, -3.7589583, -21.215513, 3.5238204, -3.7589583, -21.215513, 3.5238204, -3.7375984, -21.105513, 3.5353546, -3.7375984, -21.210388, 3.5031013, -3.7589583, -21.215513, 3.5238204, -3.7589583, -21.126444, 3.5398402, -3.7375984, -21.105513, 3.5353546, -3.7589583, -21.126444, 3.5398402, -3.7375984, -21.10359, 4.04051, -3.7375984, -21.105513, 3.5353546, -3.7589583, -21.126444, 3.5398402, -3.7589583, -21.124308, 4.0355973, -3.7375984, -21.10359, 4.04051, -3.7589583, -21.124308, 4.0355973, -3.7375984, -21.219786, 4.087715, -3.7375984, -21.10359, 4.04051, -3.7589583, -21.124308, 4.0355973, -3.7589583, -21.224058, 4.0667825, -3.7375984, -21.219786, 4.087715, -3.7375984, -21.210388, 3.5031013, -3.7589583, -21.084793, 3.5306554, -3.7589583, -21.205261, 3.4823828, -3.7375984, -21.210388, 3.5031013, -3.7375984, -21.105513, 3.5353546, -3.7589583, -21.084793, 3.5306554, -3.7375984, -21.105513, 3.5353546, -3.7589583, -21.082657, 4.045636, -3.7589583, -21.084793, 3.5306554, -3.7375984, -21.105513, 3.5353546, -3.7375984, -21.10359, 4.04051, -3.7589583, -21.082657, 4.045636, -3.7375984, -21.10359, 4.04051, -3.7589583, -21.215513, 4.1086473, -3.7589583, -21.082657, 4.045636, -3.7375984, -21.10359, 4.04051, -3.7375984, -21.219786, 4.087715, -3.7589583, -21.215513, 4.1086473, -3.7589583, -21.205261, 3.4823828, -3.7803178, -21.105513, 3.5353546, -3.7803178, -21.210388, 3.5031013, -3.7589583, -21.205261, 3.4823828, -3.7589583, -21.084793, 3.5306554, -3.7803178, -21.105513, 3.5353546, -3.7803178, -21.210388, 3.5031013, -3.7589583, -21.126444, 3.5398402, -3.7589583, -21.215513, 3.5238204, -3.7803178, -21.210388, 3.5031013, -3.7803178, -21.105513, 3.5353546, -3.7589583, -21.126444, 3.5398402, -3.7803178, -21.105513, 3.5353546, -3.7589583, -21.124308, 4.0355973, -3.7589583, -21.126444, 3.5398402, -3.7803178, -21.105513, 3.5353546, -3.7803178, -21.10359, 4.04051, -3.7589583, -21.124308, 4.0355973, -3.7803178, -21.10359, 4.04051, -3.7589583, -21.224058, 4.0667825, -3.7589583, -21.124308, 4.0355973, -3.7803178, -21.10359, 4.04051, -3.7803178, -21.219786, 4.087715, -3.7589583, -21.224058, 4.0667825, -3.7589583, -21.084793, 3.5306554, -3.7803178, -21.10359, 4.04051, -3.7803178, -21.105513, 3.5353546, -3.7589583, -21.084793, 3.5306554, -3.7589583, -21.082657, 4.045636, -3.7803178, -21.10359, 4.04051, -3.7589583, -21.082657, 4.045636, -3.7803178, -21.219786, 4.087715, -3.7803178, -21.10359, 4.04051, -3.7589583, -21.082657, 4.045636, -3.7589583, -21.215513, 4.1086473, -3.7803178, -21.219786, 4.087715, -3.7589583, -21.224058, 4.0667825, -3.7375984, -21.340467, 4.044782, -3.7375984, -21.219786, 4.087715, -3.7589583, -21.224058, 4.0667825, -3.7589583, -21.320816, 4.036238, -3.7375984, -21.340467, 4.044782, -3.7375984, -21.219786, 4.087715, -3.7589583, -21.360119, 4.0533257, -3.7589583, -21.215513, 4.1086473, -3.7375984, -21.219786, 4.087715, -3.7375984, -21.340467, 4.044782, -3.7589583, -21.360119, 4.0533257, -3.7589583, -21.215513, 4.1086473, -3.7803178, -21.340467, 4.044782, -3.7803178, -21.219786, 4.087715, -3.7589583, -21.215513, 4.1086473, -3.7589583, -21.360119, 4.0533257, -3.7803178, -21.340467, 4.044782, -3.7803178, -21.219786, 4.087715, -3.7589583, -21.320816, 4.036238, -3.7589583, -21.224058, 4.0667825, -3.7803178, -21.219786, 4.087715, -3.7803178, -21.340467, 4.044782, -3.7589583, -21.320816, 4.036238, -7.491342, -21.333633, 1.4549251, -7.4672065, -21.35008, 1.0367032, -7.4672065, -21.355635, 1.4619738, -7.491342, -21.333633, 1.4549251, -7.491342, -21.328293, 1.0439655, -7.4672065, -21.35008, 1.0367032, -7.4672065, -21.355635, 1.4619738, -7.491342, -21.371868, 1.0292273, -7.491342, -21.377848, 1.4688089, -7.4672065, -21.355635, 1.4619738, -7.4672065, -21.35008, 1.0367032, -7.491342, -21.371868, 1.0292273, -7.491342, -21.377848, 1.4688089, -7.5154786, -21.35008, 1.0367032, -7.5154786, -21.355635, 1.4619738, -7.491342, -21.377848, 1.4688089, -7.491342, -21.371868, 1.0292273, -7.5154786, -21.35008, 1.0367032, -7.5154786, -21.355635, 1.4619738, -7.491342, -21.328293, 1.0439655, -7.491342, -21.333633, 1.4549251, -7.5154786, -21.355635, 1.4619738, -7.5154786, -21.35008, 1.0367032, -7.491342, -21.328293, 1.0439655, -7.491342, -21.328293, 1.0439655, -7.4672065, -21.208893, 1.0206835, -7.4672065, -21.35008, 1.0367032, -7.491342, -21.328293, 1.0439655, -7.491342, -21.21466, 1.0375576, -7.4672065, -21.208893, 1.0206835, -7.4672065, -21.35008, 1.0367032, -7.491342, -21.20334, 1.0038093, -7.491342, -21.371868, 1.0292273, -7.4672065, -21.35008, 1.0367032, -7.4672065, -21.208893, 1.0206835, -7.491342, -21.20334, 1.0038093, -7.491342, -21.371868, 1.0292273, -7.5154786, -21.208893, 1.0206835, -7.5154786, -21.35008, 1.0367032, -7.491342, -21.371868, 1.0292273, -7.491342, -21.20334, 1.0038093, -7.5154786, -21.208893, 1.0206835, -7.5154786, -21.35008, 1.0367032, -7.491342, -21.21466, 1.0375576, -7.491342, -21.328293, 1.0439655, -7.5154786, -21.35008, 1.0367032, -7.5154786, -21.208893, 1.0206835, -7.491342, -21.21466, 1.0375576, -7.491342, -21.21466, 1.0375576, -7.4672065, -21.090775, 1.0469557, -7.4672065, -21.208893, 1.0206835, -7.491342, -21.21466, 1.0375576, -7.491342, -21.11427, 1.0505869, -7.4672065, -21.090775, 1.0469557, -7.4672065, -21.208893, 1.0206835, -7.491342, -21.067278, 1.0431108, -7.491342, -21.20334, 1.0038093, -7.4672065, -21.208893, 1.0206835, -7.4672065, -21.090775, 1.0469557, -7.491342, -21.067278, 1.0431108, -7.491342, -21.20334, 1.0038093, -7.5154786, -21.090775, 1.0469557, -7.5154786, -21.208893, 1.0206835, -7.491342, -21.20334, 1.0038093, -7.491342, -21.067278, 1.0431108, -7.5154786, -21.090775, 1.0469557, -7.5154786, -21.208893, 1.0206835, -7.491342, -21.11427, 1.0505869, -7.491342, -21.21466, 1.0375576, -7.5154786, -21.208893, 1.0206835, -7.5154786, -21.090775, 1.0469557, -7.491342, -21.11427, 1.0505869, -7.491342, -21.11427, 1.0505869, -7.4672065, -21.088425, 1.4585562, -7.4672065, -21.090775, 1.0469557, -7.491342, -21.11427, 1.0505869, -7.491342, -21.111706, 1.4542842, -7.4672065, -21.088425, 1.4585562, -7.4672065, -21.090775, 1.0469557, -7.491342, -21.06493, 1.4626144, -7.491342, -21.067278, 1.0431108, -7.4672065, -21.090775, 1.0469557, -7.4672065, -21.088425, 1.4585562, -7.491342, -21.06493, 1.4626144, -7.491342, -21.067278, 1.0431108, -7.5154786, -21.088425, 1.4585562, -7.5154786, -21.090775, 1.0469557, -7.491342, -21.067278, 1.0431108, -7.491342, -21.06493, 1.4626144, -7.5154786, -21.088425, 1.4585562, -7.5154786, -21.090775, 1.0469557, -7.491342, -21.111706, 1.4542842, -7.491342, -21.11427, 1.0505869, -7.5154786, -21.090775, 1.0469557, -7.5154786, -21.088425, 1.4585562, -7.491342, -21.111706, 1.4542842, -7.491342, -21.111706, 1.4542842, -7.4672065, -21.219572, 1.4970036, -7.4672065, -21.088425, 1.4585562, -7.491342, -21.111706, 1.4542842, -7.491342, -21.224485, 1.4799159, -7.4672065, -21.219572, 1.4970036, -7.4672065, -21.088425, 1.4585562, -7.491342, -21.214874, 1.5138776, -7.491342, -21.06493, 1.4626144, -7.4672065, -21.088425, 1.4585562, -7.4672065, -21.219572, 1.4970036, -7.491342, -21.214874, 1.5138776, -7.491342, -21.06493, 1.4626144, -7.5154786, -21.219572, 1.4970036, -7.5154786, -21.088425, 1.4585562, -7.491342, -21.06493, 1.4626144, -7.491342, -21.214874, 1.5138776, -7.5154786, -21.219572, 1.4970036, -7.5154786, -21.088425, 1.4585562, -7.491342, -21.224485, 1.4799159, -7.491342, -21.111706, 1.4542842, -7.5154786, -21.088425, 1.4585562, -7.5154786, -21.219572, 1.4970036, -7.491342, -21.224485, 1.4799159, -7.491342, -21.224485, 1.4799159, -7.4672065, -21.355635, 1.4619738, -7.4672065, -21.219572, 1.4970036, -7.491342, -21.224485, 1.4799159, -7.491342, -21.333633, 1.4549251, -7.4672065, -21.355635, 1.4619738, -7.4672065, -21.219572, 1.4970036, -7.491342, -21.377848, 1.4688089, -7.491342, -21.214874, 1.5138776, -7.4672065, -21.219572, 1.4970036, -7.4672065, -21.355635, 1.4619738, -7.491342, -21.377848, 1.4688089, -7.491342, -21.214874, 1.5138776, -7.5154786, -21.355635, 1.4619738, -7.5154786, -21.219572, 1.4970036, -7.491342, -21.214874, 1.5138776, -7.491342, -21.377848, 1.4688089, -7.5154786, -21.355635, 1.4619738, -7.5154786, -21.219572, 1.4970036, -7.491342, -21.333633, 1.4549251, -7.491342, -21.224485, 1.4799159, -7.5154786, -21.219572, 1.4970036, -7.5154786, -21.355635, 1.4619738, -7.491342, -21.333633, 1.4549251, -7.4881387, -21.333633, 2.66858, -7.464002, -21.35008, 2.2503583, -7.464002, -21.355633, 2.6756287, -7.4881387, -21.333633, 2.66858, -7.4881387, -21.328293, 2.2576206, -7.464002, -21.35008, 2.2503583, -7.464002, -21.355633, 2.6756287, -7.4881387, -21.371866, 2.2428825, -7.4881387, -21.377848, 2.682464, -7.464002, -21.355633, 2.6756287, -7.464002, -21.35008, 2.2503583, -7.4881387, -21.371866, 2.2428825, -7.4881387, -21.377848, 2.682464, -7.512275, -21.35008, 2.2503583, -7.512275, -21.355633, 2.6756287, -7.4881387, -21.377848, 2.682464, -7.4881387, -21.371866, 2.2428825, -7.512275, -21.35008, 2.2503583, -7.512275, -21.355633, 2.6756287, -7.4881387, -21.328293, 2.2576206, -7.4881387, -21.333633, 2.66858, -7.512275, -21.355633, 2.6756287, -7.512275, -21.35008, 2.2503583, -7.4881387, -21.328293, 2.2576206, -7.4881387, -21.328293, 2.2576206, -7.464002, -21.208893, 2.2343385, -7.464002, -21.35008, 2.2503583, -7.4881387, -21.328293, 2.2576206, -7.4881387, -21.21466, 2.2512126, -7.464002, -21.208893, 2.2343385, -7.464002, -21.35008, 2.2503583, -7.4881387, -21.203339, 2.2174644, -7.4881387, -21.371866, 2.2428825, -7.464002, -21.35008, 2.2503583, -7.464002, -21.208893, 2.2343385, -7.4881387, -21.203339, 2.2174644, -7.4881387, -21.371866, 2.2428825, -7.512275, -21.208893, 2.2343385, -7.512275, -21.35008, 2.2503583, -7.4881387, -21.371866, 2.2428825, -7.4881387, -21.203339, 2.2174644, -7.512275, -21.208893, 2.2343385, -7.512275, -21.35008, 2.2503583, -7.4881387, -21.21466, 2.2512126, -7.4881387, -21.328293, 2.2576206, -7.512275, -21.35008, 2.2503583, -7.512275, -21.208893, 2.2343385, -7.4881387, -21.21466, 2.2512126, -7.4881387, -21.21466, 2.2512126, -7.464002, -21.090775, 2.2606108, -7.464002, -21.208893, 2.2343385, -7.4881387, -21.21466, 2.2512126, -7.4881387, -21.11427, 2.264242, -7.464002, -21.090775, 2.2606108, -7.464002, -21.208893, 2.2343385, -7.4881387, -21.067278, 2.256766, -7.4881387, -21.203339, 2.2174644, -7.464002, -21.208893, 2.2343385, -7.464002, -21.090775, 2.2606108, -7.4881387, -21.067278, 2.256766, -7.4881387, -21.203339, 2.2174644, -7.512275, -21.090775, 2.2606108, -7.512275, -21.208893, 2.2343385, -7.4881387, -21.203339, 2.2174644, -7.4881387, -21.067278, 2.256766, -7.512275, -21.090775, 2.2606108, -7.512275, -21.208893, 2.2343385, -7.4881387, -21.11427, 2.264242, -7.4881387, -21.21466, 2.2512126, -7.512275, -21.208893, 2.2343385, -7.512275, -21.090775, 2.2606108, -7.4881387, -21.11427, 2.264242, -7.4881387, -21.11427, 2.264242, -7.464002, -21.088425, 2.6722112, -7.464002, -21.090775, 2.2606108, -7.4881387, -21.11427, 2.264242, -7.4881387, -21.111706, 2.6679392, -7.464002, -21.088425, 2.6722112, -7.464002, -21.090775, 2.2606108, -7.4881387, -21.064928, 2.6762695, -7.4881387, -21.067278, 2.256766, -7.464002, -21.090775, 2.2606108, -7.464002, -21.088425, 2.6722112, -7.4881387, -21.064928, 2.6762695, -7.4881387, -21.067278, 2.256766, -7.512275, -21.088425, 2.6722112, -7.512275, -21.090775, 2.2606108, -7.4881387, -21.067278, 2.256766, -7.4881387, -21.064928, 2.6762695, -7.512275, -21.088425, 2.6722112, -7.512275, -21.090775, 2.2606108, -7.4881387, -21.111706, 2.6679392, -7.4881387, -21.11427, 2.264242, -7.512275, -21.090775, 2.2606108, -7.512275, -21.088425, 2.6722112, -7.4881387, -21.111706, 2.6679392, -7.4881387, -21.111706, 2.6679392, -7.464002, -21.219572, 2.7106586, -7.464002, -21.088425, 2.6722112, -7.4881387, -21.111706, 2.6679392, -7.4881387, -21.224485, 2.6935709, -7.464002, -21.219572, 2.7106586, -7.464002, -21.088425, 2.6722112, -7.4881387, -21.214874, 2.7275326, -7.4881387, -21.064928, 2.6762695, -7.464002, -21.088425, 2.6722112, -7.464002, -21.219572, 2.7106586, -7.4881387, -21.214874, 2.7275326, -7.4881387, -21.064928, 2.6762695, -7.512275, -21.219572, 2.7106586, -7.512275, -21.088425, 2.6722112, -7.4881387, -21.064928, 2.6762695, -7.4881387, -21.214874, 2.7275326, -7.512275, -21.219572, 2.7106586, -7.512275, -21.088425, 2.6722112, -7.4881387, -21.224485, 2.6935709, -7.4881387, -21.111706, 2.6679392, -7.512275, -21.088425, 2.6722112, -7.512275, -21.219572, 2.7106586, -7.4881387, -21.224485, 2.6935709, -7.4881387, -21.224485, 2.6935709, -7.464002, -21.355633, 2.6756287, -7.464002, -21.219572, 2.7106586, -7.4881387, -21.224485, 2.6935709, -7.4881387, -21.333633, 2.66858, -7.464002, -21.355633, 2.6756287, -7.464002, -21.219572, 2.7106586, -7.4881387, -21.377848, 2.682464, -7.4881387, -21.214874, 2.7275326, -7.464002, -21.219572, 2.7106586, -7.464002, -21.355633, 2.6756287, -7.4881387, -21.377848, 2.682464, -7.4881387, -21.214874, 2.7275326, -7.512275, -21.355633, 2.6756287, -7.512275, -21.219572, 2.7106586, -7.4881387, -21.214874, 2.7275326, -7.4881387, -21.377848, 2.682464, -7.512275, -21.355633, 2.6756287, -7.512275, -21.219572, 2.7106586, -7.4881387, -21.333633, 2.66858, -7.4881387, -21.224485, 2.6935709, -7.512275, -21.219572, 2.7106586, -7.512275, -21.355633, 2.6756287, -7.4881387, -21.333633, 2.66858, -3.7905703, -21.320818, 1.4850421, -3.7692103, -21.335342, 0.9713427, -3.7692103, -21.34047, 1.4933724, -3.7905703, -21.320818, 1.4850421, -3.7905703, -21.316118, 0.98031366, -3.7692103, -21.335342, 0.9713427, -3.7905703, -21.316118, 0.98031366, -3.7692103, -21.210388, 0.95169187, -3.7692103, -21.335342, 0.9713427, -3.7905703, -21.316118, 0.98031366, -3.7905703, -21.215515, 0.9724107, -3.7692103, -21.210388, 0.95169187, -3.7692103, -21.34047, 1.4933724, -3.7905703, -21.35478, 0.9623716, -3.7905703, -21.360119, 1.5019164, -3.7692103, -21.34047, 1.4933724, -3.7692103, -21.335342, 0.9713427, -3.7905703, -21.35478, 0.9623716, -3.7692103, -21.335342, 0.9713427, -3.7905703, -21.205261, 0.93097293, -3.7905703, -21.35478, 0.9623716, -3.7692103, -21.335342, 0.9713427, -3.7692103, -21.210388, 0.95169187, -3.7905703, -21.205261, 0.93097293, -3.7905703, -21.360119, 1.5019164, -3.8119302, -21.335342, 0.9713427, -3.8119302, -21.34047, 1.4933724, -3.7905703, -21.360119, 1.5019164, -3.7905703, -21.35478, 0.9623716, -3.8119302, -21.335342, 0.9713427, -3.7905703, -21.35478, 0.9623716, -3.8119302, -21.210388, 0.95169187, -3.8119302, -21.335342, 0.9713427, -3.7905703, -21.35478, 0.9623716, -3.7905703, -21.205261, 0.93097293, -3.8119302, -21.210388, 0.95169187, -3.8119302, -21.34047, 1.4933724, -3.7905703, -21.316118, 0.98031366, -3.7905703, -21.320818, 1.4850421, -3.8119302, -21.34047, 1.4933724, -3.8119302, -21.335342, 0.9713427, -3.7905703, -21.316118, 0.98031366, -3.8119302, -21.335342, 0.9713427, -3.7905703, -21.215515, 0.9724107, -3.7905703, -21.316118, 0.98031366, -3.8119302, -21.335342, 0.9713427, -3.8119302, -21.210388, 0.95169187, -3.7905703, -21.215515, 0.9724107, -3.7905703, -21.215515, 0.9724107, -3.7692103, -21.105513, 0.9839448, -3.7692103, -21.210388, 0.95169187, -3.7905703, -21.215515, 0.9724107, -3.7905703, -21.126446, 0.9884304, -3.7692103, -21.105513, 0.9839448, -3.7905703, -21.126446, 0.9884304, -3.7692103, -21.10359, 1.489314, -3.7692103, -21.105513, 0.9839448, -3.7905703, -21.126446, 0.9884304, -3.7905703, -21.12431, 1.4841876, -3.7692103, -21.10359, 1.489314, -3.7905703, -21.12431, 1.4841876, -3.7692103, -21.219786, 1.5363052, -3.7692103, -21.10359, 1.489314, -3.7905703, -21.12431, 1.4841876, -3.7905703, -21.224058, 1.5153728, -3.7692103, -21.219786, 1.5363052, -3.7692103, -21.210388, 0.95169187, -3.7905703, -21.084793, 0.9792458, -3.7905703, -21.205261, 0.93097293, -3.7692103, -21.210388, 0.95169187, -3.7692103, -21.105513, 0.9839448, -3.7905703, -21.084793, 0.9792458, -3.7692103, -21.105513, 0.9839448, -3.7905703, -21.082657, 1.4942267, -3.7905703, -21.084793, 0.9792458, -3.7692103, -21.105513, 0.9839448, -3.7692103, -21.10359, 1.489314, -3.7905703, -21.082657, 1.4942267, -3.7692103, -21.10359, 1.489314, -3.7905703, -21.215515, 1.5574512, -3.7905703, -21.082657, 1.4942267, -3.7692103, -21.10359, 1.489314, -3.7692103, -21.219786, 1.5363052, -3.7905703, -21.215515, 1.5574512, -3.7905703, -21.205261, 0.93097293, -3.8119302, -21.105513, 0.9839448, -3.8119302, -21.210388, 0.95169187, -3.7905703, -21.205261, 0.93097293, -3.7905703, -21.084793, 0.9792458, -3.8119302, -21.105513, 0.9839448, -3.8119302, -21.210388, 0.95169187, -3.7905703, -21.126446, 0.9884304, -3.7905703, -21.215515, 0.9724107, -3.8119302, -21.210388, 0.95169187, -3.8119302, -21.105513, 0.9839448, -3.7905703, -21.126446, 0.9884304, -3.8119302, -21.105513, 0.9839448, -3.7905703, -21.12431, 1.4841876, -3.7905703, -21.126446, 0.9884304, -3.8119302, -21.105513, 0.9839448, -3.8119302, -21.10359, 1.489314, -3.7905703, -21.12431, 1.4841876, -3.8119302, -21.10359, 1.489314, -3.7905703, -21.224058, 1.5153728, -3.7905703, -21.12431, 1.4841876, -3.8119302, -21.10359, 1.489314, -3.8119302, -21.219786, 1.5363052, -3.7905703, -21.224058, 1.5153728, -3.7905703, -21.084793, 0.9792458, -3.8119302, -21.10359, 1.489314, -3.8119302, -21.105513, 0.9839448, -3.7905703, -21.084793, 0.9792458, -3.7905703, -21.082657, 1.4942267, -3.8119302, -21.10359, 1.489314, -3.7905703, -21.082657, 1.4942267, -3.8119302, -21.219786, 1.5363052, -3.8119302, -21.10359, 1.489314, -3.7905703, -21.082657, 1.4942267, -3.7905703, -21.215515, 1.5574512, -3.8119302, -21.219786, 1.5363052, -3.7905703, -21.224058, 1.5153728, -3.7692103, -21.34047, 1.4933724, -3.7692103, -21.219786, 1.5363052, -3.7905703, -21.224058, 1.5153728, -3.7905703, -21.320818, 1.4850421, -3.7692103, -21.34047, 1.4933724, -3.7692103, -21.219786, 1.5363052, -3.7905703, -21.360119, 1.5019164, -3.7905703, -21.215515, 1.5574512, -3.7692103, -21.219786, 1.5363052, -3.7692103, -21.34047, 1.4933724, -3.7905703, -21.360119, 1.5019164, -3.7905703, -21.215515, 1.5574512, -3.8119302, -21.34047, 1.4933724, -3.8119302, -21.219786, 1.5363052, -3.7905703, -21.215515, 1.5574512, -3.7905703, -21.360119, 1.5019164, -3.8119302, -21.34047, 1.4933724, -3.8119302, -21.219786, 1.5363052, -3.7905703, -21.320818, 1.4850421, -3.7905703, -21.224058, 1.5153728, -3.8119302, -21.219786, 1.5363052, -3.8119302, -21.34047, 1.4933724, -3.7905703, -21.320818, 1.4850421, -3.8458915, -21.320818, 2.7324455, -3.824532, -21.335342, 2.218746, -3.824532, -21.340467, 2.7409892, -3.8458915, -21.320818, 2.7324455, -3.8458915, -21.316118, 2.227717, -3.824532, -21.335342, 2.218746, -3.8458915, -21.316118, 2.227717, -3.824532, -21.210388, 2.199095, -3.824532, -21.335342, 2.218746, -3.8458915, -21.316118, 2.227717, -3.8458915, -21.215515, 2.2200274, -3.824532, -21.210388, 2.199095, -3.824532, -21.340467, 2.7409892, -3.8458915, -21.354778, 2.209775, -3.8458915, -21.360119, 2.7495332, -3.824532, -21.340467, 2.7409892, -3.824532, -21.335342, 2.218746, -3.8458915, -21.354778, 2.209775, -3.824532, -21.335342, 2.218746, -3.8458915, -21.205261, 2.1783762, -3.8458915, -21.354778, 2.209775, -3.824532, -21.335342, 2.218746, -3.824532, -21.210388, 2.199095, -3.8458915, -21.205261, 2.1783762, -3.8458915, -21.360119, 2.7495332, -3.8672514, -21.335342, 2.218746, -3.8672514, -21.340467, 2.7409892, -3.8458915, -21.360119, 2.7495332, -3.8458915, -21.354778, 2.209775, -3.8672514, -21.335342, 2.218746, -3.8458915, -21.354778, 2.209775, -3.8672514, -21.210388, 2.199095, -3.8672514, -21.335342, 2.218746, -3.8458915, -21.354778, 2.209775, -3.8458915, -21.205261, 2.1783762, -3.8672514, -21.210388, 2.199095, -3.8672514, -21.340467, 2.7409892, -3.8458915, -21.316118, 2.227717, -3.8458915, -21.320818, 2.7324455, -3.8672514, -21.340467, 2.7409892, -3.8672514, -21.335342, 2.218746, -3.8458915, -21.316118, 2.227717, -3.8672514, -21.335342, 2.218746, -3.8458915, -21.215515, 2.2200274, -3.8458915, -21.316118, 2.227717, -3.8672514, -21.335342, 2.218746, -3.8672514, -21.210388, 2.199095, -3.8458915, -21.215515, 2.2200274, -3.8458915, -21.215515, 2.2200274, -3.824532, -21.105513, 2.231348, -3.824532, -21.210388, 2.199095, -3.8458915, -21.215515, 2.2200274, -3.8458915, -21.126444, 2.2360473, -3.824532, -21.105513, 2.231348, -3.8458915, -21.126444, 2.2360473, -3.824532, -21.10359, 2.7367172, -3.824532, -21.105513, 2.231348, -3.8458915, -21.126444, 2.2360473, -3.8458915, -21.124308, 2.731591, -3.824532, -21.10359, 2.7367172, -3.8458915, -21.124308, 2.731591, -3.824532, -21.219786, 2.7839222, -3.824532, -21.10359, 2.7367172, -3.8458915, -21.124308, 2.731591, -3.8458915, -21.224058, 2.7629898, -3.824532, -21.219786, 2.7839222, -3.824532, -21.210388, 2.199095, -3.8458915, -21.084793, 2.2268624, -3.8458915, -21.205261, 2.1783762, -3.824532, -21.210388, 2.199095, -3.824532, -21.105513, 2.231348, -3.8458915, -21.084793, 2.2268624, -3.824532, -21.105513, 2.231348, -3.8458915, -21.082657, 2.7418437, -3.8458915, -21.084793, 2.2268624, -3.824532, -21.105513, 2.231348, -3.824532, -21.10359, 2.7367172, -3.8458915, -21.082657, 2.7418437, -3.824532, -21.10359, 2.7367172, -3.8458915, -21.215513, 2.8048546, -3.8458915, -21.082657, 2.7418437, -3.824532, -21.10359, 2.7367172, -3.824532, -21.219786, 2.7839222, -3.8458915, -21.215513, 2.8048546, -3.8458915, -21.205261, 2.1783762, -3.8672514, -21.105513, 2.231348, -3.8672514, -21.210388, 2.199095, -3.8458915, -21.205261, 2.1783762, -3.8458915, -21.084793, 2.2268624, -3.8672514, -21.105513, 2.231348, -3.8672514, -21.210388, 2.199095, -3.8458915, -21.126444, 2.2360473, -3.8458915, -21.215515, 2.2200274, -3.8672514, -21.210388, 2.199095, -3.8672514, -21.105513, 2.231348, -3.8458915, -21.126444, 2.2360473, -3.8672514, -21.105513, 2.231348, -3.8458915, -21.124308, 2.731591, -3.8458915, -21.126444, 2.2360473, -3.8672514, -21.105513, 2.231348, -3.8672514, -21.10359, 2.7367172, -3.8458915, -21.124308, 2.731591, -3.8672514, -21.10359, 2.7367172, -3.8458915, -21.224058, 2.7629898, -3.8458915, -21.124308, 2.731591, -3.8672514, -21.10359, 2.7367172, -3.8672514, -21.219786, 2.7839222, -3.8458915, -21.224058, 2.7629898, -3.8458915, -21.084793, 2.2268624, -3.8672514, -21.10359, 2.7367172, -3.8672514, -21.105513, 2.231348, -3.8458915, -21.084793, 2.2268624, -3.8458915, -21.082657, 2.7418437, -3.8672514, -21.10359, 2.7367172, -3.8458915, -21.082657, 2.7418437, -3.8672514, -21.219786, 2.7839222, -3.8672514, -21.10359, 2.7367172, -3.8458915, -21.082657, 2.7418437, -3.8458915, -21.215513, 2.8048546, -3.8672514, -21.219786, 2.7839222, -3.8458915, -21.224058, 2.7629898, -3.824532, -21.340467, 2.7409892, -3.824532, -21.219786, 2.7839222, -3.8458915, -21.224058, 2.7629898, -3.8458915, -21.320818, 2.7324455, -3.824532, -21.340467, 2.7409892, -3.824532, -21.219786, 2.7839222, -3.8458915, -21.360119, 2.7495332, -3.8458915, -21.215513, 2.8048546, -3.824532, -21.219786, 2.7839222, -3.824532, -21.340467, 2.7409892, -3.8458915, -21.360119, 2.7495332, -3.8458915, -21.215513, 2.8048546, -3.8672514, -21.340467, 2.7409892, -3.8672514, -21.219786, 2.7839222, -3.8458915, -21.215513, 2.8048546, -3.8458915, -21.360119, 2.7495332, -3.8672514, -21.340467, 2.7409892, -3.8672514, -21.219786, 2.7839222, -3.8458915, -21.320818, 2.7324455, -3.8458915, -21.224058, 2.7629898, -3.8672514, -21.219786, 2.7839222, -3.8672514, -21.340467, 2.7409892, -3.8458915, -21.320818, 2.7324455, -3.774764, -21.320818, 2.140356, -3.7534046, -21.335342, 1.6268702, -3.7534046, -21.340467, 2.1489, -3.774764, -21.320818, 2.140356, -3.774764, -21.316118, 1.6358411, -3.7534046, -21.335342, 1.6268702, -3.774764, -21.316118, 1.6358411, -3.7534046, -21.210388, 1.6072192, -3.7534046, -21.335342, 1.6268702, -3.774764, -21.316118, 1.6358411, -3.774764, -21.215515, 1.6279382, -3.7534046, -21.210388, 1.6072192, -3.7534046, -21.340467, 2.1489, -3.774764, -21.35478, 1.6176856, -3.774764, -21.360119, 2.1574438, -3.7534046, -21.340467, 2.1489, -3.7534046, -21.335342, 1.6268702, -3.774764, -21.35478, 1.6176856, -3.7534046, -21.335342, 1.6268702, -3.774764, -21.205261, 1.5865004, -3.774764, -21.35478, 1.6176856, -3.7534046, -21.335342, 1.6268702, -3.7534046, -21.210388, 1.6072192, -3.774764, -21.205261, 1.5865004, -3.774764, -21.360119, 2.1574438, -3.796124, -21.335342, 1.6268702, -3.796124, -21.340467, 2.1489, -3.774764, -21.360119, 2.1574438, -3.774764, -21.35478, 1.6176856, -3.796124, -21.335342, 1.6268702, -3.774764, -21.35478, 1.6176856, -3.796124, -21.210388, 1.6072192, -3.796124, -21.335342, 1.6268702, -3.774764, -21.35478, 1.6176856, -3.774764, -21.205261, 1.5865004, -3.796124, -21.210388, 1.6072192, -3.796124, -21.340467, 2.1489, -3.774764, -21.316118, 1.6358411, -3.774764, -21.320818, 2.140356, -3.796124, -21.340467, 2.1489, -3.796124, -21.335342, 1.6268702, -3.774764, -21.316118, 1.6358411, -3.796124, -21.335342, 1.6268702, -3.774764, -21.215515, 1.6279382, -3.774764, -21.316118, 1.6358411, -3.796124, -21.335342, 1.6268702, -3.796124, -21.210388, 1.6072192, -3.774764, -21.215515, 1.6279382, -3.774764, -21.215515, 1.6279382, -3.7534046, -21.105513, 1.6392586, -3.7534046, -21.210388, 1.6072192, -3.774764, -21.215515, 1.6279382, -3.774764, -21.126446, 1.6439579, -3.7534046, -21.105513, 1.6392586, -3.774764, -21.126446, 1.6439579, -3.7534046, -21.10359, 2.144628, -3.7534046, -21.105513, 1.6392586, -3.774764, -21.126446, 1.6439579, -3.774764, -21.12431, 2.1395016, -3.7534046, -21.10359, 2.144628, -3.774764, -21.12431, 2.1395016, -3.7534046, -21.219786, 2.1918328, -3.7534046, -21.10359, 2.144628, -3.774764, -21.12431, 2.1395016, -3.774764, -21.224058, 2.1709003, -3.7534046, -21.219786, 2.1918328, -3.7534046, -21.210388, 1.6072192, -3.774764, -21.084793, 1.6347731, -3.774764, -21.205261, 1.5865004, -3.7534046, -21.210388, 1.6072192, -3.7534046, -21.105513, 1.6392586, -3.774764, -21.084793, 1.6347731, -3.7534046, -21.105513, 1.6392586, -3.774764, -21.082657, 2.1497543, -3.774764, -21.084793, 1.6347731, -3.7534046, -21.105513, 1.6392586, -3.7534046, -21.10359, 2.144628, -3.774764, -21.082657, 2.1497543, -3.7534046, -21.10359, 2.144628, -3.774764, -21.215515, 2.2127652, -3.774764, -21.082657, 2.1497543, -3.7534046, -21.10359, 2.144628, -3.7534046, -21.219786, 2.1918328, -3.774764, -21.215515, 2.2127652, -3.774764, -21.205261, 1.5865004, -3.796124, -21.105513, 1.6392586, -3.796124, -21.210388, 1.6072192, -3.774764, -21.205261, 1.5865004, -3.774764, -21.084793, 1.6347731, -3.796124, -21.105513, 1.6392586, -3.796124, -21.210388, 1.6072192, -3.774764, -21.126446, 1.6439579, -3.774764, -21.215515, 1.6279382, -3.796124, -21.210388, 1.6072192, -3.796124, -21.105513, 1.6392586, -3.774764, -21.126446, 1.6439579, -3.796124, -21.105513, 1.6392586, -3.774764, -21.12431, 2.1395016, -3.774764, -21.126446, 1.6439579, -3.796124, -21.105513, 1.6392586, -3.796124, -21.10359, 2.144628, -3.774764, -21.12431, 2.1395016, -3.796124, -21.10359, 2.144628, -3.774764, -21.224058, 2.1709003, -3.774764, -21.12431, 2.1395016, -3.796124, -21.10359, 2.144628, -3.796124, -21.219786, 2.1918328, -3.774764, -21.224058, 2.1709003, -3.774764, -21.084793, 1.6347731, -3.796124, -21.10359, 2.144628, -3.796124, -21.105513, 1.6392586, -3.774764, -21.084793, 1.6347731, -3.774764, -21.082657, 2.1497543, -3.796124, -21.10359, 2.144628, -3.774764, -21.082657, 2.1497543, -3.796124, -21.219786, 2.1918328, -3.796124, -21.10359, 2.144628, -3.774764, -21.082657, 2.1497543, -3.774764, -21.215515, 2.2127652, -3.796124, -21.219786, 2.1918328, -3.774764, -21.224058, 2.1709003, -3.7534046, -21.340467, 2.1489, -3.7534046, -21.219786, 2.1918328, -3.774764, -21.224058, 2.1709003, -3.774764, -21.320818, 2.140356, -3.7534046, -21.340467, 2.1489, -3.7534046, -21.219786, 2.1918328, -3.774764, -21.360119, 2.1574438, -3.774764, -21.215515, 2.2127652, -3.7534046, -21.219786, 2.1918328, -3.7534046, -21.340467, 2.1489, -3.774764, -21.360119, 2.1574438, -3.774764, -21.215515, 2.2127652, -3.796124, -21.340467, 2.1489, -3.796124, -21.219786, 2.1918328, -3.774764, -21.215515, 2.2127652, -3.774764, -21.360119, 2.1574438, -3.796124, -21.340467, 2.1489, -3.796124, -21.219786, 2.1918328, -3.774764, -21.320818, 2.140356, -3.774764, -21.224058, 2.1709003, -3.796124, -21.219786, 2.1918328, -3.796124, -21.340467, 2.1489, -3.774764, -21.320818, 2.140356, -7.4558854, -21.333633, -0.46060777, -7.431749, -21.35008, -0.89848065, -7.431749, -21.355635, -0.45334554, -7.4558854, -21.333633, -0.46060777, -7.4558854, -21.328295, -0.89057755, -7.431749, -21.35008, -0.89848065, -7.431749, -21.355635, -0.45334554, -7.4558854, -21.371868, -0.9061701, -7.4558854, -21.37785, -0.4460833, -7.431749, -21.355635, -0.45334554, -7.431749, -21.35008, -0.89848065, -7.4558854, -21.371868, -0.9061701, -7.4558854, -21.37785, -0.4460833, -7.480022, -21.35008, -0.89848065, -7.480022, -21.355635, -0.45334554, -7.4558854, -21.37785, -0.4460833, -7.4558854, -21.371868, -0.9061701, -7.480022, -21.35008, -0.89848065, -7.480022, -21.355635, -0.45334554, -7.4558854, -21.328295, -0.89057755, -7.4558854, -21.333633, -0.46060777, -7.480022, -21.355635, -0.45334554, -7.480022, -21.35008, -0.89848065, -7.4558854, -21.328295, -0.89057755, -7.4558854, -21.328295, -0.89057755, -7.431749, -21.208895, -0.9149277, -7.431749, -21.35008, -0.89848065, -7.4558854, -21.328295, -0.89057755, -7.4558854, -21.21466, -0.8974128, -7.431749, -21.208895, -0.9149277, -7.431749, -21.35008, -0.89848065, -7.4558854, -21.20334, -0.9326565, -7.4558854, -21.371868, -0.9061701, -7.431749, -21.35008, -0.89848065, -7.431749, -21.208895, -0.9149277, -7.4558854, -21.20334, -0.9326565, -7.4558854, -21.371868, -0.9061701, -7.480022, -21.208895, -0.9149277, -7.480022, -21.35008, -0.89848065, -7.4558854, -21.371868, -0.9061701, -7.4558854, -21.20334, -0.9326565, -7.480022, -21.208895, -0.9149277, -7.480022, -21.35008, -0.89848065, -7.4558854, -21.21466, -0.8974128, -7.4558854, -21.328295, -0.89057755, -7.480022, -21.35008, -0.89848065, -7.480022, -21.208895, -0.9149277, -7.4558854, -21.21466, -0.8974128, -7.4558854, -21.21466, -0.8974128, -7.431749, -21.090775, -0.88758755, -7.431749, -21.208895, -0.9149277, -7.4558854, -21.21466, -0.8974128, -7.4558854, -21.114271, -0.8837426, -7.431749, -21.090775, -0.88758755, -7.431749, -21.208895, -0.9149277, -7.4558854, -21.06728, -0.89164567, -7.4558854, -21.20334, -0.9326565, -7.431749, -21.208895, -0.9149277, -7.431749, -21.090775, -0.88758755, -7.4558854, -21.06728, -0.89164567, -7.4558854, -21.20334, -0.9326565, -7.480022, -21.090775, -0.88758755, -7.480022, -21.208895, -0.9149277, -7.4558854, -21.20334, -0.9326565, -7.4558854, -21.06728, -0.89164567, -7.480022, -21.090775, -0.88758755, -7.480022, -21.208895, -0.9149277, -7.4558854, -21.114271, -0.8837426, -7.4558854, -21.21466, -0.8974128, -7.480022, -21.208895, -0.9149277, -7.480022, -21.090775, -0.88758755, -7.4558854, -21.114271, -0.8837426, -7.4558854, -21.114271, -0.8837426, -7.431749, -21.088425, -0.45697713, -7.431749, -21.090775, -0.88758755, -7.4558854, -21.114271, -0.8837426, -7.4558854, -21.111708, -0.46124887, -7.431749, -21.088425, -0.45697713, -7.4558854, -21.111708, -0.46124887, -7.431749, -21.219574, -0.416821, -7.431749, -21.088425, -0.45697713, -7.4558854, -21.111708, -0.46124887, -7.4558854, -21.224487, -0.43454933, -7.431749, -21.219574, -0.416821, -7.431749, -21.090775, -0.88758755, -7.4558854, -21.06493, -0.45249128, -7.4558854, -21.06728, -0.89164567, -7.431749, -21.090775, -0.88758755, -7.431749, -21.088425, -0.45697713, -7.4558854, -21.06493, -0.45249128, -7.431749, -21.088425, -0.45697713, -7.4558854, -21.214874, -0.39887857, -7.4558854, -21.06493, -0.45249128, -7.431749, -21.088425, -0.45697713, -7.431749, -21.219574, -0.416821, -7.4558854, -21.214874, -0.39887857, -7.4558854, -21.06728, -0.89164567, -7.480022, -21.088425, -0.45697713, -7.480022, -21.090775, -0.88758755, -7.4558854, -21.06728, -0.89164567, -7.4558854, -21.06493, -0.45249128, -7.480022, -21.088425, -0.45697713, -7.4558854, -21.06493, -0.45249128, -7.480022, -21.219574, -0.416821, -7.480022, -21.088425, -0.45697713, -7.4558854, -21.06493, -0.45249128, -7.4558854, -21.214874, -0.39887857, -7.480022, -21.219574, -0.416821, -7.480022, -21.090775, -0.88758755, -7.4558854, -21.111708, -0.46124887, -7.4558854, -21.114271, -0.8837426, -7.480022, -21.090775, -0.88758755, -7.480022, -21.088425, -0.45697713, -7.4558854, -21.111708, -0.46124887, -7.480022, -21.088425, -0.45697713, -7.4558854, -21.224487, -0.43454933, -7.4558854, -21.111708, -0.46124887, -7.480022, -21.088425, -0.45697713, -7.480022, -21.219574, -0.416821, -7.4558854, -21.224487, -0.43454933, -7.4558854, -21.224487, -0.43454933, -7.431749, -21.355635, -0.45334554, -7.431749, -21.219574, -0.416821, -7.4558854, -21.224487, -0.43454933, -7.4558854, -21.333633, -0.46060777, -7.431749, -21.355635, -0.45334554, -7.431749, -21.219574, -0.416821, -7.4558854, -21.37785, -0.4460833, -7.4558854, -21.214874, -0.39887857, -7.431749, -21.219574, -0.416821, -7.431749, -21.355635, -0.45334554, -7.4558854, -21.37785, -0.4460833, -7.4558854, -21.214874, -0.39887857, -7.480022, -21.355635, -0.45334554, -7.480022, -21.219574, -0.416821, -7.4558854, -21.214874, -0.39887857, -7.4558854, -21.37785, -0.4460833, -7.480022, -21.355635, -0.45334554, -7.480022, -21.219574, -0.416821, -7.4558854, -21.333633, -0.46060777, -7.4558854, -21.224487, -0.43454933, -7.480022, -21.219574, -0.416821, -7.480022, -21.355635, -0.45334554, -7.4558854, -21.333633, -0.46060777, -3.8775039, -21.320818, 0.85066056, -3.8561444, -21.335342, 0.33696115, -3.8561444, -21.34047, 0.85920453, -3.8775039, -21.320818, 0.85066056, -3.8775039, -21.316118, 0.34593213, -3.8561444, -21.335342, 0.33696115, -3.8775039, -21.316118, 0.34593213, -3.8561444, -21.210388, 0.31752384, -3.8561444, -21.335342, 0.33696115, -3.8775039, -21.316118, 0.34593213, -3.8775039, -21.215515, 0.33824265, -3.8561444, -21.210388, 0.31752384, -3.8561444, -21.34047, 0.85920453, -3.8775039, -21.35478, 0.32799017, -3.8775039, -21.360119, 0.8677484, -3.8561444, -21.34047, 0.85920453, -3.8561444, -21.335342, 0.33696115, -3.8775039, -21.35478, 0.32799017, -3.8561444, -21.335342, 0.33696115, -3.8775039, -21.205263, 0.29680502, -3.8775039, -21.35478, 0.32799017, -3.8561444, -21.335342, 0.33696115, -3.8561444, -21.210388, 0.31752384, -3.8775039, -21.205263, 0.29680502, -3.8775039, -21.360119, 0.8677484, -3.8988638, -21.335342, 0.33696115, -3.8988638, -21.34047, 0.85920453, -3.8775039, -21.360119, 0.8677484, -3.8775039, -21.35478, 0.32799017, -3.8988638, -21.335342, 0.33696115, -3.8775039, -21.35478, 0.32799017, -3.8988638, -21.210388, 0.31752384, -3.8988638, -21.335342, 0.33696115, -3.8775039, -21.35478, 0.32799017, -3.8775039, -21.205263, 0.29680502, -3.8988638, -21.210388, 0.31752384, -3.8988638, -21.34047, 0.85920453, -3.8775039, -21.316118, 0.34593213, -3.8775039, -21.320818, 0.85066056, -3.8988638, -21.34047, 0.85920453, -3.8988638, -21.335342, 0.33696115, -3.8775039, -21.316118, 0.34593213, -3.8988638, -21.335342, 0.33696115, -3.8775039, -21.215515, 0.33824265, -3.8775039, -21.316118, 0.34593213, -3.8988638, -21.335342, 0.33696115, -3.8988638, -21.210388, 0.31752384, -3.8775039, -21.215515, 0.33824265, -3.8775039, -21.215515, 0.33824265, -3.8561444, -21.105513, 0.34956324, -3.8561444, -21.210388, 0.31752384, -3.8775039, -21.215515, 0.33824265, -3.8775039, -21.126446, 0.35404897, -3.8561444, -21.105513, 0.34956324, -3.8775039, -21.126446, 0.35404897, -3.8561444, -21.10359, 0.85493267, -3.8561444, -21.105513, 0.34956324, -3.8775039, -21.126446, 0.35404897, -3.8775039, -21.12431, 0.8498062, -3.8561444, -21.10359, 0.85493267, -3.8775039, -21.12431, 0.8498062, -3.8561444, -21.219788, 0.9021374, -3.8561444, -21.10359, 0.85493267, -3.8775039, -21.12431, 0.8498062, -3.8775039, -21.224058, 0.88120484, -3.8561444, -21.219788, 0.9021374, -3.8561444, -21.210388, 0.31752384, -3.8775039, -21.084793, 0.34507775, -3.8775039, -21.205263, 0.29680502, -3.8561444, -21.210388, 0.31752384, -3.8561444, -21.105513, 0.34956324, -3.8775039, -21.084793, 0.34507775, -3.8561444, -21.105513, 0.34956324, -3.8775039, -21.082657, 0.8600589, -3.8775039, -21.084793, 0.34507775, -3.8561444, -21.105513, 0.34956324, -3.8561444, -21.10359, 0.85493267, -3.8775039, -21.082657, 0.8600589, -3.8561444, -21.10359, 0.85493267, -3.8775039, -21.215515, 0.92306983, -3.8775039, -21.082657, 0.8600589, -3.8561444, -21.10359, 0.85493267, -3.8561444, -21.219788, 0.9021374, -3.8775039, -21.215515, 0.92306983, -3.8775039, -21.205263, 0.29680502, -3.8988638, -21.105513, 0.34956324, -3.8988638, -21.210388, 0.31752384, -3.8775039, -21.205263, 0.29680502, -3.8775039, -21.084793, 0.34507775, -3.8988638, -21.105513, 0.34956324, -3.8988638, -21.210388, 0.31752384, -3.8775039, -21.126446, 0.35404897, -3.8775039, -21.215515, 0.33824265, -3.8988638, -21.210388, 0.31752384, -3.8988638, -21.105513, 0.34956324, -3.8775039, -21.126446, 0.35404897, -3.8988638, -21.105513, 0.34956324, -3.8775039, -21.12431, 0.8498062, -3.8775039, -21.126446, 0.35404897, -3.8988638, -21.105513, 0.34956324, -3.8988638, -21.10359, 0.85493267, -3.8775039, -21.12431, 0.8498062, -3.8988638, -21.10359, 0.85493267, -3.8775039, -21.224058, 0.88120484, -3.8775039, -21.12431, 0.8498062, -3.8988638, -21.10359, 0.85493267, -3.8988638, -21.219788, 0.9021374, -3.8775039, -21.224058, 0.88120484, -3.8775039, -21.084793, 0.34507775, -3.8988638, -21.10359, 0.85493267, -3.8988638, -21.105513, 0.34956324, -3.8775039, -21.084793, 0.34507775, -3.8775039, -21.082657, 0.8600589, -3.8988638, -21.10359, 0.85493267, -3.8775039, -21.082657, 0.8600589, -3.8988638, -21.219788, 0.9021374, -3.8988638, -21.10359, 0.85493267, -3.8775039, -21.082657, 0.8600589, -3.8775039, -21.215515, 0.92306983, -3.8988638, -21.219788, 0.9021374, -3.8775039, -21.224058, 0.88120484, -3.8561444, -21.34047, 0.85920453, -3.8561444, -21.219788, 0.9021374, -3.8775039, -21.224058, 0.88120484, -3.8775039, -21.320818, 0.85066056, -3.8561444, -21.34047, 0.85920453, -3.8561444, -21.219788, 0.9021374, -3.8775039, -21.360119, 0.8677484, -3.8775039, -21.215515, 0.92306983, -3.8561444, -21.219788, 0.9021374, -3.8561444, -21.34047, 0.85920453, -3.8775039, -21.360119, 0.8677484, -3.8775039, -21.215515, 0.92306983, -3.8988638, -21.34047, 0.85920453, -3.8988638, -21.219788, 0.9021374, -3.8775039, -21.215515, 0.92306983, -3.8775039, -21.360119, 0.8677484, -3.8988638, -21.34047, 0.85920453, -3.8988638, -21.219788, 0.9021374, -3.8775039, -21.320818, 0.85066056, -3.8775039, -21.224058, 0.88120484, -3.8988638, -21.219788, 0.9021374, -3.8988638, -21.34047, 0.85920453, -3.8775039, -21.320818, 0.85066056, -3.7194428, -21.320816, 3.3595648, -3.698083, -21.335342, 2.8460789, -3.698083, -21.340467, 3.3681085, -3.7194428, -21.320816, 3.3595648, -3.7194428, -21.316118, 2.8550498, -3.698083, -21.335342, 2.8460789, -3.7194428, -21.316118, 2.8550498, -3.698083, -21.210388, 2.826428, -3.698083, -21.335342, 2.8460789, -3.7194428, -21.316118, 2.8550498, -3.7194428, -21.215513, 2.8471467, -3.698083, -21.210388, 2.826428, -3.698083, -21.340467, 3.3681085, -3.7194428, -21.354778, 2.836894, -3.7194428, -21.360119, 3.3768659, -3.698083, -21.340467, 3.3681085, -3.698083, -21.335342, 2.8460789, -3.7194428, -21.354778, 2.836894, -3.698083, -21.335342, 2.8460789, -3.7194428, -21.205261, 2.805709, -3.7194428, -21.354778, 2.836894, -3.698083, -21.335342, 2.8460789, -3.698083, -21.210388, 2.826428, -3.7194428, -21.205261, 2.805709, -3.7194428, -21.360119, 3.3768659, -3.7408023, -21.335342, 2.8460789, -3.7408023, -21.340467, 3.3681085, -3.7194428, -21.360119, 3.3768659, -3.7194428, -21.354778, 2.836894, -3.7408023, -21.335342, 2.8460789, -3.7194428, -21.354778, 2.836894, -3.7408023, -21.210388, 2.826428, -3.7408023, -21.335342, 2.8460789, -3.7194428, -21.354778, 2.836894, -3.7194428, -21.205261, 2.805709, -3.7408023, -21.210388, 2.826428, -3.7408023, -21.340467, 3.3681085, -3.7194428, -21.316118, 2.8550498, -3.7194428, -21.320816, 3.3595648, -3.7408023, -21.340467, 3.3681085, -3.7408023, -21.335342, 2.8460789, -3.7194428, -21.316118, 2.8550498, -3.7408023, -21.335342, 2.8460789, -3.7194428, -21.215513, 2.8471467, -3.7194428, -21.316118, 2.8550498, -3.7408023, -21.335342, 2.8460789, -3.7408023, -21.210388, 2.826428, -3.7194428, -21.215513, 2.8471467, -3.7194428, -21.215513, 2.8471467, -3.698083, -21.105513, 2.8586807, -3.698083, -21.210388, 2.826428, -3.7194428, -21.215513, 2.8471467, -3.7194428, -21.126444, 2.8631663, -3.698083, -21.105513, 2.8586807, -3.7194428, -21.126444, 2.8631663, -3.698083, -21.10359, 3.3640501, -3.698083, -21.105513, 2.8586807, -3.7194428, -21.126444, 2.8631663, -3.7194428, -21.124308, 3.358924, -3.698083, -21.10359, 3.3640501, -3.7194428, -21.124308, 3.358924, -3.698083, -21.219786, 3.411255, -3.698083, -21.10359, 3.3640501, -3.7194428, -21.124308, 3.358924, -3.7194428, -21.224058, 3.3903227, -3.698083, -21.219786, 3.411255, -3.698083, -21.210388, 2.826428, -3.7194428, -21.084793, 2.8539817, -3.7194428, -21.205261, 2.805709, -3.698083, -21.210388, 2.826428, -3.698083, -21.105513, 2.8586807, -3.7194428, -21.084793, 2.8539817, -3.698083, -21.105513, 2.8586807, -3.7194428, -21.082657, 3.3691764, -3.7194428, -21.084793, 2.8539817, -3.698083, -21.105513, 2.8586807, -3.698083, -21.10359, 3.3640501, -3.7194428, -21.082657, 3.3691764, -3.698083, -21.10359, 3.3640501, -3.7194428, -21.215513, 3.431974, -3.7194428, -21.082657, 3.3691764, -3.698083, -21.10359, 3.3640501, -3.698083, -21.219786, 3.411255, -3.7194428, -21.215513, 3.431974, -3.7194428, -21.205261, 2.805709, -3.7408023, -21.105513, 2.8586807, -3.7408023, -21.210388, 2.826428, -3.7194428, -21.205261, 2.805709, -3.7194428, -21.084793, 2.8539817, -3.7408023, -21.105513, 2.8586807, -3.7408023, -21.210388, 2.826428, -3.7194428, -21.126444, 2.8631663, -3.7194428, -21.215513, 2.8471467, -3.7408023, -21.210388, 2.826428, -3.7408023, -21.105513, 2.8586807, -3.7194428, -21.126444, 2.8631663, -3.7408023, -21.105513, 2.8586807, -3.7194428, -21.124308, 3.358924, -3.7194428, -21.126444, 2.8631663, -3.7408023, -21.105513, 2.8586807, -3.7408023, -21.10359, 3.3640501, -3.7194428, -21.124308, 3.358924, -3.7408023, -21.10359, 3.3640501, -3.7194428, -21.224058, 3.3903227, -3.7194428, -21.124308, 3.358924, -3.7408023, -21.10359, 3.3640501, -3.7408023, -21.219786, 3.411255, -3.7194428, -21.224058, 3.3903227, -3.7194428, -21.084793, 2.8539817, -3.7408023, -21.10359, 3.3640501, -3.7408023, -21.105513, 2.8586807, -3.7194428, -21.084793, 2.8539817, -3.7194428, -21.082657, 3.3691764, -3.7408023, -21.10359, 3.3640501, -3.7194428, -21.082657, 3.3691764, -3.7408023, -21.219786, 3.411255, -3.7408023, -21.10359, 3.3640501, -3.7194428, -21.082657, 3.3691764, -3.7194428, -21.215513, 3.431974, -3.7408023, -21.219786, 3.411255, -3.7194428, -21.224058, 3.3903227, -3.698083, -21.340467, 3.3681085, -3.698083, -21.219786, 3.411255, -3.7194428, -21.224058, 3.3903227, -3.7194428, -21.320816, 3.3595648, -3.698083, -21.340467, 3.3681085, -3.698083, -21.219786, 3.411255, -3.7194428, -21.360119, 3.3768659, -3.7194428, -21.215513, 3.431974, -3.698083, -21.219786, 3.411255, -3.698083, -21.340467, 3.3681085, -3.7194428, -21.360119, 3.3768659, -3.7194428, -21.215513, 3.431974, -3.7408023, -21.340467, 3.3681085, -3.7408023, -21.219786, 3.411255, -3.7194428, -21.215513, 3.431974, -3.7194428, -21.360119, 3.3768659, -3.7408023, -21.340467, 3.3681085, -3.7408023, -21.219786, 3.411255, -3.7194428, -21.320816, 3.3595648, -3.7194428, -21.224058, 3.3903227, -3.7408023, -21.219786, 3.411255, -3.7408023, -21.340467, 3.3681085, -3.7194428, -21.320816, 3.3595648, -7.4172244, -21.333633, 4.035811, -7.393088, -21.35008, 3.6370263, -7.393088, -21.355633, 4.0424323, -7.4172244, -21.333633, 4.035811, -7.4172244, -21.328293, 3.6438615, -7.393088, -21.35008, 3.6370263, -7.393088, -21.355633, 4.0424323, -7.4172244, -21.371866, 3.6299777, -7.4172244, -21.377848, 4.049054, -7.393088, -21.355633, 4.0424323, -7.393088, -21.35008, 3.6370263, -7.4172244, -21.371866, 3.6299777, -7.4172244, -21.377848, 4.049054, -7.441361, -21.35008, 3.6370263, -7.441361, -21.355633, 4.0424323, -7.4172244, -21.377848, 4.049054, -7.4172244, -21.371866, 3.6299777, -7.441361, -21.35008, 3.6370263, -7.441361, -21.355633, 4.0424323, -7.4172244, -21.328293, 3.6438615, -7.4172244, -21.333633, 4.035811, -7.441361, -21.355633, 4.0424323, -7.441361, -21.35008, 3.6370263, -7.4172244, -21.328293, 3.6438615, -7.4172244, -21.328293, 3.6438615, -7.393088, -21.208893, 3.621861, -7.393088, -21.35008, 3.6370263, -7.4172244, -21.328293, 3.6438615, -7.4172244, -21.214659, 3.6378808, -7.393088, -21.208893, 3.621861, -7.393088, -21.35008, 3.6370263, -7.4172244, -21.203339, 3.6056278, -7.4172244, -21.371866, 3.6299777, -7.393088, -21.35008, 3.6370263, -7.393088, -21.208893, 3.621861, -7.4172244, -21.203339, 3.6056278, -7.4172244, -21.371866, 3.6299777, -7.441361, -21.208893, 3.621861, -7.441361, -21.35008, 3.6370263, -7.4172244, -21.371866, 3.6299777, -7.4172244, -21.203339, 3.6056278, -7.441361, -21.208893, 3.621861, -7.441361, -21.35008, 3.6370263, -7.4172244, -21.214659, 3.6378808, -7.4172244, -21.328293, 3.6438615, -7.441361, -21.35008, 3.6370263, -7.441361, -21.208893, 3.621861, -7.4172244, -21.214659, 3.6378808, -7.4172244, -21.214659, 3.6378808, -7.393088, -21.090773, 3.6466384, -7.393088, -21.208893, 3.621861, -7.4172244, -21.214659, 3.6378808, -7.4172244, -21.11427, 3.6502695, -7.393088, -21.090773, 3.6466384, -7.393088, -21.208893, 3.621861, -7.4172244, -21.067278, 3.6432207, -7.4172244, -21.203339, 3.6056278, -7.393088, -21.208893, 3.621861, -7.393088, -21.090773, 3.6466384, -7.4172244, -21.067278, 3.6432207, -7.4172244, -21.203339, 3.6056278, -7.441361, -21.090773, 3.6466384, -7.441361, -21.208893, 3.621861, -7.4172244, -21.203339, 3.6056278, -7.4172244, -21.067278, 3.6432207, -7.441361, -21.090773, 3.6466384, -7.441361, -21.208893, 3.621861, -7.4172244, -21.11427, 3.6502695, -7.4172244, -21.214659, 3.6378808, -7.441361, -21.208893, 3.621861, -7.441361, -21.090773, 3.6466384, -7.4172244, -21.11427, 3.6502695, -7.4172244, -21.11427, 3.6502695, -7.393088, -21.088425, 4.0392284, -7.393088, -21.090773, 3.6466384, -7.4172244, -21.11427, 3.6502695, -7.4172244, -21.111706, 4.03517, -7.393088, -21.088425, 4.0392284, -7.393088, -21.090773, 3.6466384, -7.4172244, -21.064928, 4.043073, -7.4172244, -21.067278, 3.6432207, -7.393088, -21.090773, 3.6466384, -7.393088, -21.088425, 4.0392284, -7.4172244, -21.064928, 4.043073, -7.4172244, -21.067278, 3.6432207, -7.441361, -21.088425, 4.0392284, -7.441361, -21.090773, 3.6466384, -7.4172244, -21.067278, 3.6432207, -7.4172244, -21.064928, 4.043073, -7.441361, -21.088425, 4.0392284, -7.441361, -21.090773, 3.6466384, -7.4172244, -21.111706, 4.03517, -7.4172244, -21.11427, 3.6502695, -7.441361, -21.090773, 3.6466384, -7.441361, -21.088425, 4.0392284, -7.4172244, -21.111706, 4.03517, -7.4172244, -21.111706, 4.03517, -7.393088, -21.219572, 4.0757537, -7.393088, -21.088425, 4.0392284, -7.4172244, -21.111706, 4.03517, -7.4172244, -21.224485, 4.0595202, -7.393088, -21.219572, 4.0757537, -7.393088, -21.088425, 4.0392284, -7.4172244, -21.214872, 4.0919867, -7.4172244, -21.064928, 4.043073, -7.393088, -21.088425, 4.0392284, -7.393088, -21.219572, 4.0757537, -7.4172244, -21.214872, 4.0919867, -7.4172244, -21.064928, 4.043073, -7.441361, -21.219572, 4.0757537, -7.441361, -21.088425, 4.0392284, -7.4172244, -21.064928, 4.043073, -7.4172244, -21.214872, 4.0919867, -7.441361, -21.219572, 4.0757537, -7.441361, -21.088425, 4.0392284, -7.4172244, -21.224485, 4.0595202, -7.4172244, -21.111706, 4.03517, -7.441361, -21.088425, 4.0392284, -7.441361, -21.219572, 4.0757537, -7.4172244, -21.224485, 4.0595202, -7.4172244, -21.224485, 4.0595202, -7.393088, -21.355633, 4.0424323, -7.393088, -21.219572, 4.0757537, -7.4172244, -21.224485, 4.0595202, -7.4172244, -21.333633, 4.035811, -7.393088, -21.355633, 4.0424323, -7.393088, -21.219572, 4.0757537, -7.4172244, -21.377848, 4.049054, -7.4172244, -21.214872, 4.0919867, -7.393088, -21.219572, 4.0757537, -7.393088, -21.355633, 4.0424323, -7.4172244, -21.377848, 4.049054, -7.4172244, -21.214872, 4.0919867, -7.441361, -21.355633, 4.0424323, -7.441361, -21.219572, 4.0757537, -7.4172244, -21.214872, 4.0919867, -7.4172244, -21.377848, 4.049054, -7.441361, -21.355633, 4.0424323, -7.441361, -21.219572, 4.0757537, -7.4172244, -21.333633, 4.035811, -7.4172244, -21.224485, 4.0595202, -7.441361, -21.219572, 4.0757537, -7.441361, -21.355633, 4.0424323, -7.4172244, -21.333633, 4.035811, -7.4558854, -21.333632, 4.726582, -7.431749, -21.350079, 4.2887096, -7.431749, -21.355633, 4.7338443, -7.4558854, -21.333632, 4.726582, -7.4558854, -21.328293, 4.2966127, -7.431749, -21.350079, 4.2887096, -7.431749, -21.355633, 4.7338443, -7.4558854, -21.371866, 4.281234, -7.4558854, -21.377846, 4.7411065, -7.431749, -21.355633, 4.7338443, -7.431749, -21.350079, 4.2887096, -7.4558854, -21.371866, 4.281234, -7.4558854, -21.377846, 4.7411065, -7.480022, -21.350079, 4.2887096, -7.480022, -21.355633, 4.7338443, -7.4558854, -21.377846, 4.7411065, -7.4558854, -21.371866, 4.281234, -7.480022, -21.350079, 4.2887096, -7.480022, -21.355633, 4.7338443, -7.4558854, -21.328293, 4.2966127, -7.4558854, -21.333632, 4.726582, -7.480022, -21.355633, 4.7338443, -7.480022, -21.350079, 4.2887096, -7.4558854, -21.328293, 4.2966127, -7.4558854, -21.328293, 4.2966127, -7.431749, -21.208893, 4.272262, -7.431749, -21.350079, 4.2887096, -7.4558854, -21.328293, 4.2966127, -7.4558854, -21.214659, 4.2899904, -7.431749, -21.208893, 4.272262, -7.431749, -21.350079, 4.2887096, -7.4558854, -21.203339, 4.254534, -7.4558854, -21.371866, 4.281234, -7.431749, -21.350079, 4.2887096, -7.431749, -21.208893, 4.272262, -7.4558854, -21.203339, 4.254534, -7.4558854, -21.371866, 4.281234, -7.480022, -21.208893, 4.272262, -7.480022, -21.350079, 4.2887096, -7.4558854, -21.371866, 4.281234, -7.4558854, -21.203339, 4.254534, -7.480022, -21.208893, 4.272262, -7.480022, -21.350079, 4.2887096, -7.4558854, -21.214659, 4.2899904, -7.4558854, -21.328293, 4.2966127, -7.480022, -21.350079, 4.2887096, -7.480022, -21.208893, 4.272262, -7.4558854, -21.214659, 4.2899904, -7.4558854, -21.214659, 4.2899904, -7.431749, -21.090773, 4.2996025, -7.431749, -21.208893, 4.272262, -7.4558854, -21.214659, 4.2899904, -7.4558854, -21.11427, 4.3034472, -7.431749, -21.090773, 4.2996025, -7.431749, -21.208893, 4.272262, -7.4558854, -21.067278, 4.295544, -7.4558854, -21.203339, 4.254534, -7.431749, -21.208893, 4.272262, -7.431749, -21.090773, 4.2996025, -7.4558854, -21.067278, 4.295544, -7.4558854, -21.203339, 4.254534, -7.480022, -21.090773, 4.2996025, -7.480022, -21.208893, 4.272262, -7.4558854, -21.203339, 4.254534, -7.4558854, -21.067278, 4.295544, -7.480022, -21.090773, 4.2996025, -7.480022, -21.208893, 4.272262, -7.4558854, -21.11427, 4.3034472, -7.4558854, -21.214659, 4.2899904, -7.480022, -21.208893, 4.272262, -7.480022, -21.090773, 4.2996025, -7.4558854, -21.11427, 4.3034472, -7.4558854, -21.11427, 4.3034472, -7.431749, -21.088423, 4.730213, -7.431749, -21.090773, 4.2996025, -7.4558854, -21.11427, 4.3034472, -7.4558854, -21.111706, 4.7259407, -7.431749, -21.088423, 4.730213, -7.4558854, -21.111706, 4.7259407, -7.431749, -21.219572, 4.770369, -7.431749, -21.088423, 4.730213, -7.4558854, -21.111706, 4.7259407, -7.4558854, -21.224485, 4.7526407, -7.431749, -21.219572, 4.770369, -7.431749, -21.090773, 4.2996025, -7.4558854, -21.064928, 4.7346983, -7.4558854, -21.067278, 4.295544, -7.431749, -21.090773, 4.2996025, -7.431749, -21.088423, 4.730213, -7.4558854, -21.064928, 4.7346983, -7.431749, -21.088423, 4.730213, -7.4558854, -21.214872, 4.788311, -7.4558854, -21.064928, 4.7346983, -7.431749, -21.088423, 4.730213, -7.431749, -21.219572, 4.770369, -7.4558854, -21.214872, 4.788311, -7.4558854, -21.067278, 4.295544, -7.480022, -21.088423, 4.730213, -7.480022, -21.090773, 4.2996025, -7.4558854, -21.067278, 4.295544, -7.4558854, -21.064928, 4.7346983, -7.480022, -21.088423, 4.730213, -7.4558854, -21.064928, 4.7346983, -7.480022, -21.219572, 4.770369, -7.480022, -21.088423, 4.730213, -7.4558854, -21.064928, 4.7346983, -7.4558854, -21.214872, 4.788311, -7.480022, -21.219572, 4.770369, -7.480022, -21.090773, 4.2996025, -7.4558854, -21.111706, 4.7259407, -7.4558854, -21.11427, 4.3034472, -7.480022, -21.090773, 4.2996025, -7.480022, -21.088423, 4.730213, -7.4558854, -21.111706, 4.7259407, -7.480022, -21.088423, 4.730213, -7.4558854, -21.224485, 4.7526407, -7.4558854, -21.111706, 4.7259407, -7.480022, -21.088423, 4.730213, -7.480022, -21.219572, 4.770369, -7.4558854, -21.224485, 4.7526407, -7.4558854, -21.224485, 4.7526407, -7.431749, -21.355633, 4.7338443, -7.431749, -21.219572, 4.770369, -7.4558854, -21.224485, 4.7526407, -7.4558854, -21.333632, 4.726582, -7.431749, -21.355633, 4.7338443, -7.431749, -21.219572, 4.770369, -7.4558854, -21.377846, 4.7411065, -7.4558854, -21.214872, 4.788311, -7.431749, -21.219572, 4.770369, -7.431749, -21.355633, 4.7338443, -7.4558854, -21.377846, 4.7411065, -7.4558854, -21.214872, 4.788311, -7.480022, -21.355633, 4.7338443, -7.480022, -21.219572, 4.770369, -7.4558854, -21.214872, 4.788311, -7.4558854, -21.377846, 4.7411065, -7.480022, -21.355633, 4.7338443, -7.480022, -21.219572, 4.770369, -7.4558854, -21.333632, 4.726582, -7.4558854, -21.224485, 4.7526407, -7.480022, -21.219572, 4.770369, -7.480022, -21.355633, 4.7338443, -7.4558854, -21.333632, 4.726582, -3.7589583, -21.320816, 4.7411065, -3.7375984, -21.33534, 4.2274075, -3.7375984, -21.340467, 4.7496505, -3.7589583, -21.320816, 4.7411065, -3.7589583, -21.316118, 4.236378, -3.7375984, -21.33534, 4.2274075, -3.7589583, -21.316118, 4.236378, -3.7375984, -21.210388, 4.2079697, -3.7375984, -21.33534, 4.2274075, -3.7589583, -21.316118, 4.236378, -3.7589583, -21.215513, 4.2284746, -3.7375984, -21.210388, 4.2079697, -3.7375984, -21.340467, 4.7496505, -3.7589583, -21.354778, 4.2184362, -3.7589583, -21.360119, 4.758194, -3.7375984, -21.340467, 4.7496505, -3.7375984, -21.33534, 4.2274075, -3.7589583, -21.354778, 4.2184362, -3.7375984, -21.33534, 4.2274075, -3.7589583, -21.205261, 4.1870375, -3.7589583, -21.354778, 4.2184362, -3.7375984, -21.33534, 4.2274075, -3.7375984, -21.210388, 4.2079697, -3.7589583, -21.205261, 4.1870375, -3.7589583, -21.360119, 4.758194, -3.7803178, -21.33534, 4.2274075, -3.7803178, -21.340467, 4.7496505, -3.7589583, -21.360119, 4.758194, -3.7589583, -21.354778, 4.2184362, -3.7803178, -21.33534, 4.2274075, -3.7589583, -21.354778, 4.2184362, -3.7803178, -21.210388, 4.2079697, -3.7803178, -21.33534, 4.2274075, -3.7589583, -21.354778, 4.2184362, -3.7589583, -21.205261, 4.1870375, -3.7803178, -21.210388, 4.2079697, -3.7803178, -21.340467, 4.7496505, -3.7589583, -21.316118, 4.236378, -3.7589583, -21.320816, 4.7411065, -3.7803178, -21.340467, 4.7496505, -3.7803178, -21.33534, 4.2274075, -3.7589583, -21.316118, 4.236378, -3.7803178, -21.33534, 4.2274075, -3.7589583, -21.215513, 4.2284746, -3.7589583, -21.316118, 4.236378, -3.7803178, -21.33534, 4.2274075, -3.7803178, -21.210388, 4.2079697, -3.7589583, -21.215513, 4.2284746, -3.7589583, -21.215513, 4.2284746, -3.7375984, -21.10551, 4.2400093, -3.7375984, -21.210388, 4.2079697, -3.7589583, -21.215513, 4.2284746, -3.7589583, -21.126444, 4.2444944, -3.7375984, -21.10551, 4.2400093, -3.7589583, -21.126444, 4.2444944, -3.7375984, -21.103588, 4.7453785, -3.7375984, -21.10551, 4.2400093, -3.7589583, -21.126444, 4.2444944, -3.7589583, -21.124308, 4.740252, -3.7375984, -21.103588, 4.7453785, -3.7589583, -21.124308, 4.740252, -3.7375984, -21.219786, 4.7925835, -3.7375984, -21.103588, 4.7453785, -3.7589583, -21.124308, 4.740252, -3.7589583, -21.224058, 4.7716503, -3.7375984, -21.219786, 4.7925835, -3.7375984, -21.210388, 4.2079697, -3.7589583, -21.084793, 4.23531, -3.7589583, -21.205261, 4.1870375, -3.7375984, -21.210388, 4.2079697, -3.7375984, -21.10551, 4.2400093, -3.7589583, -21.084793, 4.23531, -3.7375984, -21.10551, 4.2400093, -3.7589583, -21.082657, 4.7505045, -3.7589583, -21.084793, 4.23531, -3.7375984, -21.10551, 4.2400093, -3.7375984, -21.103588, 4.7453785, -3.7589583, -21.082657, 4.7505045, -3.7375984, -21.103588, 4.7453785, -3.7589583, -21.215513, 4.8135157, -3.7589583, -21.082657, 4.7505045, -3.7375984, -21.103588, 4.7453785, -3.7375984, -21.219786, 4.7925835, -3.7589583, -21.215513, 4.8135157, -3.7589583, -21.205261, 4.1870375, -3.7803178, -21.10551, 4.2400093, -3.7803178, -21.210388, 4.2079697, -3.7589583, -21.205261, 4.1870375, -3.7589583, -21.084793, 4.23531, -3.7803178, -21.10551, 4.2400093, -3.7803178, -21.210388, 4.2079697, -3.7589583, -21.126444, 4.2444944, -3.7589583, -21.215513, 4.2284746, -3.7803178, -21.210388, 4.2079697, -3.7803178, -21.10551, 4.2400093, -3.7589583, -21.126444, 4.2444944, -3.7803178, -21.10551, 4.2400093, -3.7589583, -21.124308, 4.740252, -3.7589583, -21.126444, 4.2444944, -3.7803178, -21.10551, 4.2400093, -3.7803178, -21.103588, 4.7453785, -3.7589583, -21.124308, 4.740252, -3.7803178, -21.103588, 4.7453785, -3.7589583, -21.224058, 4.7716503, -3.7589583, -21.124308, 4.740252, -3.7803178, -21.103588, 4.7453785, -3.7803178, -21.219786, 4.7925835, -3.7589583, -21.224058, 4.7716503, -3.7589583, -21.084793, 4.23531, -3.7803178, -21.103588, 4.7453785, -3.7803178, -21.10551, 4.2400093, -3.7589583, -21.084793, 4.23531, -3.7589583, -21.082657, 4.7505045, -3.7803178, -21.103588, 4.7453785, -3.7589583, -21.082657, 4.7505045, -3.7803178, -21.219786, 4.7925835, -3.7803178, -21.103588, 4.7453785, -3.7589583, -21.082657, 4.7505045, -3.7589583, -21.215513, 4.8135157, -3.7803178, -21.219786, 4.7925835, -3.7589583, -21.224058, 4.7716503, -3.7375984, -21.340467, 4.7496505, -3.7375984, -21.219786, 4.7925835, -3.7589583, -21.224058, 4.7716503, -3.7589583, -21.320816, 4.7411065, -3.7375984, -21.340467, 4.7496505, -3.7375984, -21.219786, 4.7925835, -3.7589583, -21.360119, 4.758194, -3.7589583, -21.215513, 4.8135157, -3.7375984, -21.219786, 4.7925835, -3.7375984, -21.340467, 4.7496505, -3.7589583, -21.360119, 4.758194, -3.7589583, -21.215513, 4.8135157, -3.7803178, -21.340467, 4.7496505, -3.7803178, -21.219786, 4.7925835, -3.7589583, -21.215513, 4.8135157, -3.7589583, -21.360119, 4.758194, -3.7803178, -21.340467, 4.7496505, -3.7803178, -21.219786, 4.7925835, -3.7589583, -21.320816, 4.7411065, -3.7589583, -21.224058, 4.7716503, -3.7803178, -21.219786, 4.7925835, -3.7803178, -21.340467, 4.7496505, -3.7589583, -21.320816, 4.7411065, -3.7589583, -21.320816, 5.3611774, -3.7375984, -21.33534, 4.8476915, -3.7375984, -21.340467, 5.3697214, -3.7589583, -21.320816, 5.3611774, -3.7589583, -21.316116, 4.8566623, -3.7375984, -21.33534, 4.8476915, -3.7589583, -21.316116, 4.8566623, -3.7375984, -21.210386, 4.82804, -3.7375984, -21.33534, 4.8476915, -3.7589583, -21.316116, 4.8566623, -3.7589583, -21.215513, 4.8487587, -3.7375984, -21.210386, 4.82804, -3.7375984, -21.340467, 5.3697214, -3.7589583, -21.354778, 4.8387203, -3.7589583, -21.360119, 5.378265, -3.7375984, -21.340467, 5.3697214, -3.7375984, -21.33534, 4.8476915, -3.7589583, -21.354778, 4.8387203, -3.7375984, -21.33534, 4.8476915, -3.7589583, -21.205261, 4.8073215, -3.7589583, -21.354778, 4.8387203, -3.7375984, -21.33534, 4.8476915, -3.7375984, -21.210386, 4.82804, -3.7589583, -21.205261, 4.8073215, -3.7589583, -21.360119, 5.378265, -3.7803178, -21.33534, 4.8476915, -3.7803178, -21.340467, 5.3697214, -3.7589583, -21.360119, 5.378265, -3.7589583, -21.354778, 4.8387203, -3.7803178, -21.33534, 4.8476915, -3.7589583, -21.354778, 4.8387203, -3.7803178, -21.210386, 4.82804, -3.7803178, -21.33534, 4.8476915, -3.7589583, -21.354778, 4.8387203, -3.7589583, -21.205261, 4.8073215, -3.7803178, -21.210386, 4.82804, -3.7803178, -21.340467, 5.3697214, -3.7589583, -21.316116, 4.8566623, -3.7589583, -21.320816, 5.3611774, -3.7803178, -21.340467, 5.3697214, -3.7803178, -21.33534, 4.8476915, -3.7589583, -21.316116, 4.8566623, -3.7803178, -21.33534, 4.8476915, -3.7589583, -21.215513, 4.8487587, -3.7589583, -21.316116, 4.8566623, -3.7803178, -21.33534, 4.8476915, -3.7803178, -21.210386, 4.82804, -3.7589583, -21.215513, 4.8487587, -3.7589583, -21.215513, 4.8487587, -3.7375984, -21.10551, 4.8602934, -3.7375984, -21.210386, 4.82804, -3.7589583, -21.215513, 4.8487587, -3.7589583, -21.126444, 4.8647785, -3.7375984, -21.10551, 4.8602934, -3.7589583, -21.126444, 4.8647785, -3.7375984, -21.103588, 5.3656626, -3.7375984, -21.10551, 4.8602934, -3.7589583, -21.126444, 4.8647785, -3.7589583, -21.124308, 5.3605366, -3.7375984, -21.103588, 5.3656626, -3.7589583, -21.124308, 5.3605366, -3.7375984, -21.219786, 5.412654, -3.7375984, -21.103588, 5.3656626, -3.7589583, -21.124308, 5.3605366, -3.7589583, -21.224056, 5.3917212, -3.7375984, -21.219786, 5.412654, -3.7375984, -21.210386, 4.82804, -3.7589583, -21.084793, 4.855594, -3.7589583, -21.205261, 4.8073215, -3.7375984, -21.210386, 4.82804, -3.7375984, -21.10551, 4.8602934, -3.7589583, -21.084793, 4.855594, -3.7375984, -21.10551, 4.8602934, -3.7589583, -21.082657, 5.370575, -3.7589583, -21.084793, 4.855594, -3.7375984, -21.10551, 4.8602934, -3.7375984, -21.103588, 5.3656626, -3.7589583, -21.082657, 5.370575, -3.7375984, -21.103588, 5.3656626, -3.7589583, -21.215513, 5.433586, -3.7589583, -21.082657, 5.370575, -3.7375984, -21.103588, 5.3656626, -3.7375984, -21.219786, 5.412654, -3.7589583, -21.215513, 5.433586, -3.7589583, -21.205261, 4.8073215, -3.7803178, -21.10551, 4.8602934, -3.7803178, -21.210386, 4.82804, -3.7589583, -21.205261, 4.8073215, -3.7589583, -21.084793, 4.855594, -3.7803178, -21.10551, 4.8602934, -3.7803178, -21.210386, 4.82804, -3.7589583, -21.126444, 4.8647785, -3.7589583, -21.215513, 4.8487587, -3.7803178, -21.210386, 4.82804, -3.7803178, -21.10551, 4.8602934, -3.7589583, -21.126444, 4.8647785, -3.7803178, -21.10551, 4.8602934, -3.7589583, -21.124308, 5.3605366, -3.7589583, -21.126444, 4.8647785, -3.7803178, -21.10551, 4.8602934, -3.7803178, -21.103588, 5.3656626, -3.7589583, -21.124308, 5.3605366, -3.7803178, -21.103588, 5.3656626, -3.7589583, -21.224056, 5.3917212, -3.7589583, -21.124308, 5.3605366, -3.7803178, -21.103588, 5.3656626, -3.7803178, -21.219786, 5.412654, -3.7589583, -21.224056, 5.3917212, -3.7589583, -21.084793, 4.855594, -3.7803178, -21.103588, 5.3656626, -3.7803178, -21.10551, 4.8602934, -3.7589583, -21.084793, 4.855594, -3.7589583, -21.082657, 5.370575, -3.7803178, -21.103588, 5.3656626, -3.7589583, -21.082657, 5.370575, -3.7803178, -21.219786, 5.412654, -3.7803178, -21.103588, 5.3656626, -3.7589583, -21.082657, 5.370575, -3.7589583, -21.215513, 5.433586, -3.7803178, -21.219786, 5.412654, -3.7589583, -21.224056, 5.3917212, -3.7375984, -21.340467, 5.3697214, -3.7375984, -21.219786, 5.412654, -3.7589583, -21.224056, 5.3917212, -3.7589583, -21.320816, 5.3611774, -3.7375984, -21.340467, 5.3697214, -3.7375984, -21.219786, 5.412654, -3.7589583, -21.360119, 5.378265, -3.7589583, -21.215513, 5.433586, -3.7375984, -21.219786, 5.412654, -3.7375984, -21.340467, 5.3697214, -3.7589583, -21.360119, 5.378265, -3.7589583, -21.215513, 5.433586, -3.7803178, -21.340467, 5.3697214, -3.7803178, -21.219786, 5.412654, -3.7589583, -21.215513, 5.433586, -3.7589583, -21.360119, 5.378265, -3.7803178, -21.340467, 5.3697214, -3.7803178, -21.219786, 5.412654, -3.7589583, -21.320816, 5.3611774, -3.7589583, -21.224056, 5.3917212, -3.7803178, -21.219786, 5.412654, -3.7803178, -21.340467, 5.3697214, -3.7589583, -21.320816, 5.3611774, -7.3817677, -21.333632, 5.409023, -7.357631, -21.350079, 4.8643517, -7.357631, -21.355633, 5.418207, -7.3817677, -21.333632, 5.409023, -7.3817677, -21.328293, 4.8739634, -7.357631, -21.350079, 4.8643517, -7.3817677, -21.328293, 4.8739634, -7.357631, -21.208893, 4.8436327, -7.357631, -21.350079, 4.8643517, -7.3817677, -21.328293, 4.8739634, -7.3817677, -21.214659, 4.8654194, -7.357631, -21.208893, 4.8436327, -7.357631, -21.355633, 5.418207, -7.3817677, -21.371866, 4.85474, -7.3817677, -21.377846, 5.4271784, -7.357631, -21.355633, 5.418207, -7.357631, -21.350079, 4.8643517, -7.3817677, -21.371866, 4.85474, -7.357631, -21.350079, 4.8643517, -7.3817677, -21.203339, 4.8216324, -7.3817677, -21.371866, 4.85474, -7.357631, -21.350079, 4.8643517, -7.357631, -21.208893, 4.8436327, -7.3817677, -21.203339, 4.8216324, -7.3817677, -21.377846, 5.4271784, -7.4059043, -21.350079, 4.8643517, -7.4059043, -21.355633, 5.418207, -7.3817677, -21.377846, 5.4271784, -7.3817677, -21.371866, 4.85474, -7.4059043, -21.350079, 4.8643517, -7.3817677, -21.371866, 4.85474, -7.4059043, -21.208893, 4.8436327, -7.4059043, -21.350079, 4.8643517, -7.3817677, -21.371866, 4.85474, -7.3817677, -21.203339, 4.8216324, -7.4059043, -21.208893, 4.8436327, -7.4059043, -21.355633, 5.418207, -7.3817677, -21.328293, 4.8739634, -7.3817677, -21.333632, 5.409023, -7.4059043, -21.355633, 5.418207, -7.4059043, -21.350079, 4.8643517, -7.3817677, -21.328293, 4.8739634, -7.4059043, -21.350079, 4.8643517, -7.3817677, -21.214659, 4.8654194, -7.3817677, -21.328293, 4.8739634, -7.4059043, -21.350079, 4.8643517, -7.4059043, -21.208893, 4.8436327, -7.3817677, -21.214659, 4.8654194, -7.3817677, -21.214659, 4.8654194, -7.357631, -21.090773, 4.8775945, -7.357631, -21.208893, 4.8436327, -7.3817677, -21.214659, 4.8654194, -7.3817677, -21.11427, 4.8825073, -7.357631, -21.090773, 4.8775945, -7.3817677, -21.11427, 4.8825073, -7.357631, -21.088423, 5.4137216, -7.357631, -21.090773, 4.8775945, -7.3817677, -21.11427, 4.8825073, -7.3817677, -21.111706, 5.4083815, -7.357631, -21.088423, 5.4137216, -7.3817677, -21.111706, 5.4083815, -7.357631, -21.219572, 5.463703, -7.357631, -21.088423, 5.4137216, -7.3817677, -21.111706, 5.4083815, -7.3817677, -21.224483, 5.441489, -7.357631, -21.219572, 5.463703, -7.357631, -21.208893, 4.8436327, -7.3817677, -21.067278, 4.8726816, -7.3817677, -21.203339, 4.8216324, -7.357631, -21.208893, 4.8436327, -7.357631, -21.090773, 4.8775945, -7.3817677, -21.067278, 4.8726816, -7.357631, -21.090773, 4.8775945, -7.3817677, -21.064928, 5.4190617, -7.3817677, -21.067278, 4.8726816, -7.357631, -21.090773, 4.8775945, -7.357631, -21.088423, 5.4137216, -7.3817677, -21.064928, 5.4190617, -7.357631, -21.088423, 5.4137216, -7.3817677, -21.214872, 5.485917, -7.3817677, -21.064928, 5.4190617, -7.357631, -21.088423, 5.4137216, -7.357631, -21.219572, 5.463703, -7.3817677, -21.214872, 5.485917, -7.3817677, -21.203339, 4.8216324, -7.4059043, -21.090773, 4.8775945, -7.4059043, -21.208893, 4.8436327, -7.3817677, -21.203339, 4.8216324, -7.3817677, -21.067278, 4.8726816, -7.4059043, -21.090773, 4.8775945, -7.4059043, -21.208893, 4.8436327, -7.3817677, -21.11427, 4.8825073, -7.3817677, -21.214659, 4.8654194, -7.4059043, -21.208893, 4.8436327, -7.4059043, -21.090773, 4.8775945, -7.3817677, -21.11427, 4.8825073, -7.4059043, -21.090773, 4.8775945, -7.3817677, -21.111706, 5.4083815, -7.3817677, -21.11427, 4.8825073, -7.4059043, -21.090773, 4.8775945, -7.4059043, -21.088423, 5.4137216, -7.3817677, -21.111706, 5.4083815, -7.4059043, -21.088423, 5.4137216, -7.3817677, -21.224483, 5.441489, -7.3817677, -21.111706, 5.4083815, -7.4059043, -21.088423, 5.4137216, -7.4059043, -21.219572, 5.463703, -7.3817677, -21.224483, 5.441489, -7.3817677, -21.067278, 4.8726816, -7.4059043, -21.088423, 5.4137216, -7.4059043, -21.090773, 4.8775945, -7.3817677, -21.067278, 4.8726816, -7.3817677, -21.064928, 5.4190617, -7.4059043, -21.088423, 5.4137216, -7.3817677, -21.064928, 5.4190617, -7.4059043, -21.219572, 5.463703, -7.4059043, -21.088423, 5.4137216, -7.3817677, -21.064928, 5.4190617, -7.3817677, -21.214872, 5.485917, -7.4059043, -21.219572, 5.463703, -7.3817677, -21.224483, 5.441489, -7.357631, -21.355633, 5.418207, -7.357631, -21.219572, 5.463703, -7.3817677, -21.224483, 5.441489, -7.3817677, -21.333632, 5.409023, -7.357631, -21.355633, 5.418207, -7.357631, -21.219572, 5.463703, -7.3817677, -21.377846, 5.4271784, -7.3817677, -21.214872, 5.485917, -7.357631, -21.219572, 5.463703, -7.357631, -21.355633, 5.418207, -7.3817677, -21.377846, 5.4271784, -7.3817677, -21.214872, 5.485917, -7.4059043, -21.355633, 5.418207, -7.4059043, -21.219572, 5.463703, -7.3817677, -21.214872, 5.485917, -7.3817677, -21.377846, 5.4271784, -7.4059043, -21.355633, 5.418207, -7.4059043, -21.219572, 5.463703, -7.3817677, -21.333632, 5.409023, -7.3817677, -21.224483, 5.441489, -7.4059043, -21.219572, 5.463703, -7.4059043, -21.355633, 5.418207, -7.3817677, -21.333632, 5.409023, 16.86181, -22.803505, -1.1805243, 12.459326, -22.631578, -0.992887, 16.845665, -22.437832, -1.1752877, 16.86181, -22.803505, -1.1805243, 12.4754715, -22.996815, -0.99855995, 12.459326, -22.631578, -0.992887, 21.372076, -22.244087, -0.9867778, 16.86181, -22.803505, -1.1805243, 16.845665, -22.437832, -1.1752877, 21.372076, -22.244087, -0.9867778, 21.38822, -22.609324, -0.9924512, 16.86181, -22.803505, -1.1805243, 16.86181, -22.78867, -2.160163, 12.4754715, -22.996815, -0.99855995, 16.86181, -22.803505, -1.1805243, 16.86181, -22.78867, -2.160163, 12.4754715, -22.984161, -1.8285255, 12.4754715, -22.996815, -0.99855995, 16.86181, -22.803505, -1.1805243, 21.38822, -22.592306, -2.12569, 16.86181, -22.78867, -2.160163, 16.86181, -22.803505, -1.1805243, 21.38822, -22.601032, -1.5496888, 21.38822, -22.592306, -2.12569, 21.38822, -22.601032, -1.5496888, 16.86181, -22.803505, -1.1805243, 21.38822, -22.609324, -0.9924512, 12.4754715, -22.984161, -1.8285255, 12.459326, -22.631578, -0.992887, 12.4754715, -22.996815, -0.99855995, 12.4754715, -22.984161, -1.8285255, 12.459326, -22.618923, -1.8228531, 12.459326, -22.631578, -0.992887, 21.372076, -22.236668, -1.4776883, 21.20582, -22.351868, -1.5763068, 21.38822, -22.601032, -1.5496888, 16.845665, -22.426924, -1.9096894, 16.849592, -22.514633, -2.155799, 17.977158, -22.374123, -2.1461992, 16.845665, -22.426924, -1.9096894, 15.749081, -22.472305, -2.0715804, 16.849592, -22.514633, -2.155799, 16.845665, -22.426924, -1.9096894, 12.459326, -22.618923, -1.8228531, 15.749081, -22.472305, -2.0715804, 16.845665, -22.426924, -1.9096894, 12.459326, -22.631578, -0.992887, 12.459326, -22.618923, -1.8228531, 12.459326, -22.631578, -0.992887, 16.845665, -22.426924, -1.9096894, 16.845665, -22.437832, -1.1752877, 17.977158, -22.374123, -2.1461992, 16.845665, -22.437832, -1.1752877, 16.845665, -22.426924, -1.9096894, 16.845665, -22.437832, -1.1752877, 21.372076, -22.236668, -1.4776883, 21.372076, -22.244087, -0.9867778, 16.845665, -22.437832, -1.1752877, 20.594036, -22.268959, -1.5780525, 21.372076, -22.236668, -1.4776883, 17.977158, -22.374123, -2.1461992, 20.594036, -22.268959, -1.5780525, 16.845665, -22.437832, -1.1752877, 21.20582, -22.351868, -1.5763068, 21.372076, -22.236668, -1.4776883, 20.594036, -22.268959, -1.5780525, 21.372076, -22.232304, -1.7539072, 21.20582, -22.351868, -1.5763068, 20.594036, -22.268959, -1.5780525, 20.594036, -22.268959, -1.5780525, 17.977158, -22.374123, -2.1461992, 21.372076, -22.232304, -1.7539072, 21.372076, -22.232304, -1.7539072, 17.977158, -22.374123, -2.1461992, 21.37251, -22.227068, -2.1200175, 21.38822, -22.609324, -0.9924512, 21.372076, -22.236668, -1.4776883, 21.38822, -22.601032, -1.5496888, 21.38822, -22.609324, -0.9924512, 21.372076, -22.244087, -0.9867778, 21.372076, -22.236668, -1.4776883, 21.372076, -22.232304, -1.7539072, 21.38822, -22.592306, -2.12569, 21.38822, -22.601032, -1.5496888, 21.372076, -22.232304, -1.7539072, 21.37251, -22.227068, -2.1200175, 21.38822, -22.592306, -2.12569, 21.20582, -22.351868, -1.5763068, 21.372076, -22.232304, -1.7539072, 21.38822, -22.601032, -1.5496888, 21.37251, -22.227068, -2.1200175, 16.86181, -22.78867, -2.160163, 21.38822, -22.592306, -2.12569, 16.86181, -22.78867, -2.160163, 21.37251, -22.227068, -2.1200175, 16.849592, -22.514633, -2.155799, 16.849592, -22.514633, -2.155799, 21.37251, -22.227068, -2.1200175, 17.977158, -22.374123, -2.1461992, 16.849592, -22.514633, -2.155799, 12.459326, -22.618923, -1.8228531, 16.86181, -22.78867, -2.160163, 16.86181, -22.78867, -2.160163, 12.459326, -22.618923, -1.8228531, 12.4754715, -22.984161, -1.8285255, 12.459326, -22.618923, -1.8228531, 16.849592, -22.514633, -2.155799, 15.749081, -22.472305, -2.0715804, 13.651039, -22.54256, -3.436966, 12.76609, -22.946634, -3.462275, 13.666748, -22.907797, -3.4426389, 13.651039, -22.54256, -3.436966, 12.749945, -22.581396, -3.4570384, 12.76609, -22.946634, -3.462275, 13.666748, -22.907797, -3.4426389, 21.08582, -22.31085, -3.4526749, 13.651039, -22.54256, -3.436966, 13.651039, -22.54256, -3.436966, 21.08582, -22.31085, -3.4526749, 19.224287, -22.300377, -3.4474387, 21.08582, -22.31085, -3.4526749, 13.666748, -22.907797, -3.4426389, 21.0976, -22.584887, -3.4566026, 12.749945, -22.596233, -2.4743457, 13.651039, -22.54256, -3.436966, 13.651039, -22.557396, -2.4507813, 19.224287, -22.300377, -3.4474387, 13.651039, -22.557396, -2.4507813, 13.651039, -22.54256, -3.436966, 19.224287, -22.300377, -3.4474387, 21.081892, -22.231869, -2.6214004, 13.651039, -22.557396, -2.4507813, 21.081892, -22.231869, -2.6214004, 19.224287, -22.300377, -3.4474387, 21.081892, -22.222704, -3.2436562, 21.081892, -22.222704, -3.2436562, 19.224287, -22.300377, -3.4474387, 21.08582, -22.31085, -3.4526749, 13.651039, -22.54256, -3.436966, 12.749945, -22.596233, -2.4743457, 13.230383, -22.567432, -2.9914374, 13.230383, -22.567432, -2.9914374, 12.749945, -22.596233, -2.4743457, 12.749945, -22.590124, -2.8701282, 12.749945, -22.590124, -2.8701282, 12.838528, -22.724524, -2.981401, 13.230383, -22.567432, -2.9914374, 12.838528, -22.724524, -2.981401, 12.749945, -22.586197, -3.144165, 13.230383, -22.567432, -2.9914374, 13.651039, -22.54256, -3.436966, 13.230383, -22.567432, -2.9914374, 12.749945, -22.586197, -3.144165, 13.651039, -22.54256, -3.436966, 12.749945, -22.586197, -3.144165, 12.749945, -22.581396, -3.4570384, 13.666748, -22.923069, -2.4560184, 12.749945, -22.596233, -2.4743457, 13.651039, -22.557396, -2.4507813, 13.666748, -22.923069, -2.4560184, 12.76609, -22.96147, -2.4795818, 12.749945, -22.596233, -2.4743457, 21.081892, -22.231869, -2.6214004, 13.666748, -22.923069, -2.4560184, 13.651039, -22.557396, -2.4507813, 21.081892, -22.231869, -2.6214004, 21.0976, -22.597105, -2.6270728, 13.666748, -22.923069, -2.4560184, 12.76609, -22.946634, -3.462275, 13.666748, -22.923069, -2.4560184, 13.666748, -22.907797, -3.4426389, 13.666748, -22.923069, -2.4560184, 12.76609, -22.946634, -3.462275, 12.76609, -22.954924, -2.931219, 13.666748, -22.923069, -2.4560184, 12.76609, -22.954924, -2.931219, 12.76609, -22.96147, -2.4795818, 21.0976, -22.597105, -2.6270728, 13.666748, -22.907797, -3.4426389, 13.666748, -22.923069, -2.4560184, 21.0976, -22.597105, -2.6270728, 21.0976, -22.584887, -3.4566026, 13.666748, -22.907797, -3.4426389, 12.749945, -22.586197, -3.144165, 12.838528, -22.724524, -2.981401, 12.76609, -22.954924, -2.931219, 12.838528, -22.724524, -2.981401, 12.749945, -22.590124, -2.8701282, 12.76609, -22.954924, -2.931219, 12.76609, -22.946634, -3.462275, 12.749945, -22.586197, -3.144165, 12.76609, -22.954924, -2.931219, 12.76609, -22.946634, -3.462275, 12.749945, -22.581396, -3.4570384, 12.749945, -22.586197, -3.144165, 12.749945, -22.590124, -2.8701282, 12.76609, -22.96147, -2.4795818, 12.76609, -22.954924, -2.931219, 12.749945, -22.590124, -2.8701282, 12.749945, -22.596233, -2.4743457, 12.76609, -22.96147, -2.4795818, 21.081892, -22.231869, -2.6214004, 21.0976, -22.584887, -3.4566026, 21.0976, -22.597105, -2.6270728, 21.0976, -22.584887, -3.4566026, 21.081892, -22.231869, -2.6214004, 21.08582, -22.31085, -3.4526749, 21.08582, -22.31085, -3.4526749, 21.081892, -22.231869, -2.6214004, 21.081892, -22.222704, -3.2436562, 16.86661, -22.74067, -5.2888975, 12.750381, -22.556524, -5.1012607, 16.850464, -22.375433, -5.2836614, 16.86661, -22.74067, -5.2888975, 12.76609, -22.92176, -5.1069336, 12.750381, -22.556524, -5.1012607, 21.081892, -22.194342, -5.0951514, 16.86661, -22.74067, -5.2888975, 16.850464, -22.375433, -5.2836614, 21.081892, -22.194342, -5.0951514, 21.0976, -22.559578, -5.100825, 16.86661, -22.74067, -5.2888975, 16.86661, -22.725834, -6.2681003, 12.76609, -22.92176, -5.1069336, 16.86661, -22.74067, -5.2888975, 16.86661, -22.725834, -6.2681003, 12.76609, -22.909105, -5.936463, 12.76609, -22.92176, -5.1069336, 16.86661, -22.74067, -5.2888975, 21.0976, -22.54256, -6.2344995, 16.86661, -22.725834, -6.2681003, 16.86661, -22.74067, -5.2888975, 21.0976, -22.551287, -5.658062, 21.0976, -22.54256, -6.2344995, 21.0976, -22.551287, -5.658062, 16.86661, -22.74067, -5.2888975, 21.0976, -22.559578, -5.100825, 12.76609, -22.909105, -5.936463, 12.750381, -22.556524, -5.1012607, 12.76609, -22.92176, -5.1069336, 12.76609, -22.909105, -5.936463, 12.750381, -22.543869, -5.93079, 12.750381, -22.556524, -5.1012607, 21.081892, -22.186922, -5.5856256, 20.926546, -22.301687, -5.6846805, 21.0976, -22.551287, -5.658062, 16.850464, -22.364523, -6.018063, 16.854391, -22.451796, -6.2637362, 17.908213, -22.314777, -6.2541366, 16.850464, -22.364523, -6.018063, 15.825443, -22.406414, -6.179518, 16.854391, -22.451796, -6.2637362, 16.850464, -22.364523, -6.018063, 12.750381, -22.543869, -5.93079, 15.825443, -22.406414, -6.179518, 16.850464, -22.364523, -6.018063, 12.750381, -22.556524, -5.1012607, 12.750381, -22.543869, -5.93079, 12.750381, -22.556524, -5.1012607, 16.850464, -22.364523, -6.018063, 16.850464, -22.375433, -5.2836614, 17.908213, -22.314777, -6.2541366, 16.850464, -22.375433, -5.2836614, 16.850464, -22.364523, -6.018063, 16.850464, -22.375433, -5.2836614, 21.081892, -22.186922, -5.5856256, 21.081892, -22.194342, -5.0951514, 16.850464, -22.375433, -5.2836614, 20.354473, -22.217031, -5.6864257, 21.081892, -22.186922, -5.5856256, 17.908213, -22.314777, -6.2541366, 20.354473, -22.217031, -5.6864257, 16.850464, -22.375433, -5.2836614, 20.926546, -22.301687, -5.6846805, 21.081892, -22.186922, -5.5856256, 20.354473, -22.217031, -5.6864257, 21.081892, -22.182995, -5.862281, 20.926546, -22.301687, -5.6846805, 20.354473, -22.217031, -5.6864257, 20.354473, -22.217031, -5.6864257, 17.908213, -22.314777, -6.2541366, 21.081892, -22.182995, -5.862281, 21.081892, -22.182995, -5.862281, 17.908213, -22.314777, -6.2541366, 21.081892, -22.177322, -6.228827, 21.0976, -22.559578, -5.100825, 21.081892, -22.186922, -5.5856256, 21.0976, -22.551287, -5.658062, 21.0976, -22.559578, -5.100825, 21.081892, -22.194342, -5.0951514, 21.081892, -22.186922, -5.5856256, 21.081892, -22.182995, -5.862281, 21.0976, -22.54256, -6.2344995, 21.0976, -22.551287, -5.658062, 21.081892, -22.182995, -5.862281, 21.081892, -22.177322, -6.228827, 21.0976, -22.54256, -6.2344995, 20.926546, -22.301687, -5.6846805, 21.081892, -22.182995, -5.862281, 21.0976, -22.551287, -5.658062, 21.081892, -22.177322, -6.228827, 16.86661, -22.725834, -6.2681003, 21.0976, -22.54256, -6.2344995, 16.86661, -22.725834, -6.2681003, 21.081892, -22.177322, -6.228827, 16.854391, -22.451796, -6.2637362, 16.854391, -22.451796, -6.2637362, 21.081892, -22.177322, -6.228827, 17.908213, -22.314777, -6.2541366, 16.854391, -22.451796, -6.2637362, 12.750381, -22.543869, -5.93079, 16.86661, -22.725834, -6.2681003, 16.86661, -22.725834, -6.2681003, 12.750381, -22.543869, -5.93079, 12.76609, -22.909105, -5.936463, 12.750381, -22.543869, -5.93079, 16.854391, -22.451796, -6.2637362, 15.825443, -22.406414, -6.179518, 19.724798, -22.63725, -3.9361672, 12.459763, -22.58925, -3.7760215, 19.70909, -22.271578, -3.9304943, 19.724798, -22.63725, -3.9361672, 12.4754715, -22.954489, -3.781694, 12.459763, -22.58925, -3.7760215, 21.37251, -22.199577, -3.9226394, 19.724798, -22.63725, -3.9361672, 19.70909, -22.271578, -3.9304943, 21.37251, -22.199577, -3.9226394, 21.38822, -22.564814, -3.9283128, 19.724798, -22.63725, -3.9361672, 19.724798, -22.621979, -4.944606, 12.4754715, -22.954489, -3.781694, 19.724798, -22.63725, -3.9361672, 19.724798, -22.621979, -4.944606, 12.4754715, -22.939651, -4.764387, 12.4754715, -22.954489, -3.781694, 21.38822, -22.564814, -3.9283128, 19.724798, -22.621979, -4.944606, 19.724798, -22.63725, -3.9361672, 21.38822, -22.564814, -3.9283128, 21.38822, -22.55216, -4.7578416, 19.724798, -22.621979, -4.944606, 21.38822, -22.564814, -3.9283128, 21.37251, -22.186922, -4.752169, 21.38822, -22.55216, -4.7578416, 21.38822, -22.564814, -3.9283128, 21.37251, -22.199577, -3.9226394, 21.37251, -22.186922, -4.752169, 19.70909, -22.260231, -4.6867146, 19.713017, -22.34794, -4.9402423, 20.124945, -22.239286, -4.8926787, 19.70909, -22.260231, -4.6867146, 17.896868, -22.335724, -4.8939877, 19.713017, -22.34794, -4.9402423, 19.70909, -22.260231, -4.6867146, 13.819038, -22.514633, -4.792751, 17.896868, -22.335724, -4.8939877, 20.124945, -22.239286, -4.8926787, 19.70909, -22.271578, -3.9304943, 19.70909, -22.260231, -4.6867146, 20.124945, -22.239286, -4.8926787, 21.37251, -22.199577, -3.9226394, 19.70909, -22.271578, -3.9304943, 21.37251, -22.199577, -3.9226394, 20.124945, -22.239286, -4.8926787, 21.37251, -22.186922, -4.752169, 12.459763, -22.58925, -3.7760215, 19.70909, -22.260231, -4.6867146, 19.70909, -22.271578, -3.9304943, 19.70909, -22.260231, -4.6867146, 12.459763, -22.58925, -3.7760215, 13.819038, -22.514633, -4.792751, 13.819038, -22.514633, -4.792751, 12.459763, -22.58925, -3.7760215, 12.459763, -22.577906, -4.513041, 13.819038, -22.514633, -4.792751, 12.459763, -22.577906, -4.513041, 12.46369, -22.665615, -4.7600236, 21.37251, -22.186922, -4.752169, 19.724798, -22.621979, -4.944606, 21.38822, -22.55216, -4.7578416, 19.724798, -22.621979, -4.944606, 21.37251, -22.186922, -4.752169, 19.713017, -22.34794, -4.9402423, 19.713017, -22.34794, -4.9402423, 21.37251, -22.186922, -4.752169, 20.124945, -22.239286, -4.8926787, 19.713017, -22.34794, -4.9402423, 12.46369, -22.665615, -4.7600236, 19.724798, -22.621979, -4.944606, 19.724798, -22.621979, -4.944606, 12.46369, -22.665615, -4.7600236, 12.4754715, -22.939651, -4.764387, 12.46369, -22.665615, -4.7600236, 19.713017, -22.34794, -4.9402423, 13.819038, -22.514633, -4.792751, 13.819038, -22.514633, -4.792751, 19.713017, -22.34794, -4.9402423, 17.896868, -22.335724, -4.8939877, 12.46369, -22.665615, -4.7600236, 12.4754715, -22.954489, -3.781694, 12.4754715, -22.939651, -4.764387, 12.4754715, -22.954489, -3.781694, 12.46369, -22.665615, -4.7600236, 12.459763, -22.58925, -3.7760215, 12.459763, -22.58925, -3.7760215, 12.46369, -22.665615, -4.7600236, 12.459763, -22.577906, -4.513041, 16.95781, -23.00336, -0.79565, 21.06182, -22.63725, -0.9811053, 16.95781, -22.63725, -0.79565, 16.95781, -23.00336, -0.79565, 21.06182, -23.00336, -0.9811053, 21.06182, -22.63725, -0.9811053, 12.722454, -22.63725, -0.9811053, 16.95781, -23.00336, -0.79565, 16.95781, -22.63725, -0.79565, 12.722454, -22.63725, -0.9811053, 12.722454, -23.00336, -0.9811053, 16.95781, -23.00336, -0.79565, 16.95781, -23.00336, 0.18311548, 21.06182, -23.00336, -0.9811053, 16.95781, -23.00336, -0.79565, 16.95781, -23.00336, 0.18311548, 21.06182, -23.00336, -0.15113974, 21.06182, -23.00336, -0.9811053, 16.95781, -23.00336, -0.79565, 12.722454, -23.00336, 0.15257025, 16.95781, -23.00336, 0.18311548, 16.95781, -23.00336, -0.79565, 12.722454, -23.00336, -0.4238677, 12.722454, -23.00336, 0.15257025, 12.722454, -23.00336, -0.4238677, 16.95781, -23.00336, -0.79565, 12.722454, -23.00336, -0.9811053, 21.06182, -23.00336, -0.15113974, 21.06182, -22.63725, -0.9811053, 21.06182, -23.00336, -0.9811053, 21.06182, -23.00336, -0.15113974, 21.06182, -22.63725, -0.15113974, 21.06182, -22.63725, -0.9811053, 12.722454, -22.63725, -0.49063158, 12.882601, -22.746778, -0.39288568, 12.722454, -23.00336, -0.4238677, 16.95781, -22.63725, -0.06124878, 16.95781, -22.728888, 0.18311548, 15.899189, -22.63725, 0.17569733, 16.95781, -22.63725, -0.06124878, 17.983704, -22.63725, 0.09977007, 16.95781, -22.728888, 0.18311548, 16.95781, -22.63725, -0.06124878, 21.06182, -22.63725, -0.15113974, 17.983704, -22.63725, 0.09977007, 16.95781, -22.63725, -0.06124878, 21.06182, -22.63725, -0.9811053, 21.06182, -22.63725, -0.15113974, 21.06182, -22.63725, -0.9811053, 16.95781, -22.63725, -0.06124878, 16.95781, -22.63725, -0.79565, 15.899189, -22.63725, 0.17569733, 16.95781, -22.63725, -0.79565, 16.95781, -22.63725, -0.06124878, 16.95781, -22.63725, -0.79565, 12.722454, -22.63725, -0.49063158, 12.722454, -22.63725, -0.9811053, 16.95781, -22.63725, -0.79565, 13.450312, -22.63725, -0.39070415, 12.722454, -22.63725, -0.49063158, 15.899189, -22.63725, 0.17569733, 13.450312, -22.63725, -0.39070415, 16.95781, -22.63725, -0.79565, 12.882601, -22.746778, -0.39288568, 12.722454, -22.63725, -0.49063158, 13.450312, -22.63725, -0.39070415, 12.722454, -22.63725, -0.21441269, 12.882601, -22.746778, -0.39288568, 13.450312, -22.63725, -0.39070415, 13.450312, -22.63725, -0.39070415, 15.899189, -22.63725, 0.17569733, 12.722454, -22.63725, -0.21441269, 12.722454, -22.63725, -0.21441269, 15.899189, -22.63725, 0.17569733, 12.722454, -22.63725, 0.15257025, 12.722454, -23.00336, -0.9811053, 12.722454, -22.63725, -0.49063158, 12.722454, -23.00336, -0.4238677, 12.722454, -23.00336, -0.9811053, 12.722454, -22.63725, -0.9811053, 12.722454, -22.63725, -0.49063158, 12.722454, -22.63725, -0.21441269, 12.722454, -23.00336, 0.15257025, 12.722454, -23.00336, -0.4238677, 12.722454, -22.63725, -0.21441269, 12.722454, -22.63725, 0.15257025, 12.722454, -23.00336, 0.15257025, 12.882601, -22.746778, -0.39288568, 12.722454, -22.63725, -0.21441269, 12.722454, -23.00336, -0.4238677, 12.722454, -22.63725, 0.15257025, 16.95781, -23.00336, 0.18311548, 12.722454, -23.00336, 0.15257025, 16.95781, -23.00336, 0.18311548, 12.722454, -22.63725, 0.15257025, 15.899189, -22.63725, 0.17569733, 16.95781, -23.00336, 0.18311548, 15.899189, -22.63725, 0.17569733, 16.95781, -22.728888, 0.18311548, 16.95781, -22.728888, 0.18311548, 21.06182, -23.00336, -0.15113974, 16.95781, -23.00336, 0.18311548, 16.95781, -22.728888, 0.18311548, 21.06182, -22.63725, -0.15113974, 21.06182, -23.00336, -0.15113974, 21.06182, -22.63725, -0.15113974, 16.95781, -22.728888, 0.18311548, 17.983704, -22.63725, 0.09977007, 20.16029, -22.63725, 2.5866122, 21.06182, -23.00336, 2.6058125, 20.16029, -23.00336, 2.5866122, 20.16029, -22.63725, 2.5866122, 21.06182, -22.63725, 2.6058125, 21.06182, -23.00336, 2.6058125, 20.16029, -23.00336, 2.5866122, 14.582241, -22.63725, 2.6010118, 20.16029, -22.63725, 2.5866122, 20.16029, -23.00336, 2.5866122, 12.722454, -22.728888, 2.6058125, 14.582241, -22.63725, 2.6010118, 12.722454, -22.728888, 2.6058125, 20.16029, -23.00336, 2.5866122, 12.722454, -23.00336, 2.6058125, 21.06182, -22.63725, 1.6231189, 20.16029, -22.63725, 2.5866122, 20.16029, -22.63725, 1.6004281, 14.582241, -22.63725, 2.6010118, 20.16029, -22.63725, 1.6004281, 20.16029, -22.63725, 2.5866122, 14.582241, -22.63725, 2.6010118, 12.722454, -22.63725, 1.7762828, 20.16029, -22.63725, 1.6004281, 12.722454, -22.63725, 1.7762828, 14.582241, -22.63725, 2.6010118, 12.722454, -22.63725, 2.398975, 12.722454, -22.63725, 2.398975, 14.582241, -22.63725, 2.6010118, 12.722454, -22.728888, 2.6058125, 20.16029, -22.63725, 2.5866122, 21.06182, -22.63725, 1.6231189, 20.581383, -22.63725, 2.1410842, 20.581383, -22.63725, 2.1410842, 21.06182, -22.63725, 1.6231189, 21.06182, -22.63725, 2.0193381, 21.06182, -22.63725, 2.0193381, 20.979784, -22.776888, 2.128429, 20.581383, -22.63725, 2.1410842, 20.979784, -22.776888, 2.128429, 21.06182, -22.63725, 2.2933755, 20.581383, -22.63725, 2.1410842, 20.16029, -22.63725, 2.5866122, 20.581383, -22.63725, 2.1410842, 21.06182, -22.63725, 2.2933755, 20.16029, -22.63725, 2.5866122, 21.06182, -22.63725, 2.2933755, 21.06182, -22.63725, 2.6058125, 20.16029, -23.00336, 1.6004281, 21.06182, -22.63725, 1.6231189, 20.16029, -22.63725, 1.6004281, 20.16029, -23.00336, 1.6004281, 21.06182, -23.00336, 1.6231189, 21.06182, -22.63725, 1.6231189, 12.722454, -22.63725, 1.7762828, 20.16029, -23.00336, 1.6004281, 20.16029, -22.63725, 1.6004281, 12.722454, -22.63725, 1.7762828, 12.722454, -23.00336, 1.7762828, 20.16029, -23.00336, 1.6004281, 21.06182, -23.00336, 2.6058125, 20.16029, -23.00336, 1.6004281, 20.16029, -23.00336, 2.5866122, 20.16029, -23.00336, 1.6004281, 21.06182, -23.00336, 2.6058125, 21.06182, -23.00336, 2.0747561, 20.16029, -23.00336, 1.6004281, 21.06182, -23.00336, 2.0747561, 21.06182, -23.00336, 1.6231189, 12.722454, -23.00336, 1.7762828, 20.16029, -23.00336, 2.5866122, 20.16029, -23.00336, 1.6004281, 12.722454, -23.00336, 1.7762828, 12.722454, -23.00336, 2.6058125, 20.16029, -23.00336, 2.5866122, 21.06182, -22.63725, 2.2933755, 20.979784, -22.776888, 2.128429, 21.06182, -23.00336, 2.0747561, 20.979784, -22.776888, 2.128429, 21.06182, -22.63725, 2.0193381, 21.06182, -23.00336, 2.0747561, 21.06182, -23.00336, 2.6058125, 21.06182, -22.63725, 2.2933755, 21.06182, -23.00336, 2.0747561, 21.06182, -23.00336, 2.6058125, 21.06182, -22.63725, 2.6058125, 21.06182, -22.63725, 2.2933755, 21.06182, -22.63725, 2.0193381, 21.06182, -23.00336, 1.6231189, 21.06182, -23.00336, 2.0747561, 21.06182, -22.63725, 2.0193381, 21.06182, -22.63725, 1.6231189, 21.06182, -23.00336, 1.6231189, 12.722454, -22.63725, 1.7762828, 12.722454, -23.00336, 2.6058125, 12.722454, -23.00336, 1.7762828, 12.722454, -23.00336, 2.6058125, 12.722454, -22.63725, 1.7762828, 12.722454, -22.63725, 2.398975, 12.722454, -23.00336, 2.6058125, 12.722454, -22.63725, 2.398975, 12.722454, -22.728888, 2.6058125, 20.38851, -22.63725, 3.7669787, 21.352875, -23.00336, 3.786178, 20.38851, -23.00336, 3.7669787, 20.38851, -22.63725, 3.7669787, 21.352875, -22.63725, 3.786178, 21.352875, -23.00336, 3.786178, 20.38851, -23.00336, 3.7669787, 14.420786, -22.63725, 3.7813792, 20.38851, -22.63725, 3.7669787, 20.38851, -23.00336, 3.7669787, 12.431835, -22.728888, 3.786178, 14.420786, -22.63725, 3.7813792, 12.431835, -22.728888, 3.786178, 20.38851, -23.00336, 3.7669787, 12.431835, -23.00336, 3.786178, 21.352875, -22.63725, 2.8034854, 20.38851, -22.63725, 3.7669787, 20.38851, -22.63725, 2.7803578, 14.420786, -22.63725, 3.7813792, 20.38851, -22.63725, 2.7803578, 20.38851, -22.63725, 3.7669787, 14.420786, -22.63725, 3.7813792, 12.431835, -22.63725, 2.9562135, 20.38851, -22.63725, 2.7803578, 12.431835, -22.63725, 2.9562135, 14.420786, -22.63725, 3.7813792, 12.431835, -22.63725, 3.5784698, 12.431835, -22.63725, 3.5784698, 14.420786, -22.63725, 3.7813792, 12.431835, -22.728888, 3.786178, 20.38851, -22.63725, 3.7669787, 21.352875, -22.63725, 2.8034854, 20.838837, -22.63725, 3.321015, 20.838837, -22.63725, 3.321015, 21.352875, -22.63725, 2.8034854, 21.352875, -22.63725, 3.1997046, 21.352875, -22.63725, 3.1997046, 21.264729, -22.776888, 3.3083596, 20.838837, -22.63725, 3.321015, 21.264729, -22.776888, 3.3083596, 21.352875, -22.63725, 3.4733052, 20.838837, -22.63725, 3.321015, 20.38851, -22.63725, 3.7669787, 20.838837, -22.63725, 3.321015, 21.352875, -22.63725, 3.4733052, 20.38851, -22.63725, 3.7669787, 21.352875, -22.63725, 3.4733052, 21.352875, -22.63725, 3.786178, 20.38851, -23.00336, 2.7803578, 21.352875, -22.63725, 2.8034854, 20.38851, -22.63725, 2.7803578, 20.38851, -23.00336, 2.7803578, 21.352875, -23.00336, 2.8034854, 21.352875, -22.63725, 2.8034854, 12.431835, -22.63725, 2.9562135, 20.38851, -23.00336, 2.7803578, 20.38851, -22.63725, 2.7803578, 12.431835, -22.63725, 2.9562135, 12.431835, -23.00336, 2.9562135, 20.38851, -23.00336, 2.7803578, 21.352875, -23.00336, 3.786178, 20.38851, -23.00336, 2.7803578, 20.38851, -23.00336, 3.7669787, 20.38851, -23.00336, 2.7803578, 21.352875, -23.00336, 3.786178, 21.352875, -23.00336, 3.2546868, 20.38851, -23.00336, 2.7803578, 21.352875, -23.00336, 3.2546868, 21.352875, -23.00336, 2.8034854, 12.431835, -23.00336, 2.9562135, 20.38851, -23.00336, 3.7669787, 20.38851, -23.00336, 2.7803578, 12.431835, -23.00336, 2.9562135, 12.431835, -23.00336, 3.786178, 20.38851, -23.00336, 3.7669787, 21.352875, -22.63725, 3.4733052, 21.264729, -22.776888, 3.3083596, 21.352875, -23.00336, 3.2546868, 21.264729, -22.776888, 3.3083596, 21.352875, -22.63725, 3.1997046, 21.352875, -23.00336, 3.2546868, 21.352875, -23.00336, 3.786178, 21.352875, -22.63725, 3.4733052, 21.352875, -23.00336, 3.2546868, 21.352875, -23.00336, 3.786178, 21.352875, -22.63725, 3.786178, 21.352875, -22.63725, 3.4733052, 21.352875, -22.63725, 3.1997046, 21.352875, -23.00336, 2.8034854, 21.352875, -23.00336, 3.2546868, 21.352875, -22.63725, 3.1997046, 21.352875, -22.63725, 2.8034854, 21.352875, -23.00336, 2.8034854, 12.431835, -22.63725, 2.9562135, 12.431835, -23.00336, 3.786178, 12.431835, -23.00336, 2.9562135, 12.431835, -23.00336, 3.786178, 12.431835, -22.63725, 2.9562135, 12.431835, -22.63725, 3.5784698, 12.431835, -23.00336, 3.786178, 12.431835, -22.63725, 3.5784698, 12.431835, -22.728888, 3.786178, 14.157221, -23.00336, 0.4183159, 21.256002, -22.63725, 0.25860643, 14.157221, -22.63725, 0.4183159, 14.157221, -23.00336, 0.4183159, 21.256002, -23.00336, 0.25860643, 21.256002, -22.63725, 0.25860643, 12.528709, -22.63725, 0.41177034, 14.157221, -23.00336, 0.4183159, 14.157221, -22.63725, 0.4183159, 12.528709, -22.63725, 0.41177034, 12.528709, -23.00336, 0.41177034, 14.157221, -23.00336, 0.4183159, 14.157221, -23.00336, 1.4267545, 21.256002, -23.00336, 0.25860643, 14.157221, -23.00336, 0.4183159, 14.157221, -23.00336, 1.4267545, 21.256002, -23.00336, 1.2413001, 21.256002, -23.00336, 0.25860643, 12.528709, -23.00336, 0.41177034, 14.157221, -23.00336, 1.4267545, 14.157221, -23.00336, 0.4183159, 12.528709, -23.00336, 0.41177034, 12.528709, -23.00336, 1.2413001, 14.157221, -23.00336, 1.4267545, 12.528709, -23.00336, 0.41177034, 12.528709, -22.63725, 1.2413001, 12.528709, -23.00336, 1.2413001, 12.528709, -23.00336, 0.41177034, 12.528709, -22.63725, 0.41177034, 12.528709, -22.63725, 1.2413001, 14.157221, -22.63725, 1.1745362, 14.157221, -22.728888, 1.4267545, 13.750093, -22.63725, 1.3805003, 14.157221, -22.63725, 1.1745362, 15.931917, -22.63725, 1.3805003, 14.157221, -22.728888, 1.4267545, 14.157221, -22.63725, 1.1745362, 19.925089, -22.63725, 1.2762094, 15.931917, -22.63725, 1.3805003, 13.750093, -22.63725, 1.3805003, 14.157221, -22.63725, 0.4183159, 14.157221, -22.63725, 1.1745362, 13.750093, -22.63725, 1.3805003, 12.528709, -22.63725, 0.41177034, 14.157221, -22.63725, 0.4183159, 12.528709, -22.63725, 0.41177034, 13.750093, -22.63725, 1.3805003, 12.528709, -22.63725, 1.2413001, 21.256002, -22.63725, 0.25860643, 14.157221, -22.63725, 1.1745362, 14.157221, -22.63725, 0.4183159, 14.157221, -22.63725, 1.1745362, 21.256002, -22.63725, 0.25860643, 19.925089, -22.63725, 1.2762094, 19.925089, -22.63725, 1.2762094, 21.256002, -22.63725, 0.25860643, 21.256002, -22.63725, 0.99606276, 19.925089, -22.63725, 1.2762094, 21.256002, -22.63725, 0.99606276, 21.256002, -22.728888, 1.2413001, 12.528709, -22.63725, 1.2413001, 14.157221, -23.00336, 1.4267545, 12.528709, -23.00336, 1.2413001, 14.157221, -23.00336, 1.4267545, 12.528709, -22.63725, 1.2413001, 13.750093, -22.63725, 1.3805003, 14.157221, -23.00336, 1.4267545, 13.750093, -22.63725, 1.3805003, 14.157221, -22.728888, 1.4267545, 14.157221, -22.728888, 1.4267545, 21.256002, -23.00336, 1.2413001, 14.157221, -23.00336, 1.4267545, 14.157221, -22.728888, 1.4267545, 19.925089, -22.63725, 1.2762094, 21.256002, -23.00336, 1.2413001, 21.256002, -23.00336, 1.2413001, 19.925089, -22.63725, 1.2762094, 21.256002, -22.728888, 1.2413001, 19.925089, -22.63725, 1.2762094, 14.157221, -22.728888, 1.4267545, 15.931917, -22.63725, 1.3805003, 21.256002, -22.728888, 1.2413001, 21.256002, -23.00336, 0.25860643, 21.256002, -23.00336, 1.2413001, 21.256002, -23.00336, 0.25860643, 21.256002, -22.728888, 1.2413001, 21.256002, -22.63725, 0.99606276, 21.256002, -23.00336, 0.25860643, 21.256002, -22.63725, 0.99606276, 21.256002, -22.63725, 0.25860643, 17.195192, -22.439577, -16.726889, 21.299639, -22.073904, -16.912344, 17.195192, -22.073904, -16.726889, 17.195192, -22.439577, -16.726889, 21.299639, -22.439577, -16.912344, 21.299639, -22.073904, -16.912344, 12.960274, -22.073904, -16.912344, 17.195192, -22.439577, -16.726889, 17.195192, -22.073904, -16.726889, 12.960274, -22.073904, -16.912344, 12.960274, -22.439577, -16.912344, 17.195192, -22.439577, -16.726889, 17.195192, -22.439577, -15.748123, 21.299639, -22.439577, -16.912344, 17.195192, -22.439577, -16.726889, 17.195192, -22.439577, -15.748123, 21.299639, -22.439577, -16.082378, 21.299639, -22.439577, -16.912344, 17.195192, -22.439577, -16.726889, 12.960274, -22.439577, -15.779104, 17.195192, -22.439577, -15.748123, 17.195192, -22.439577, -16.726889, 12.960274, -22.439577, -16.355106, 12.960274, -22.439577, -15.779104, 12.960274, -22.439577, -16.355106, 17.195192, -22.439577, -16.726889, 12.960274, -22.439577, -16.912344, 21.299639, -22.439577, -16.082378, 21.299639, -22.073904, -16.912344, 21.299639, -22.439577, -16.912344, 21.299639, -22.439577, -16.082378, 21.299639, -22.073904, -16.082378, 21.299639, -22.073904, -16.912344, 12.960274, -22.073904, -16.422306, 13.119982, -22.182995, -16.32456, 12.960274, -22.439577, -16.355106, 17.195192, -22.073904, -15.992487, 17.195192, -22.165104, -15.748123, 16.136572, -22.073904, -15.755539, 17.195192, -22.073904, -15.992487, 18.221523, -22.073904, -15.831467, 17.195192, -22.165104, -15.748123, 17.195192, -22.073904, -15.992487, 21.299639, -22.073904, -16.082378, 18.221523, -22.073904, -15.831467, 17.195192, -22.073904, -15.992487, 21.299639, -22.073904, -16.912344, 21.299639, -22.073904, -16.082378, 21.299639, -22.073904, -16.912344, 17.195192, -22.073904, -15.992487, 17.195192, -22.073904, -16.726889, 16.136572, -22.073904, -15.755539, 17.195192, -22.073904, -16.726889, 17.195192, -22.073904, -15.992487, 17.195192, -22.073904, -16.726889, 12.960274, -22.073904, -16.422306, 12.960274, -22.073904, -16.912344, 17.195192, -22.073904, -16.726889, 13.688129, -22.073904, -16.321941, 12.960274, -22.073904, -16.422306, 16.136572, -22.073904, -15.755539, 13.688129, -22.073904, -16.321941, 17.195192, -22.073904, -16.726889, 13.119982, -22.182995, -16.32456, 12.960274, -22.073904, -16.422306, 13.688129, -22.073904, -16.321941, 12.960274, -22.073904, -16.14565, 13.119982, -22.182995, -16.32456, 13.688129, -22.073904, -16.321941, 13.688129, -22.073904, -16.321941, 16.136572, -22.073904, -15.755539, 12.960274, -22.073904, -16.14565, 12.960274, -22.073904, -16.14565, 16.136572, -22.073904, -15.755539, 12.960274, -22.073904, -15.779104, 12.960274, -22.439577, -16.912344, 12.960274, -22.073904, -16.422306, 12.960274, -22.439577, -16.355106, 12.960274, -22.439577, -16.912344, 12.960274, -22.073904, -16.912344, 12.960274, -22.073904, -16.422306, 12.960274, -22.073904, -16.14565, 12.960274, -22.439577, -15.779104, 12.960274, -22.439577, -16.355106, 12.960274, -22.073904, -16.14565, 12.960274, -22.073904, -15.779104, 12.960274, -22.439577, -15.779104, 13.119982, -22.182995, -16.32456, 12.960274, -22.073904, -16.14565, 12.960274, -22.439577, -16.355106, 12.960274, -22.073904, -15.779104, 17.195192, -22.439577, -15.748123, 12.960274, -22.439577, -15.779104, 17.195192, -22.439577, -15.748123, 12.960274, -22.073904, -15.779104, 16.136572, -22.073904, -15.755539, 17.195192, -22.439577, -15.748123, 16.136572, -22.073904, -15.755539, 17.195192, -22.165104, -15.748123, 17.195192, -22.165104, -15.748123, 21.299639, -22.439577, -16.082378, 17.195192, -22.439577, -15.748123, 17.195192, -22.165104, -15.748123, 21.299639, -22.073904, -16.082378, 21.299639, -22.439577, -16.082378, 21.299639, -22.073904, -16.082378, 17.195192, -22.165104, -15.748123, 18.221523, -22.073904, -15.831467, 20.39811, -22.073904, -13.344625, 21.299639, -22.439577, -13.325426, 20.39811, -22.439577, -13.344625, 20.39811, -22.073904, -13.344625, 21.299639, -22.073904, -13.325426, 21.299639, -22.439577, -13.325426, 20.39811, -22.439577, -13.344625, 14.819624, -22.073904, -13.330225, 20.39811, -22.073904, -13.344625, 20.39811, -22.439577, -13.344625, 12.960274, -22.165104, -13.325426, 14.819624, -22.073904, -13.330225, 12.960274, -22.165104, -13.325426, 20.39811, -22.439577, -13.344625, 12.960274, -22.439577, -13.325426, 21.299639, -22.073904, -14.308119, 20.39811, -22.073904, -13.344625, 20.39811, -22.073904, -14.331245, 14.819624, -22.073904, -13.330225, 20.39811, -22.073904, -14.331245, 20.39811, -22.073904, -13.344625, 14.819624, -22.073904, -13.330225, 12.960274, -22.073904, -14.154955, 20.39811, -22.073904, -14.331245, 12.960274, -22.073904, -14.154955, 14.819624, -22.073904, -13.330225, 12.960274, -22.073904, -13.5327, 12.960274, -22.073904, -13.5327, 14.819624, -22.073904, -13.330225, 12.960274, -22.165104, -13.325426, 20.39811, -22.073904, -13.344625, 21.299639, -22.073904, -14.308119, 20.818766, -22.073904, -13.7901535, 20.818766, -22.073904, -13.7901535, 21.299639, -22.073904, -14.308119, 21.299639, -22.073904, -13.9119, 21.299639, -22.073904, -13.9119, 21.217165, -22.213541, -13.803246, 20.818766, -22.073904, -13.7901535, 21.217165, -22.213541, -13.803246, 21.299639, -22.073904, -13.6383, 20.818766, -22.073904, -13.7901535, 20.39811, -22.073904, -13.344625, 20.818766, -22.073904, -13.7901535, 21.299639, -22.073904, -13.6383, 20.39811, -22.073904, -13.344625, 21.299639, -22.073904, -13.6383, 21.299639, -22.073904, -13.325426, 20.39811, -22.439577, -14.331245, 21.299639, -22.073904, -14.308119, 20.39811, -22.073904, -14.331245, 20.39811, -22.439577, -14.331245, 21.299639, -22.439577, -14.308119, 21.299639, -22.073904, -14.308119, 12.960274, -22.073904, -14.154955, 20.39811, -22.439577, -14.331245, 20.39811, -22.073904, -14.331245, 12.960274, -22.073904, -14.154955, 12.960274, -22.439577, -14.154955, 20.39811, -22.439577, -14.331245, 21.299639, -22.439577, -13.325426, 20.39811, -22.439577, -14.331245, 20.39811, -22.439577, -13.344625, 20.39811, -22.439577, -14.331245, 21.299639, -22.439577, -13.325426, 21.299639, -22.439577, -13.856482, 20.39811, -22.439577, -14.331245, 21.299639, -22.439577, -13.856482, 21.299639, -22.439577, -14.308119, 12.960274, -22.439577, -14.154955, 20.39811, -22.439577, -13.344625, 20.39811, -22.439577, -14.331245, 12.960274, -22.439577, -14.154955, 12.960274, -22.439577, -13.325426, 20.39811, -22.439577, -13.344625, 21.299639, -22.073904, -13.6383, 21.217165, -22.213541, -13.803246, 21.299639, -22.439577, -13.856482, 21.217165, -22.213541, -13.803246, 21.299639, -22.073904, -13.9119, 21.299639, -22.439577, -13.856482, 21.299639, -22.439577, -13.325426, 21.299639, -22.073904, -13.6383, 21.299639, -22.439577, -13.856482, 21.299639, -22.439577, -13.325426, 21.299639, -22.073904, -13.325426, 21.299639, -22.073904, -13.6383, 21.299639, -22.073904, -13.9119, 21.299639, -22.439577, -14.308119, 21.299639, -22.439577, -13.856482, 21.299639, -22.073904, -13.9119, 21.299639, -22.073904, -14.308119, 21.299639, -22.439577, -14.308119, 12.960274, -22.073904, -14.154955, 12.960274, -22.439577, -13.325426, 12.960274, -22.439577, -14.154955, 12.960274, -22.439577, -13.325426, 12.960274, -22.073904, -14.154955, 12.960274, -22.073904, -13.5327, 12.960274, -22.439577, -13.325426, 12.960274, -22.073904, -13.5327, 12.960274, -22.165104, -13.325426, 13.295837, -22.635942, 4.8980365, 13.527548, -22.543432, 4.8980365, 13.527548, -22.635942, 4.8980365, 13.295837, -22.635942, 4.8980365, 13.295837, -22.543432, 4.8980365, 13.527548, -22.543432, 4.8980365, 13.295837, -22.543432, 4.8980365, 13.527548, -22.543432, -19.742168, 13.527548, -22.543432, 4.8980365, 13.295837, -22.543432, 4.8980365, 13.295837, -22.543432, -19.742168, 13.527548, -22.543432, -19.742168, 13.295837, -22.543432, -19.742168, 13.527548, -22.635942, -19.742168, 13.527548, -22.543432, -19.742168, 13.295837, -22.543432, -19.742168, 13.295837, -22.635942, -19.742168, 13.527548, -22.635942, -19.742168, 13.295837, -22.635942, -19.742168, 13.527548, -22.635942, 4.8980365, 13.527548, -22.635942, -19.742168, 13.295837, -22.635942, -19.742168, 13.295837, -22.635942, 4.8980365, 13.527548, -22.635942, 4.8980365, 13.527548, -22.635942, 4.8980365, 13.527548, -22.543432, -19.742168, 13.527548, -22.635942, -19.742168, 13.527548, -22.635942, 4.8980365, 13.527548, -22.543432, 4.8980365, 13.527548, -22.543432, -19.742168, 13.295837, -22.635942, -19.742168, 13.295837, -22.543432, 4.8980365, 13.295837, -22.635942, 4.8980365, 13.295837, -22.635942, -19.742168, 13.295837, -22.543432, -19.742168, 13.295837, -22.543432, 4.8980365, 14.33395, -22.439577, -18.07831, 21.590258, -22.073904, -18.237583, 14.33395, -22.073904, -18.07831, 14.33395, -22.439577, -18.07831, 21.590258, -22.439577, -18.237583, 21.590258, -22.073904, -18.237583, 12.669218, -22.073904, -18.084854, 14.33395, -22.439577, -18.07831, 14.33395, -22.073904, -18.07831, 12.669218, -22.073904, -18.084854, 12.669218, -22.439577, -18.084854, 14.33395, -22.439577, -18.07831, 14.33395, -22.439577, -17.069435, 21.590258, -22.439577, -18.237583, 14.33395, -22.439577, -18.07831, 14.33395, -22.439577, -17.069435, 21.590258, -22.439577, -17.25489, 21.590258, -22.439577, -18.237583, 12.669218, -22.439577, -18.084854, 14.33395, -22.439577, -17.069435, 14.33395, -22.439577, -18.07831, 12.669218, -22.439577, -18.084854, 12.669218, -22.439577, -17.25489, 14.33395, -22.439577, -17.069435, 12.669218, -22.439577, -18.084854, 12.669218, -22.073904, -17.25489, 12.669218, -22.439577, -17.25489, 12.669218, -22.439577, -18.084854, 12.669218, -22.073904, -18.084854, 12.669218, -22.073904, -17.25489, 14.33395, -22.073904, -17.321653, 14.33395, -22.165104, -17.069435, 13.917658, -22.073904, -17.115688, 14.33395, -22.073904, -17.321653, 16.147917, -22.073904, -17.115688, 14.33395, -22.165104, -17.069435, 14.33395, -22.073904, -17.321653, 20.229671, -22.073904, -17.220417, 16.147917, -22.073904, -17.115688, 13.917658, -22.073904, -17.115688, 14.33395, -22.073904, -18.07831, 14.33395, -22.073904, -17.321653, 13.917658, -22.073904, -17.115688, 12.669218, -22.073904, -18.084854, 14.33395, -22.073904, -18.07831, 12.669218, -22.073904, -18.084854, 13.917658, -22.073904, -17.115688, 12.669218, -22.073904, -17.25489, 21.590258, -22.073904, -18.237583, 14.33395, -22.073904, -17.321653, 14.33395, -22.073904, -18.07831, 14.33395, -22.073904, -17.321653, 21.590258, -22.073904, -18.237583, 20.229671, -22.073904, -17.220417, 20.229671, -22.073904, -17.220417, 21.590258, -22.073904, -18.237583, 21.590258, -22.073904, -17.501, 20.229671, -22.073904, -17.220417, 21.590258, -22.073904, -17.501, 21.590258, -22.165104, -17.25489, 12.669218, -22.073904, -17.25489, 14.33395, -22.439577, -17.069435, 12.669218, -22.439577, -17.25489, 14.33395, -22.439577, -17.069435, 12.669218, -22.073904, -17.25489, 13.917658, -22.073904, -17.115688, 14.33395, -22.439577, -17.069435, 13.917658, -22.073904, -17.115688, 14.33395, -22.165104, -17.069435, 14.33395, -22.165104, -17.069435, 21.590258, -22.439577, -17.25489, 14.33395, -22.439577, -17.069435, 14.33395, -22.165104, -17.069435, 20.229671, -22.073904, -17.220417, 21.590258, -22.439577, -17.25489, 21.590258, -22.439577, -17.25489, 20.229671, -22.073904, -17.220417, 21.590258, -22.165104, -17.25489, 20.229671, -22.073904, -17.220417, 14.33395, -22.165104, -17.069435, 16.147917, -22.073904, -17.115688, 21.590258, -22.165104, -17.25489, 21.590258, -22.439577, -18.237583, 21.590258, -22.439577, -17.25489, 21.590258, -22.439577, -18.237583, 21.590258, -22.165104, -17.25489, 21.590258, -22.073904, -17.501, 21.590258, -22.439577, -18.237583, 21.590258, -22.073904, -17.501, 21.590258, -22.073904, -18.237583, 12.749508, -25.90344, -19.752205, 13.527111, -21.174994, -19.579403, 13.354311, -25.90344, -19.752205, 12.749508, -25.90344, -19.752205, 12.57671, -21.174994, -19.579403, 13.527111, -21.174994, -19.579403, 12.57671, -21.174994, -19.579403, 13.527111, -21.174994, -20.52937, 13.527111, -21.174994, -19.579403, 12.57671, -21.174994, -19.579403, 12.57671, -21.174994, -20.52937, 13.527111, -21.174994, -20.52937, 12.57671, -21.174994, -20.52937, 13.354311, -25.90344, -20.356571, 13.527111, -21.174994, -20.52937, 12.57671, -21.174994, -20.52937, 12.749508, -25.90344, -20.356571, 13.354311, -25.90344, -20.356571, 12.749508, -25.90344, -20.356571, 13.354311, -25.90344, -19.752205, 13.354311, -25.90344, -20.356571, 12.749508, -25.90344, -20.356571, 12.749508, -25.90344, -19.752205, 13.354311, -25.90344, -19.752205, 13.354311, -25.90344, -19.752205, 13.527111, -21.174994, -20.52937, 13.354311, -25.90344, -20.356571, 13.354311, -25.90344, -19.752205, 13.527111, -21.174994, -19.579403, 13.527111, -21.174994, -20.52937, 12.749508, -25.90344, -20.356571, 12.57671, -21.174994, -19.579403, 12.749508, -25.90344, -19.752205, 12.749508, -25.90344, -20.356571, 12.57671, -21.174994, -20.52937, 12.57671, -21.174994, -19.579403, 20.39811, -22.073904, -18.576202, 21.299639, -22.439577, -18.556564, 20.39811, -22.439577, -18.576202, 20.39811, -22.073904, -18.576202, 21.299639, -22.073904, -18.556564, 21.299639, -22.439577, -18.556564, 20.39811, -22.439577, -18.576202, 14.819624, -22.073904, -18.561365, 20.39811, -22.073904, -18.576202, 20.39811, -22.439577, -18.576202, 12.960274, -22.165104, -18.556564, 14.819624, -22.073904, -18.561365, 12.960274, -22.165104, -18.556564, 20.39811, -22.439577, -18.576202, 12.960274, -22.439577, -18.556564, 21.299639, -22.073904, -19.539696, 20.39811, -22.073904, -18.576202, 20.39811, -22.073904, -19.562386, 14.819624, -22.073904, -18.561365, 20.39811, -22.073904, -19.562386, 20.39811, -22.073904, -18.576202, 14.819624, -22.073904, -18.561365, 12.960274, -22.073904, -19.386532, 20.39811, -22.073904, -19.562386, 12.960274, -22.073904, -19.386532, 14.819624, -22.073904, -18.561365, 12.960274, -22.073904, -18.764275, 12.960274, -22.073904, -18.764275, 14.819624, -22.073904, -18.561365, 12.960274, -22.165104, -18.556564, 20.39811, -22.073904, -18.576202, 21.299639, -22.073904, -19.539696, 20.818766, -22.073904, -19.022167, 20.818766, -22.073904, -19.022167, 21.299639, -22.073904, -19.539696, 21.299639, -22.073904, -19.143913, 21.299639, -22.073904, -19.143913, 21.217165, -22.213541, -19.034386, 20.818766, -22.073904, -19.022167, 21.217165, -22.213541, -19.034386, 21.299639, -22.073904, -18.869438, 20.818766, -22.073904, -19.022167, 20.39811, -22.073904, -18.576202, 20.818766, -22.073904, -19.022167, 21.299639, -22.073904, -18.869438, 20.39811, -22.073904, -18.576202, 21.299639, -22.073904, -18.869438, 21.299639, -22.073904, -18.556564, 20.39811, -22.439577, -19.562386, 21.299639, -22.073904, -19.539696, 20.39811, -22.073904, -19.562386, 20.39811, -22.439577, -19.562386, 21.299639, -22.439577, -19.539696, 21.299639, -22.073904, -19.539696, 12.960274, -22.073904, -19.386532, 20.39811, -22.439577, -19.562386, 20.39811, -22.073904, -19.562386, 12.960274, -22.073904, -19.386532, 12.960274, -22.439577, -19.386532, 20.39811, -22.439577, -19.562386, 21.299639, -22.439577, -18.556564, 20.39811, -22.439577, -19.562386, 20.39811, -22.439577, -18.576202, 20.39811, -22.439577, -19.562386, 21.299639, -22.439577, -18.556564, 21.299639, -22.439577, -19.088493, 20.39811, -22.439577, -19.562386, 21.299639, -22.439577, -19.088493, 21.299639, -22.439577, -19.539696, 12.960274, -22.439577, -19.386532, 20.39811, -22.439577, -18.576202, 20.39811, -22.439577, -19.562386, 12.960274, -22.439577, -19.386532, 12.960274, -22.439577, -18.556564, 20.39811, -22.439577, -18.576202, 21.299639, -22.073904, -18.869438, 21.217165, -22.213541, -19.034386, 21.299639, -22.439577, -19.088493, 21.217165, -22.213541, -19.034386, 21.299639, -22.073904, -19.143913, 21.299639, -22.439577, -19.088493, 21.299639, -22.439577, -18.556564, 21.299639, -22.073904, -18.869438, 21.299639, -22.439577, -19.088493, 21.299639, -22.439577, -18.556564, 21.299639, -22.073904, -18.556564, 21.299639, -22.073904, -18.869438, 21.299639, -22.073904, -19.143913, 21.299639, -22.439577, -19.539696, 21.299639, -22.439577, -19.088493, 21.299639, -22.073904, -19.143913, 21.299639, -22.073904, -19.539696, 21.299639, -22.439577, -19.539696, 12.960274, -22.073904, -19.386532, 12.960274, -22.439577, -18.556564, 12.960274, -22.439577, -19.386532, 12.960274, -22.439577, -18.556564, 12.960274, -22.073904, -19.386532, 12.960274, -22.073904, -18.764275, 12.960274, -22.439577, -18.556564, 12.960274, -22.073904, -18.764275, 12.960274, -22.165104, -18.556564, 20.62589, -22.073904, -12.164696, 21.590258, -22.439577, -12.14506, 20.62589, -22.439577, -12.164696, 20.62589, -22.073904, -12.164696, 21.590258, -22.073904, -12.14506, 21.590258, -22.439577, -12.14506, 20.62589, -22.439577, -12.164696, 14.658169, -22.073904, -12.149858, 20.62589, -22.073904, -12.164696, 20.62589, -22.439577, -12.164696, 12.669218, -22.165104, -12.14506, 14.658169, -22.073904, -12.149858, 12.669218, -22.165104, -12.14506, 20.62589, -22.439577, -12.164696, 12.669218, -22.439577, -12.14506, 21.590258, -22.073904, -13.127752, 20.62589, -22.073904, -12.164696, 20.62589, -22.073904, -13.150879, 14.658169, -22.073904, -12.149858, 20.62589, -22.073904, -13.150879, 20.62589, -22.073904, -12.164696, 14.658169, -22.073904, -12.149858, 12.669218, -22.073904, -12.975025, 20.62589, -22.073904, -13.150879, 12.669218, -22.073904, -12.975025, 14.658169, -22.073904, -12.149858, 12.669218, -22.073904, -12.352768, 12.669218, -22.073904, -12.352768, 14.658169, -22.073904, -12.149858, 12.669218, -22.165104, -12.14506, 20.62589, -22.073904, -12.164696, 21.590258, -22.073904, -13.127752, 21.07622, -22.073904, -12.610224, 21.07622, -22.073904, -12.610224, 21.590258, -22.073904, -13.127752, 21.590258, -22.073904, -12.73197, 21.590258, -22.073904, -12.73197, 21.502111, -22.213541, -12.622879, 21.07622, -22.073904, -12.610224, 21.502111, -22.213541, -12.622879, 21.590258, -22.073904, -12.457933, 21.07622, -22.073904, -12.610224, 20.62589, -22.073904, -12.164696, 21.07622, -22.073904, -12.610224, 21.590258, -22.073904, -12.457933, 20.62589, -22.073904, -12.164696, 21.590258, -22.073904, -12.457933, 21.590258, -22.073904, -12.14506, 20.62589, -22.439577, -13.150879, 21.590258, -22.073904, -13.127752, 20.62589, -22.073904, -13.150879, 20.62589, -22.439577, -13.150879, 21.590258, -22.439577, -13.127752, 21.590258, -22.073904, -13.127752, 12.669218, -22.073904, -12.975025, 20.62589, -22.439577, -13.150879, 20.62589, -22.073904, -13.150879, 12.669218, -22.073904, -12.975025, 12.669218, -22.439577, -12.975025, 20.62589, -22.439577, -13.150879, 21.590258, -22.439577, -12.14506, 20.62589, -22.439577, -13.150879, 20.62589, -22.439577, -12.164696, 20.62589, -22.439577, -13.150879, 21.590258, -22.439577, -12.14506, 21.590258, -22.439577, -12.676552, 20.62589, -22.439577, -13.150879, 21.590258, -22.439577, -12.676552, 21.590258, -22.439577, -13.127752, 12.669218, -22.439577, -12.975025, 20.62589, -22.439577, -12.164696, 20.62589, -22.439577, -13.150879, 12.669218, -22.439577, -12.975025, 12.669218, -22.439577, -12.14506, 20.62589, -22.439577, -12.164696, 21.590258, -22.073904, -12.457933, 21.502111, -22.213541, -12.622879, 21.590258, -22.439577, -12.676552, 21.502111, -22.213541, -12.622879, 21.590258, -22.073904, -12.73197, 21.590258, -22.439577, -12.676552, 21.590258, -22.439577, -12.14506, 21.590258, -22.073904, -12.457933, 21.590258, -22.439577, -12.676552, 21.590258, -22.439577, -12.14506, 21.590258, -22.073904, -12.14506, 21.590258, -22.073904, -12.457933, 21.590258, -22.073904, -12.73197, 21.590258, -22.439577, -13.127752, 21.590258, -22.439577, -12.676552, 21.590258, -22.073904, -12.73197, 21.590258, -22.073904, -13.127752, 21.590258, -22.439577, -13.127752, 12.669218, -22.073904, -12.975025, 12.669218, -22.439577, -12.14506, 12.669218, -22.439577, -12.975025, 12.669218, -22.439577, -12.14506, 12.669218, -22.073904, -12.975025, 12.669218, -22.073904, -12.352768, 12.669218, -22.439577, -12.14506, 12.669218, -22.073904, -12.352768, 12.669218, -22.165104, -12.14506, 17.199993, -22.439577, -11.627094, 21.590258, -22.073904, -11.812113, 17.199993, -22.073904, -11.627094, 17.199993, -22.439577, -11.627094, 21.590258, -22.439577, -11.812113, 21.590258, -22.073904, -11.812113, 12.669218, -22.073904, -11.812113, 17.199993, -22.439577, -11.627094, 17.199993, -22.073904, -11.627094, 12.669218, -22.073904, -11.812113, 12.669218, -22.439577, -11.812113, 17.199993, -22.439577, -11.627094, 17.199993, -22.439577, -10.647455, 21.590258, -22.439577, -11.812113, 17.199993, -22.439577, -11.627094, 17.199993, -22.439577, -10.647455, 21.590258, -22.439577, -10.982147, 21.590258, -22.439577, -11.812113, 17.199993, -22.439577, -11.627094, 12.669218, -22.439577, -10.678438, 17.199993, -22.439577, -10.647455, 17.199993, -22.439577, -11.627094, 12.669218, -22.439577, -11.254438, 12.669218, -22.439577, -10.678438, 12.669218, -22.439577, -11.254438, 17.199993, -22.439577, -11.627094, 12.669218, -22.439577, -11.812113, 21.590258, -22.439577, -10.982147, 21.590258, -22.073904, -11.812113, 21.590258, -22.439577, -11.812113, 21.590258, -22.439577, -10.982147, 21.590258, -22.073904, -10.982147, 21.590258, -22.073904, -11.812113, 12.669218, -22.073904, -11.32164, 12.840273, -22.182995, -11.224329, 12.669218, -22.439577, -11.254438, 17.199993, -22.073904, -10.892256, 17.199993, -22.165104, -10.647455, 16.06719, -22.073904, -10.65531, 17.199993, -22.073904, -10.892256, 18.29745, -22.073904, -10.731237, 17.199993, -22.165104, -10.647455, 17.199993, -22.073904, -10.892256, 21.590258, -22.073904, -10.982147, 18.29745, -22.073904, -10.731237, 17.199993, -22.073904, -10.892256, 21.590258, -22.073904, -11.812113, 21.590258, -22.073904, -10.982147, 21.590258, -22.073904, -11.812113, 17.199993, -22.073904, -10.892256, 17.199993, -22.073904, -11.627094, 16.06719, -22.073904, -10.65531, 17.199993, -22.073904, -11.627094, 17.199993, -22.073904, -10.892256, 17.199993, -22.073904, -11.627094, 12.669218, -22.073904, -11.32164, 12.669218, -22.073904, -11.812113, 17.199993, -22.073904, -11.627094, 13.447693, -22.073904, -11.221275, 12.669218, -22.073904, -11.32164, 16.06719, -22.073904, -10.65531, 13.447693, -22.073904, -11.221275, 17.199993, -22.073904, -11.627094, 12.840273, -22.182995, -11.224329, 12.669218, -22.073904, -11.32164, 13.447693, -22.073904, -11.221275, 12.669218, -22.073904, -11.044985, 12.840273, -22.182995, -11.224329, 13.447693, -22.073904, -11.221275, 13.447693, -22.073904, -11.221275, 16.06719, -22.073904, -10.65531, 12.669218, -22.073904, -11.044985, 12.669218, -22.073904, -11.044985, 16.06719, -22.073904, -10.65531, 12.669218, -22.073904, -10.678438, 12.669218, -22.439577, -11.812113, 12.669218, -22.073904, -11.32164, 12.669218, -22.439577, -11.254438, 12.669218, -22.439577, -11.812113, 12.669218, -22.073904, -11.812113, 12.669218, -22.073904, -11.32164, 12.669218, -22.073904, -11.044985, 12.669218, -22.439577, -10.678438, 12.669218, -22.439577, -11.254438, 12.669218, -22.073904, -11.044985, 12.669218, -22.073904, -10.678438, 12.669218, -22.439577, -10.678438, 12.840273, -22.182995, -11.224329, 12.669218, -22.073904, -11.044985, 12.669218, -22.439577, -11.254438, 12.669218, -22.073904, -10.678438, 17.199993, -22.439577, -10.647455, 12.669218, -22.439577, -10.678438, 17.199993, -22.439577, -10.647455, 12.669218, -22.073904, -10.678438, 16.06719, -22.073904, -10.65531, 17.199993, -22.439577, -10.647455, 16.06719, -22.073904, -10.65531, 17.199993, -22.165104, -10.647455, 17.199993, -22.165104, -10.647455, 21.590258, -22.439577, -10.982147, 17.199993, -22.439577, -10.647455, 17.199993, -22.165104, -10.647455, 21.590258, -22.073904, -10.982147, 21.590258, -22.439577, -10.982147, 21.590258, -22.073904, -10.982147, 17.199993, -22.165104, -10.647455, 18.29745, -22.073904, -10.731237, 20.39811, -22.073904, -9.367163, 21.299639, -22.439577, -9.347525, 20.39811, -22.439577, -9.367163, 20.39811, -22.073904, -9.367163, 21.299639, -22.073904, -9.347525, 21.299639, -22.439577, -9.347525, 20.39811, -22.439577, -9.367163, 14.819624, -22.073904, -9.352325, 20.39811, -22.073904, -9.367163, 20.39811, -22.439577, -9.367163, 12.960274, -22.165104, -9.347525, 14.819624, -22.073904, -9.352325, 12.960274, -22.165104, -9.347525, 20.39811, -22.439577, -9.367163, 12.960274, -22.439577, -9.347525, 21.299639, -22.073904, -10.330656, 20.39811, -22.073904, -9.367163, 20.39811, -22.073904, -10.353346, 14.819624, -22.073904, -9.352325, 20.39811, -22.073904, -10.353346, 20.39811, -22.073904, -9.367163, 14.819624, -22.073904, -9.352325, 12.960274, -22.073904, -10.17749, 20.39811, -22.073904, -10.353346, 12.960274, -22.073904, -10.17749, 14.819624, -22.073904, -9.352325, 12.960274, -22.073904, -9.555235, 12.960274, -22.073904, -9.555235, 14.819624, -22.073904, -9.352325, 12.960274, -22.165104, -9.347525, 20.39811, -22.073904, -9.367163, 21.299639, -22.073904, -10.330656, 20.818766, -22.073904, -9.8131275, 20.818766, -22.073904, -9.8131275, 21.299639, -22.073904, -10.330656, 21.299639, -22.073904, -9.934872, 21.299639, -22.073904, -9.934872, 21.217165, -22.213541, -9.825344, 20.818766, -22.073904, -9.8131275, 21.217165, -22.213541, -9.825344, 21.299639, -22.073904, -9.6603985, 20.818766, -22.073904, -9.8131275, 20.39811, -22.073904, -9.367163, 20.818766, -22.073904, -9.8131275, 21.299639, -22.073904, -9.6603985, 20.39811, -22.073904, -9.367163, 21.299639, -22.073904, -9.6603985, 21.299639, -22.073904, -9.347525, 20.39811, -22.439577, -10.353346, 21.299639, -22.073904, -10.330656, 20.39811, -22.073904, -10.353346, 20.39811, -22.439577, -10.353346, 21.299639, -22.439577, -10.330656, 21.299639, -22.073904, -10.330656, 12.960274, -22.073904, -10.17749, 20.39811, -22.439577, -10.353346, 20.39811, -22.073904, -10.353346, 12.960274, -22.073904, -10.17749, 12.960274, -22.439577, -10.17749, 20.39811, -22.439577, -10.353346, 21.299639, -22.439577, -9.347525, 20.39811, -22.439577, -10.353346, 20.39811, -22.439577, -9.367163, 20.39811, -22.439577, -10.353346, 21.299639, -22.439577, -9.347525, 21.299639, -22.439577, -9.879017, 20.39811, -22.439577, -10.353346, 21.299639, -22.439577, -9.879017, 21.299639, -22.439577, -10.330656, 12.960274, -22.439577, -10.17749, 20.39811, -22.439577, -9.367163, 20.39811, -22.439577, -10.353346, 12.960274, -22.439577, -10.17749, 12.960274, -22.439577, -9.347525, 20.39811, -22.439577, -9.367163, 21.299639, -22.073904, -9.6603985, 21.217165, -22.213541, -9.825344, 21.299639, -22.439577, -9.879017, 21.217165, -22.213541, -9.825344, 21.299639, -22.073904, -9.934872, 21.299639, -22.439577, -9.879017, 21.299639, -22.439577, -9.347525, 21.299639, -22.073904, -9.6603985, 21.299639, -22.439577, -9.879017, 21.299639, -22.439577, -9.347525, 21.299639, -22.073904, -9.347525, 21.299639, -22.073904, -9.6603985, 21.299639, -22.073904, -9.934872, 21.299639, -22.439577, -10.330656, 21.299639, -22.439577, -9.879017, 21.299639, -22.073904, -9.934872, 21.299639, -22.073904, -10.330656, 21.299639, -22.439577, -10.330656, 12.960274, -22.073904, -10.17749, 12.960274, -22.439577, -9.347525, 12.960274, -22.439577, -10.17749, 12.960274, -22.439577, -9.347525, 12.960274, -22.073904, -10.17749, 12.960274, -22.073904, -9.555235, 12.960274, -22.439577, -9.347525, 12.960274, -22.073904, -9.555235, 12.960274, -22.165104, -9.347525, 20.474909, -25.90344, -19.689804, 21.125528, -21.174994, -19.768787, 21.204512, -25.90344, -19.689804, 20.474909, -25.90344, -19.689804, 20.553892, -21.174994, -19.768787, 21.125528, -21.174994, -19.768787, 20.553892, -21.174994, -19.768787, 21.125528, -21.174994, -20.340424, 21.125528, -21.174994, -19.768787, 20.553892, -21.174994, -19.768787, 20.553892, -21.174994, -20.340424, 21.125528, -21.174994, -20.340424, 20.553892, -21.174994, -20.340424, 21.204512, -25.90344, -20.419407, 21.125528, -21.174994, -20.340424, 20.553892, -21.174994, -20.340424, 20.474909, -25.90344, -20.419407, 21.204512, -25.90344, -20.419407, 20.474909, -25.90344, -20.419407, 21.204512, -25.90344, -19.689804, 21.204512, -25.90344, -20.419407, 20.474909, -25.90344, -20.419407, 20.474909, -25.90344, -19.689804, 21.204512, -25.90344, -19.689804, 21.204512, -25.90344, -19.689804, 21.125528, -21.174994, -20.340424, 21.204512, -25.90344, -20.419407, 21.204512, -25.90344, -19.689804, 21.125528, -21.174994, -19.768787, 21.125528, -21.174994, -20.340424, 20.474909, -25.90344, -20.419407, 20.553892, -21.174994, -19.768787, 20.474909, -25.90344, -19.689804, 20.474909, -25.90344, -20.419407, 20.553892, -21.174994, -20.340424, 20.553892, -21.174994, -19.768787, 12.886963, -25.90344, 5.2994914, 13.6170025, -21.174994, 5.2994914, 13.6170025, -25.90344, 5.2994914, 12.886963, -25.90344, 5.2994914, 12.886963, -21.174994, 5.2994914, 13.6170025, -21.174994, 5.2994914, 12.886963, -25.90344, 4.5698895, 13.6170025, -25.90344, 5.2994914, 13.6170025, -25.90344, 4.5698895, 12.886963, -25.90344, 4.5698895, 12.886963, -25.90344, 5.2994914, 13.6170025, -25.90344, 5.2994914, 13.6170025, -25.90344, 5.2994914, 13.6170025, -21.174994, 4.5698895, 13.6170025, -25.90344, 4.5698895, 13.6170025, -25.90344, 5.2994914, 13.6170025, -21.174994, 5.2994914, 13.6170025, -21.174994, 4.5698895, 12.886963, -22.357105, 4.5698895, 13.069365, -21.174994, 4.5698895, 12.886963, -21.174994, 4.7522902, 12.886963, -25.90344, 5.2994914, 12.886963, -22.357105, 4.5698895, 12.886963, -21.174994, 4.7522902, 12.886963, -22.357105, 4.5698895, 12.886963, -25.90344, 5.2994914, 12.886963, -25.90344, 4.5698895, 12.886963, -25.90344, 5.2994914, 12.886963, -21.174994, 4.7522902, 12.886963, -21.174994, 5.2994914, 12.886963, -21.174994, 4.7522902, 13.6170025, -21.174994, 5.2994914, 12.886963, -21.174994, 5.2994914, 13.6170025, -21.174994, 5.2994914, 12.886963, -21.174994, 4.7522902, 13.069365, -21.174994, 4.5698895, 13.6170025, -21.174994, 5.2994914, 13.069365, -21.174994, 4.5698895, 13.6170025, -21.174994, 4.5698895, 12.886963, -25.90344, 4.5698895, 13.069365, -21.174994, 4.5698895, 12.886963, -22.357105, 4.5698895, 13.069365, -21.174994, 4.5698895, 12.886963, -25.90344, 4.5698895, 13.6170025, -25.90344, 4.5698895, 13.069365, -21.174994, 4.5698895, 13.6170025, -25.90344, 4.5698895, 13.6170025, -21.174994, 4.5698895, 17.195192, -22.439577, -7.5178485, 21.299639, -22.073904, -7.703304, 17.195192, -22.073904, -7.5178485, 17.195192, -22.439577, -7.5178485, 21.299639, -22.439577, -7.703304, 21.299639, -22.073904, -7.703304, 12.960274, -22.073904, -7.703304, 17.195192, -22.439577, -7.5178485, 17.195192, -22.073904, -7.5178485, 12.960274, -22.073904, -7.703304, 12.960274, -22.439577, -7.703304, 17.195192, -22.439577, -7.5178485, 17.195192, -22.439577, -6.539082, 21.299639, -22.439577, -7.703304, 17.195192, -22.439577, -7.5178485, 17.195192, -22.439577, -6.539082, 21.299639, -22.439577, -6.873338, 21.299639, -22.439577, -7.703304, 17.195192, -22.439577, -7.5178485, 12.960274, -22.439577, -6.570064, 17.195192, -22.439577, -6.539082, 17.195192, -22.439577, -7.5178485, 12.960274, -22.439577, -7.146065, 12.960274, -22.439577, -6.570064, 12.960274, -22.439577, -7.146065, 17.195192, -22.439577, -7.5178485, 12.960274, -22.439577, -7.703304, 21.299639, -22.439577, -6.873338, 21.299639, -22.073904, -7.703304, 21.299639, -22.439577, -7.703304, 21.299639, -22.439577, -6.873338, 21.299639, -22.073904, -6.873338, 21.299639, -22.073904, -7.703304, 12.960274, -22.073904, -7.2123933, 13.119982, -22.182995, -7.11552, 12.960274, -22.439577, -7.146065, 17.195192, -22.073904, -6.783447, 17.195192, -22.165104, -6.539082, 16.136572, -22.073904, -6.5465007, 17.195192, -22.073904, -6.783447, 18.221523, -22.073904, -6.6224284, 17.195192, -22.165104, -6.539082, 17.195192, -22.073904, -6.783447, 21.299639, -22.073904, -6.873338, 18.221523, -22.073904, -6.6224284, 17.195192, -22.073904, -6.783447, 21.299639, -22.073904, -7.703304, 21.299639, -22.073904, -6.873338, 21.299639, -22.073904, -7.703304, 17.195192, -22.073904, -6.783447, 17.195192, -22.073904, -7.5178485, 16.136572, -22.073904, -6.5465007, 17.195192, -22.073904, -7.5178485, 17.195192, -22.073904, -6.783447, 17.195192, -22.073904, -7.5178485, 12.960274, -22.073904, -7.2123933, 12.960274, -22.073904, -7.703304, 17.195192, -22.073904, -7.5178485, 13.688129, -22.073904, -7.112902, 12.960274, -22.073904, -7.2123933, 16.136572, -22.073904, -6.5465007, 13.688129, -22.073904, -7.112902, 17.195192, -22.073904, -7.5178485, 13.119982, -22.182995, -7.11552, 12.960274, -22.073904, -7.2123933, 13.688129, -22.073904, -7.112902, 12.960274, -22.073904, -6.9366107, 13.119982, -22.182995, -7.11552, 13.688129, -22.073904, -7.112902, 13.688129, -22.073904, -7.112902, 16.136572, -22.073904, -6.5465007, 12.960274, -22.073904, -6.9366107, 12.960274, -22.073904, -6.9366107, 16.136572, -22.073904, -6.5465007, 12.960274, -22.073904, -6.570064, 12.960274, -22.439577, -7.703304, 12.960274, -22.073904, -7.2123933, 12.960274, -22.439577, -7.146065, 12.960274, -22.439577, -7.703304, 12.960274, -22.073904, -7.703304, 12.960274, -22.073904, -7.2123933, 12.960274, -22.073904, -6.9366107, 12.960274, -22.439577, -6.570064, 12.960274, -22.439577, -7.146065, 12.960274, -22.073904, -6.9366107, 12.960274, -22.073904, -6.570064, 12.960274, -22.439577, -6.570064, 13.119982, -22.182995, -7.11552, 12.960274, -22.073904, -6.9366107, 12.960274, -22.439577, -7.146065, 12.960274, -22.073904, -6.570064, 17.195192, -22.439577, -6.539082, 12.960274, -22.439577, -6.570064, 17.195192, -22.439577, -6.539082, 12.960274, -22.073904, -6.570064, 16.136572, -22.073904, -6.5465007, 17.195192, -22.439577, -6.539082, 16.136572, -22.073904, -6.5465007, 17.195192, -22.165104, -6.539082, 17.195192, -22.165104, -6.539082, 21.299639, -22.439577, -6.873338, 17.195192, -22.439577, -6.539082, 17.195192, -22.165104, -6.539082, 21.299639, -22.073904, -6.873338, 21.299639, -22.439577, -6.873338, 21.299639, -22.073904, -6.873338, 17.195192, -22.165104, -6.539082, 18.221523, -22.073904, -6.6224284, 14.394604, -22.439577, -15.51292, 21.493383, -22.073904, -15.67263, 14.394604, -22.073904, -15.51292, 14.394604, -22.439577, -15.51292, 21.493383, -22.439577, -15.67263, 21.493383, -22.073904, -15.67263, 12.76609, -22.073904, -15.519905, 14.394604, -22.439577, -15.51292, 14.394604, -22.073904, -15.51292, 12.76609, -22.073904, -15.519905, 12.76609, -22.439577, -15.519905, 14.394604, -22.439577, -15.51292, 14.394604, -22.439577, -14.504482, 21.493383, -22.439577, -15.67263, 14.394604, -22.439577, -15.51292, 14.394604, -22.439577, -14.504482, 21.493383, -22.439577, -14.689938, 21.493383, -22.439577, -15.67263, 12.76609, -22.439577, -15.519905, 14.394604, -22.439577, -14.504482, 14.394604, -22.439577, -15.51292, 12.76609, -22.439577, -15.519905, 12.76609, -22.439577, -14.689938, 14.394604, -22.439577, -14.504482, 12.76609, -22.439577, -15.519905, 12.76609, -22.073904, -14.689938, 12.76609, -22.439577, -14.689938, 12.76609, -22.439577, -15.519905, 12.76609, -22.073904, -15.519905, 12.76609, -22.073904, -14.689938, 14.394604, -22.073904, -14.756702, 14.394604, -22.165104, -14.504482, 13.987476, -22.073904, -14.550737, 14.394604, -22.073904, -14.756702, 16.1693, -22.073904, -14.550737, 14.394604, -22.165104, -14.504482, 14.394604, -22.073904, -14.756702, 20.162472, -22.073904, -14.655029, 16.1693, -22.073904, -14.550737, 13.987476, -22.073904, -14.550737, 14.394604, -22.073904, -15.51292, 14.394604, -22.073904, -14.756702, 13.987476, -22.073904, -14.550737, 12.76609, -22.073904, -15.519905, 14.394604, -22.073904, -15.51292, 12.76609, -22.073904, -15.519905, 13.987476, -22.073904, -14.550737, 12.76609, -22.073904, -14.689938, 21.493383, -22.073904, -15.67263, 14.394604, -22.073904, -14.756702, 14.394604, -22.073904, -15.51292, 14.394604, -22.073904, -14.756702, 21.493383, -22.073904, -15.67263, 20.162472, -22.073904, -14.655029, 20.162472, -22.073904, -14.655029, 21.493383, -22.073904, -15.67263, 21.493383, -22.073904, -14.935175, 20.162472, -22.073904, -14.655029, 21.493383, -22.073904, -14.935175, 21.493383, -22.165104, -14.689938, 12.76609, -22.073904, -14.689938, 14.394604, -22.439577, -14.504482, 12.76609, -22.439577, -14.689938, 14.394604, -22.439577, -14.504482, 12.76609, -22.073904, -14.689938, 13.987476, -22.073904, -14.550737, 14.394604, -22.439577, -14.504482, 13.987476, -22.073904, -14.550737, 14.394604, -22.165104, -14.504482, 14.394604, -22.165104, -14.504482, 21.493383, -22.439577, -14.689938, 14.394604, -22.439577, -14.504482, 14.394604, -22.165104, -14.504482, 20.162472, -22.073904, -14.655029, 21.493383, -22.439577, -14.689938, 21.493383, -22.439577, -14.689938, 20.162472, -22.073904, -14.655029, 21.493383, -22.165104, -14.689938, 20.162472, -22.073904, -14.655029, 14.394604, -22.165104, -14.504482, 16.1693, -22.073904, -14.550737, 21.493383, -22.165104, -14.689938, 21.493383, -22.439577, -15.67263, 21.493383, -22.439577, -14.689938, 21.493383, -22.439577, -15.67263, 21.493383, -22.165104, -14.689938, 21.493383, -22.073904, -14.935175, 21.493383, -22.439577, -15.67263, 21.493383, -22.073904, -14.935175, 21.493383, -22.073904, -15.67263, 14.33395, -22.439577, -8.868834, 21.590258, -22.073904, -9.028543, 14.33395, -22.073904, -8.868834, 14.33395, -22.439577, -8.868834, 21.590258, -22.439577, -9.028543, 21.590258, -22.073904, -9.028543, 12.669218, -22.073904, -8.875814, 14.33395, -22.439577, -8.868834, 14.33395, -22.073904, -8.868834, 12.669218, -22.073904, -8.875814, 12.669218, -22.439577, -8.875814, 14.33395, -22.439577, -8.868834, 14.33395, -22.439577, -7.860395, 21.590258, -22.439577, -9.028543, 14.33395, -22.439577, -8.868834, 14.33395, -22.439577, -7.860395, 21.590258, -22.439577, -8.045849, 21.590258, -22.439577, -9.028543, 12.669218, -22.439577, -8.875814, 14.33395, -22.439577, -7.860395, 14.33395, -22.439577, -8.868834, 12.669218, -22.439577, -8.875814, 12.669218, -22.439577, -8.045849, 14.33395, -22.439577, -7.860395, 12.669218, -22.439577, -8.875814, 12.669218, -22.073904, -8.045849, 12.669218, -22.439577, -8.045849, 12.669218, -22.439577, -8.875814, 12.669218, -22.073904, -8.875814, 12.669218, -22.073904, -8.045849, 14.33395, -22.073904, -8.112614, 14.33395, -22.165104, -7.860395, 13.917658, -22.073904, -7.906649, 14.33395, -22.073904, -8.112614, 16.147917, -22.073904, -7.906649, 14.33395, -22.165104, -7.860395, 14.33395, -22.073904, -8.112614, 20.229671, -22.073904, -8.011377, 16.147917, -22.073904, -7.906649, 13.917658, -22.073904, -7.906649, 14.33395, -22.073904, -8.868834, 14.33395, -22.073904, -8.112614, 13.917658, -22.073904, -7.906649, 12.669218, -22.073904, -8.875814, 14.33395, -22.073904, -8.868834, 12.669218, -22.073904, -8.875814, 13.917658, -22.073904, -7.906649, 12.669218, -22.073904, -8.045849, 21.590258, -22.073904, -9.028543, 14.33395, -22.073904, -8.112614, 14.33395, -22.073904, -8.868834, 14.33395, -22.073904, -8.112614, 21.590258, -22.073904, -9.028543, 20.229671, -22.073904, -8.011377, 20.229671, -22.073904, -8.011377, 21.590258, -22.073904, -9.028543, 21.590258, -22.073904, -8.29196, 20.229671, -22.073904, -8.011377, 21.590258, -22.073904, -8.29196, 21.590258, -22.165104, -8.045849, 12.669218, -22.073904, -8.045849, 14.33395, -22.439577, -7.860395, 12.669218, -22.439577, -8.045849, 14.33395, -22.439577, -7.860395, 12.669218, -22.073904, -8.045849, 13.917658, -22.073904, -7.906649, 14.33395, -22.439577, -7.860395, 13.917658, -22.073904, -7.906649, 14.33395, -22.165104, -7.860395, 14.33395, -22.165104, -7.860395, 21.590258, -22.439577, -8.045849, 14.33395, -22.439577, -7.860395, 14.33395, -22.165104, -7.860395, 20.229671, -22.073904, -8.011377, 21.590258, -22.439577, -8.045849, 21.590258, -22.439577, -8.045849, 20.229671, -22.073904, -8.011377, 21.590258, -22.165104, -8.045849, 20.229671, -22.073904, -8.011377, 14.33395, -22.165104, -7.860395, 16.147917, -22.073904, -7.906649, 21.590258, -22.165104, -8.045849, 21.590258, -22.439577, -9.028543, 21.590258, -22.439577, -8.045849, 21.590258, -22.439577, -9.028543, 21.590258, -22.165104, -8.045849, 21.590258, -22.073904, -8.29196, 21.590258, -22.439577, -9.028543, 21.590258, -22.073904, -8.29196, 21.590258, -22.073904, -9.028543, 20.608437, -25.90344, 5.6272016, 21.338474, -21.174994, 5.6272016, 21.338474, -25.90344, 5.6272016, 20.608437, -25.90344, 5.6272016, 20.608437, -21.723503, 5.6272016, 21.338474, -21.174994, 5.6272016, 20.608437, -21.723503, 5.6272016, 21.338474, -21.174994, 4.8975997, 21.338474, -21.174994, 5.6272016, 20.608437, -21.723503, 5.6272016, 20.608437, -21.174994, 4.8975997, 21.338474, -21.174994, 4.8975997, 20.608437, -21.174994, 4.8975997, 21.338474, -25.90344, 4.8975997, 21.338474, -21.174994, 4.8975997, 20.608437, -21.174994, 4.8975997, 20.608437, -25.90344, 4.8975997, 21.338474, -25.90344, 4.8975997, 20.608437, -25.90344, 4.8975997, 21.338474, -25.90344, 5.6272016, 21.338474, -25.90344, 4.8975997, 20.608437, -25.90344, 4.8975997, 20.608437, -25.90344, 5.6272016, 21.338474, -25.90344, 5.6272016, 21.338474, -25.90344, 5.6272016, 21.338474, -21.174994, 4.8975997, 21.338474, -25.90344, 4.8975997, 21.338474, -25.90344, 5.6272016, 21.338474, -21.174994, 5.6272016, 21.338474, -21.174994, 4.8975997, 20.608437, -25.90344, 4.8975997, 20.608437, -21.723503, 5.6272016, 20.608437, -25.90344, 5.6272016, 20.608437, -25.90344, 4.8975997, 20.608437, -21.174994, 4.8975997, 20.608437, -21.723503, 5.6272016, 20.752874, -22.635942, 4.8980365, 20.984583, -22.543432, 4.8980365, 20.984583, -22.635942, 4.8980365, 20.752874, -22.635942, 4.8980365, 20.752874, -22.543432, 4.8980365, 20.984583, -22.543432, 4.8980365, 20.752874, -22.543432, 4.8980365, 20.984583, -22.543432, -19.742168, 20.984583, -22.543432, 4.8980365, 20.752874, -22.543432, 4.8980365, 20.752874, -22.543432, -19.742168, 20.984583, -22.543432, -19.742168, 20.752874, -22.543432, -19.742168, 20.984583, -22.635942, -19.742168, 20.984583, -22.543432, -19.742168, 20.752874, -22.543432, -19.742168, 20.752874, -22.635942, -19.742168, 20.984583, -22.635942, -19.742168, 20.752874, -22.635942, -19.742168, 20.984583, -22.635942, 4.8980365, 20.984583, -22.635942, -19.742168, 20.752874, -22.635942, -19.742168, 20.752874, -22.635942, 4.8980365, 20.984583, -22.635942, 4.8980365, 20.984583, -22.635942, 4.8980365, 20.984583, -22.543432, -19.742168, 20.984583, -22.635942, -19.742168, 20.984583, -22.635942, 4.8980365, 20.984583, -22.543432, 4.8980365, 20.984583, -22.543432, -19.742168, 20.752874, -22.635942, -19.742168, 20.752874, -22.543432, 4.8980365, 20.752874, -22.635942, 4.8980365, 20.752874, -22.635942, -19.742168, 20.752874, -22.543432, -19.742168, 20.752874, -22.543432, 4.8980365, 12.886963, -25.348385, -5.927299, 13.6170025, -20.619938, -5.927299, 13.6170025, -25.348385, -5.927299, 12.886963, -25.348385, -5.927299, 12.886963, -20.619938, -5.927299, 13.6170025, -20.619938, -5.927299, 12.886963, -25.348385, -6.6573377, 13.6170025, -25.348385, -5.927299, 13.6170025, -25.348385, -6.6573377, 12.886963, -25.348385, -6.6573377, 12.886963, -25.348385, -5.927299, 13.6170025, -25.348385, -5.927299, 13.6170025, -25.348385, -5.927299, 13.6170025, -20.619938, -6.6573377, 13.6170025, -25.348385, -6.6573377, 13.6170025, -25.348385, -5.927299, 13.6170025, -20.619938, -5.927299, 13.6170025, -20.619938, -6.6573377, 12.886963, -21.80205, -6.6573377, 13.069365, -20.619938, -6.6573377, 12.886963, -20.619938, -6.474937, 12.886963, -25.348385, -5.927299, 12.886963, -21.80205, -6.6573377, 12.886963, -20.619938, -6.474937, 12.886963, -21.80205, -6.6573377, 12.886963, -25.348385, -5.927299, 12.886963, -25.348385, -6.6573377, 12.886963, -25.348385, -5.927299, 12.886963, -20.619938, -6.474937, 12.886963, -20.619938, -5.927299, 12.886963, -20.619938, -6.474937, 13.6170025, -20.619938, -5.927299, 12.886963, -20.619938, -5.927299, 13.6170025, -20.619938, -5.927299, 12.886963, -20.619938, -6.474937, 13.069365, -20.619938, -6.6573377, 13.6170025, -20.619938, -5.927299, 13.069365, -20.619938, -6.6573377, 13.6170025, -20.619938, -6.6573377, 12.886963, -25.348385, -6.6573377, 13.069365, -20.619938, -6.6573377, 12.886963, -21.80205, -6.6573377, 13.069365, -20.619938, -6.6573377, 12.886963, -25.348385, -6.6573377, 13.6170025, -25.348385, -6.6573377, 13.069365, -20.619938, -6.6573377, 13.6170025, -25.348385, -6.6573377, 13.6170025, -20.619938, -6.6573377, 12.886963, -26.235077, -13.537062, 13.6170025, -21.50663, -13.537062, 13.6170025, -26.235077, -13.537062, 12.886963, -26.235077, -13.537062, 12.886963, -21.50663, -13.537062, 13.6170025, -21.50663, -13.537062, 12.886963, -26.235077, -14.2666645, 13.6170025, -26.235077, -13.537062, 13.6170025, -26.235077, -14.2666645, 12.886963, -26.235077, -14.2666645, 12.886963, -26.235077, -13.537062, 13.6170025, -26.235077, -13.537062, 13.6170025, -26.235077, -13.537062, 13.6170025, -21.50663, -14.2666645, 13.6170025, -26.235077, -14.2666645, 13.6170025, -26.235077, -13.537062, 13.6170025, -21.50663, -13.537062, 13.6170025, -21.50663, -14.2666645, 12.886963, -22.688742, -14.2666645, 13.069365, -21.50663, -14.2666645, 12.886963, -21.50663, -14.084265, 12.886963, -26.235077, -13.537062, 12.886963, -22.688742, -14.2666645, 12.886963, -21.50663, -14.084265, 12.886963, -22.688742, -14.2666645, 12.886963, -26.235077, -13.537062, 12.886963, -26.235077, -14.2666645, 12.886963, -26.235077, -13.537062, 12.886963, -21.50663, -14.084265, 12.886963, -21.50663, -13.537062, 12.886963, -21.50663, -14.084265, 13.6170025, -21.50663, -13.537062, 12.886963, -21.50663, -13.537062, 13.6170025, -21.50663, -13.537062, 12.886963, -21.50663, -14.084265, 13.069365, -21.50663, -14.2666645, 13.6170025, -21.50663, -13.537062, 13.069365, -21.50663, -14.2666645, 13.6170025, -21.50663, -14.2666645, 12.886963, -26.235077, -14.2666645, 13.069365, -21.50663, -14.2666645, 12.886963, -22.688742, -14.2666645, 13.069365, -21.50663, -14.2666645, 12.886963, -26.235077, -14.2666645, 13.6170025, -26.235077, -14.2666645, 13.069365, -21.50663, -14.2666645, 13.6170025, -26.235077, -14.2666645, 13.6170025, -21.50663, -14.2666645, 21.21673, -25.99464, -5.8286805, 21.946331, -21.266193, -5.8286805, 21.946331, -25.99464, -5.8286805, 21.21673, -25.99464, -5.8286805, 21.21673, -21.266193, -5.8286805, 21.946331, -21.266193, -5.8286805, 21.21673, -25.99464, -6.558719, 21.946331, -25.99464, -5.8286805, 21.946331, -25.99464, -6.558719, 21.21673, -25.99464, -6.558719, 21.21673, -25.99464, -5.8286805, 21.946331, -25.99464, -5.8286805, 21.946331, -25.99464, -5.8286805, 21.946331, -21.266193, -6.558719, 21.946331, -25.99464, -6.558719, 21.946331, -25.99464, -5.8286805, 21.946331, -21.266193, -5.8286805, 21.946331, -21.266193, -6.558719, 21.21673, -22.448305, -6.558719, 21.39913, -21.266193, -6.558719, 21.21673, -21.266193, -6.3763185, 21.21673, -25.99464, -5.8286805, 21.21673, -22.448305, -6.558719, 21.21673, -21.266193, -6.3763185, 21.21673, -22.448305, -6.558719, 21.21673, -25.99464, -5.8286805, 21.21673, -25.99464, -6.558719, 21.21673, -25.99464, -5.8286805, 21.21673, -21.266193, -6.3763185, 21.21673, -21.266193, -5.8286805, 21.21673, -21.266193, -6.3763185, 21.946331, -21.266193, -5.8286805, 21.21673, -21.266193, -5.8286805, 21.946331, -21.266193, -5.8286805, 21.21673, -21.266193, -6.3763185, 21.39913, -21.266193, -6.558719, 21.946331, -21.266193, -5.8286805, 21.39913, -21.266193, -6.558719, 21.946331, -21.266193, -6.558719, 21.21673, -25.99464, -6.558719, 21.39913, -21.266193, -6.558719, 21.21673, -22.448305, -6.558719, 21.39913, -21.266193, -6.558719, 21.21673, -25.99464, -6.558719, 21.946331, -25.99464, -6.558719, 21.39913, -21.266193, -6.558719, 21.946331, -25.99464, -6.558719, 21.946331, -21.266193, -6.558719, 21.21673, -25.99464, -13.537062, 21.946331, -21.266193, -13.537062, 21.946331, -25.99464, -13.537062, 21.21673, -25.99464, -13.537062, 21.21673, -21.266193, -13.537062, 21.946331, -21.266193, -13.537062, 21.21673, -25.99464, -14.2666645, 21.946331, -25.99464, -13.537062, 21.946331, -25.99464, -14.2666645, 21.21673, -25.99464, -14.2666645, 21.21673, -25.99464, -13.537062, 21.946331, -25.99464, -13.537062, 21.946331, -25.99464, -13.537062, 21.946331, -21.266193, -14.2666645, 21.946331, -25.99464, -14.2666645, 21.946331, -25.99464, -13.537062, 21.946331, -21.266193, -13.537062, 21.946331, -21.266193, -14.2666645, 21.21673, -22.448305, -14.2666645, 21.39913, -21.266193, -14.2666645, 21.21673, -21.266193, -14.084265, 21.21673, -25.99464, -13.537062, 21.21673, -22.448305, -14.2666645, 21.21673, -21.266193, -14.084265, 21.21673, -22.448305, -14.2666645, 21.21673, -25.99464, -13.537062, 21.21673, -25.99464, -14.2666645, 21.21673, -25.99464, -13.537062, 21.21673, -21.266193, -14.084265, 21.21673, -21.266193, -13.537062, 21.21673, -21.266193, -14.084265, 21.946331, -21.266193, -13.537062, 21.21673, -21.266193, -13.537062, 21.946331, -21.266193, -13.537062, 21.21673, -21.266193, -14.084265, 21.39913, -21.266193, -14.2666645, 21.946331, -21.266193, -13.537062, 21.39913, -21.266193, -14.2666645, 21.946331, -21.266193, -14.2666645, 21.21673, -25.99464, -14.2666645, 21.39913, -21.266193, -14.2666645, 21.21673, -22.448305, -14.2666645, 21.39913, -21.266193, -14.2666645, 21.21673, -25.99464, -14.2666645, 21.946331, -25.99464, -14.2666645, 21.39913, -21.266193, -14.2666645, 21.946331, -25.99464, -14.2666645, 21.946331, -21.266193, -14.2666645, -13.073534, -0.35833585, 29.034882, -8.669889, -0.18640822, 28.876835, -13.057354, 0.007337749, 29.029753, -13.073534, -0.35833585, 29.034882, -8.686073, -0.5516454, 28.882399, -8.669889, -0.18640822, 28.876835, -17.582396, 0.2010836, 28.810827, -13.073534, -0.35833585, 29.034882, -13.057354, 0.007337749, 29.029753, -17.582396, 0.2010836, 28.810827, -17.59858, -0.16415358, 28.816393, -13.073534, -0.35833585, 29.034882, -13.080118, -0.3434996, 30.014496, -8.686073, -0.5516454, 28.882399, -13.073534, -0.35833585, 29.034882, -13.080118, -0.3434996, 30.014496, -8.69165, -0.538991, 29.712345, -8.686073, -0.5516454, 28.882399, -13.073534, -0.35833585, 29.034882, -17.606195, -0.1471355, 29.949606, -13.080118, -0.3434996, 30.014496, -13.073534, -0.35833585, 29.034882, -17.602324, -0.15586269, 29.373617, -17.606195, -0.1471355, 29.949606, -17.602324, -0.15586269, 29.373617, -13.073534, -0.35833585, 29.034882, -17.59858, -0.16415358, 28.816393, -8.69165, -0.538991, 29.712345, -8.669889, -0.18640822, 28.876835, -8.686073, -0.5516454, 28.882399, -8.69165, -0.538991, 29.712345, -8.675467, -0.17375374, 29.706781, -8.669889, -0.18640822, 28.876835, -17.585695, 0.20850176, 29.301727, -17.420107, 0.093301594, 29.40146, -17.602324, -0.15586269, 29.373617, -13.06229, 0.01824683, 29.764137, -13.067871, -0.06946254, 30.010216, -14.195347, 0.07104677, 29.993038, -13.06229, 0.01824683, 29.764137, -11.966818, -0.027135193, 29.933395, -13.067871, -0.06946254, 30.010216, -13.06229, 0.01824683, 29.764137, -8.675467, -0.17375374, 29.706781, -11.966818, -0.027135193, 29.933395, -13.06229, 0.01824683, 29.764137, -8.669889, -0.18640822, 28.876835, -8.675467, -0.17375374, 29.706781, -8.669889, -0.18640822, 28.876835, -13.06229, 0.01824683, 29.764137, -13.057354, 0.007337749, 29.029753, -14.195347, 0.07104677, 29.993038, -13.057354, 0.007337749, 29.029753, -13.06229, 0.01824683, 29.764137, -13.057354, 0.007337749, 29.029753, -17.585695, 0.20850176, 29.301727, -17.582396, 0.2010836, 28.810827, -13.057354, 0.007337749, 29.029753, -16.80835, 0.17621082, 29.407318, -17.585695, 0.20850176, 29.301727, -14.195347, 0.07104677, 29.993038, -16.80835, 0.17621082, 29.407318, -13.057354, 0.007337749, 29.029753, -17.420107, 0.093301594, 29.40146, -17.585695, 0.20850176, 29.301727, -16.80835, 0.17621082, 29.407318, -17.587553, 0.21286553, 29.57794, -17.420107, 0.093301594, 29.40146, -16.80835, 0.17621082, 29.407318, -16.80835, 0.17621082, 29.407318, -14.195347, 0.07104677, 29.993038, -17.587553, 0.21286553, 29.57794, -17.587553, 0.21286553, 29.57794, -14.195347, 0.07104677, 29.993038, -17.590448, 0.21810168, 29.944038, -17.59858, -0.16415358, 28.816393, -17.585695, 0.20850176, 29.301727, -17.602324, -0.15586269, 29.373617, -17.59858, -0.16415358, 28.816393, -17.582396, 0.2010836, 28.810827, -17.585695, 0.20850176, 29.301727, -17.587553, 0.21286553, 29.57794, -17.606195, -0.1471355, 29.949606, -17.602324, -0.15586269, 29.373617, -17.587553, 0.21286553, 29.57794, -17.590448, 0.21810168, 29.944038, -17.606195, -0.1471355, 29.949606, -17.420107, 0.093301594, 29.40146, -17.587553, 0.21286553, 29.57794, -17.602324, -0.15586269, 29.373617, -17.590448, 0.21810168, 29.944038, -13.080118, -0.3434996, 30.014496, -17.606195, -0.1471355, 29.949606, -13.080118, -0.3434996, 30.014496, -17.590448, 0.21810168, 29.944038, -13.067871, -0.06946254, 30.010216, -13.067871, -0.06946254, 30.010216, -17.590448, 0.21810168, 29.944038, -14.195347, 0.07104677, 29.993038, -13.067871, -0.06946254, 30.010216, -8.675467, -0.17375374, 29.706781, -13.080118, -0.3434996, 30.014496, -13.080118, -0.3434996, 30.014496, -8.675467, -0.17375374, 29.706781, -8.69165, -0.538991, 29.712345, -8.675467, -0.17375374, 29.706781, -13.067871, -0.06946254, 30.010216, -11.966818, -0.027135193, 29.933395, -9.878, -0.097390115, 31.312849, -8.993242, -0.5014638, 31.344105, -9.893747, -0.4626273, 31.318417, -9.878, -0.097390115, 31.312849, -8.977062, -0.13622653, 31.338978, -8.993242, -0.5014638, 31.344105, -9.893747, -0.4626273, 31.318417, -17.31272, 0.1343196, 31.278593, -9.878, -0.097390115, 31.312849, -9.878, -0.097390115, 31.312849, -17.31272, 0.1343196, 31.278593, -15.451195, 0.14479238, 31.285868, -17.31272, 0.1343196, 31.278593, -9.893747, -0.4626273, 31.318417, -17.324528, -0.13971746, 31.28244, -8.970457, -0.15106285, 30.356306, -9.878, -0.097390115, 31.312849, -9.871372, -0.11222637, 30.326687, -15.451195, 0.14479238, 31.285868, -9.871372, -0.11222637, 30.326687, -9.878, -0.097390115, 31.312849, -15.451195, 0.14479238, 31.285868, -17.303205, 0.21330172, 30.447365, -9.871372, -0.11222637, 30.326687, -17.303205, 0.21330172, 30.447365, -15.451195, 0.14479238, 31.285868, -17.307388, 0.22246522, 31.069607, -17.307388, 0.22246522, 31.069607, -15.451195, 0.14479238, 31.285868, -17.31272, 0.1343196, 31.278593, -9.878, -0.097390115, 31.312849, -8.970457, -0.15106285, 30.356306, -9.454359, -0.122262776, 30.87016, -9.454359, -0.122262776, 30.87016, -8.970457, -0.15106285, 30.356306, -8.973118, -0.14495373, 30.75208, -8.973118, -0.14495373, 30.75208, -9.062445, -0.27935404, 30.862755, -9.454359, -0.122262776, 30.87016, -9.062445, -0.27935404, 30.862755, -8.974958, -0.14102644, 31.026112, -9.454359, -0.122262776, 30.87016, -9.878, -0.097390115, 31.312849, -9.454359, -0.122262776, 30.87016, -8.974958, -0.14102644, 31.026112, -9.878, -0.097390115, 31.312849, -8.974958, -0.14102644, 31.026112, -8.977062, -0.13622653, 31.338978, -9.8871155, -0.47789997, 30.331818, -8.970457, -0.15106285, 30.356306, -9.871372, -0.11222637, 30.326687, -9.8871155, -0.47789997, 30.331818, -8.986637, -0.5163001, 30.361433, -8.970457, -0.15106285, 30.356306, -17.303205, 0.21330172, 30.447365, -9.8871155, -0.47789997, 30.331818, -9.871372, -0.11222637, 30.326687, -17.303205, 0.21330172, 30.447365, -17.318953, -0.15193558, 30.45293, -9.8871155, -0.47789997, 30.331818, -8.993242, -0.5014638, 31.344105, -9.8871155, -0.47789997, 30.331818, -9.893747, -0.4626273, 31.318417, -9.8871155, -0.47789997, 30.331818, -8.993242, -0.5014638, 31.344105, -8.989674, -0.50975466, 30.81306, -9.8871155, -0.47789997, 30.331818, -8.989674, -0.50975466, 30.81306, -8.986637, -0.5163001, 30.361433, -17.318953, -0.15193558, 30.45293, -9.893747, -0.4626273, 31.318417, -9.8871155, -0.47789997, 30.331818, -17.318953, -0.15193558, 30.45293, -17.324528, -0.13971746, 31.28244, -9.893747, -0.4626273, 31.318417, -8.974958, -0.14102644, 31.026112, -9.062445, -0.27935404, 30.862755, -8.989674, -0.50975466, 30.81306, -9.062445, -0.27935404, 30.862755, -8.973118, -0.14495373, 30.75208, -8.989674, -0.50975466, 30.81306, -8.993242, -0.5014638, 31.344105, -8.974958, -0.14102644, 31.026112, -8.989674, -0.50975466, 30.81306, -8.993242, -0.5014638, 31.344105, -8.977062, -0.13622653, 31.338978, -8.974958, -0.14102644, 31.026112, -8.973118, -0.14495373, 30.75208, -8.986637, -0.5163001, 30.361433, -8.989674, -0.50975466, 30.81306, -8.973118, -0.14495373, 30.75208, -8.970457, -0.15106285, 30.356306, -8.986637, -0.5163001, 30.361433, -17.303205, 0.21330172, 30.447365, -17.324528, -0.13971746, 31.28244, -17.318953, -0.15193558, 30.45293, -17.324528, -0.13971746, 31.28244, -17.303205, 0.21330172, 30.447365, -17.31272, 0.1343196, 31.278593, -17.31272, 0.1343196, 31.278593, -17.303205, 0.21330172, 30.447365, -17.307388, 0.22246522, 31.069607, -13.105944, -0.29549986, 33.143127, -8.988548, -0.111353934, 32.98316, -13.089764, 0.069737375, 33.138, -13.105944, -0.29549986, 33.143127, -9.004295, -0.47659117, 32.988728, -8.988548, -0.111353934, 32.98316, -17.31983, 0.25082868, 32.92106, -13.105944, -0.29549986, 33.143127, -13.089764, 0.069737375, 33.138, -17.31983, 0.25082868, 32.92106, -17.335577, -0.11440843, 32.926624, -13.105944, -0.29549986, 33.143127, -13.112524, -0.2806636, 34.12231, -9.004295, -0.47659117, 32.988728, -13.105944, -0.29549986, 33.143127, -13.112524, -0.2806636, 34.12231, -9.009869, -0.4639367, 33.818237, -9.004295, -0.47659117, 32.988728, -13.105944, -0.29549986, 33.143127, -17.343197, -0.09739047, 34.060276, -13.112524, -0.2806636, 34.12231, -13.105944, -0.29549986, 33.143127, -17.339323, -0.10611755, 33.483852, -17.343197, -0.09739047, 34.060276, -17.339323, -0.10611755, 33.483852, -13.105944, -0.29549986, 33.143127, -17.335577, -0.11440843, 32.926624, -9.009869, -0.4639367, 33.818237, -8.988548, -0.111353934, 32.98316, -9.004295, -0.47659117, 32.988728, -9.009869, -0.4639367, 33.818237, -8.994122, -0.09869945, 33.812668, -8.988548, -0.111353934, 32.98316, -17.323126, 0.25824696, 33.411522, -17.16845, 0.14348298, 33.51162, -17.339323, -0.10611755, 33.483852, -13.0947, 0.080646336, 33.872387, -13.100277, -0.0066265464, 34.118027, -14.154011, 0.1303919, 34.101345, -13.0947, 0.080646336, 33.872387, -12.070787, 0.038755357, 34.040726, -13.100277, -0.0066265464, 34.118027, -13.0947, 0.080646336, 33.872387, -8.994122, -0.09869945, 33.812668, -12.070787, 0.038755357, 34.040726, -13.0947, 0.080646336, 33.872387, -8.988548, -0.111353934, 32.98316, -8.994122, -0.09869945, 33.812668, -8.988548, -0.111353934, 32.98316, -13.0947, 0.080646336, 33.872387, -13.089764, 0.069737375, 33.138, -14.154011, 0.1303919, 34.101345, -13.089764, 0.069737375, 33.138, -13.0947, 0.080646336, 33.872387, -13.089764, 0.069737375, 33.138, -17.323126, 0.25824696, 33.411522, -17.31983, 0.25082868, 32.92106, -13.089764, 0.069737375, 33.138, -16.596401, 0.22813767, 33.51721, -17.323126, 0.25824696, 33.411522, -14.154011, 0.1303919, 34.101345, -16.596401, 0.22813767, 33.51721, -13.089764, 0.069737375, 33.138, -17.16845, 0.14348298, 33.51162, -17.323126, 0.25824696, 33.411522, -16.596401, 0.22813767, 33.51721, -17.324986, 0.2621742, 33.68817, -17.16845, 0.14348298, 33.51162, -16.596401, 0.22813767, 33.51721, -16.596401, 0.22813767, 33.51721, -14.154011, 0.1303919, 34.101345, -17.324986, 0.2621742, 33.68817, -17.324986, 0.2621742, 33.68817, -14.154011, 0.1303919, 34.101345, -17.32745, 0.26784688, 34.05471, -17.335577, -0.11440843, 32.926624, -17.323126, 0.25824696, 33.411522, -17.339323, -0.10611755, 33.483852, -17.335577, -0.11440843, 32.926624, -17.31983, 0.25082868, 32.92106, -17.323126, 0.25824696, 33.411522, -17.324986, 0.2621742, 33.68817, -17.343197, -0.09739047, 34.060276, -17.339323, -0.10611755, 33.483852, -17.324986, 0.2621742, 33.68817, -17.32745, 0.26784688, 34.05471, -17.343197, -0.09739047, 34.060276, -17.16845, 0.14348298, 33.51162, -17.324986, 0.2621742, 33.68817, -17.339323, -0.10611755, 33.483852, -17.32745, 0.26784688, 34.05471, -13.112524, -0.2806636, 34.12231, -17.343197, -0.09739047, 34.060276, -13.112524, -0.2806636, 34.12231, -17.32745, 0.26784688, 34.05471, -13.100277, -0.0066265464, 34.118027, -13.100277, -0.0066265464, 34.118027, -17.32745, 0.26784688, 34.05471, -14.154011, 0.1303919, 34.101345, -13.100277, -0.0066265464, 34.118027, -8.994122, -0.09869945, 33.812668, -13.112524, -0.2806636, 34.12231, -13.112524, -0.2806636, 34.12231, -8.994122, -0.09869945, 33.812668, -9.009869, -0.4639367, 33.818237, -8.994122, -0.09869945, 33.812668, -13.100277, -0.0066265464, 34.118027, -12.070787, 0.038755357, 34.040726, -15.954979, -0.19208127, 31.771221, -8.68903, -0.14408118, 31.659903, -15.939232, 0.17359239, 31.765654, -15.954979, -0.19208127, 31.771221, -8.704777, -0.50931835, 31.66547, -8.68903, -0.14408118, 31.659903, -17.602564, 0.24559253, 31.74662, -15.954979, -0.19208127, 31.771221, -15.939232, 0.17359239, 31.765654, -17.602564, 0.24559253, 31.74662, -17.61831, -0.11964476, 31.752188, -15.954979, -0.19208127, 31.771221, -15.961756, -0.1768086, 32.779636, -8.704777, -0.50931835, 31.66547, -15.954979, -0.19208127, 31.771221, -15.961756, -0.1768086, 32.779636, -8.71138, -0.49448207, 32.64814, -8.704777, -0.50931835, 31.66547, -17.61831, -0.11964476, 31.752188, -15.961756, -0.1768086, 32.779636, -15.954979, -0.19208127, 31.771221, -17.61831, -0.11964476, 31.752188, -17.623884, -0.10699028, 32.5817, -15.961756, -0.1768086, 32.779636, -17.61831, -0.11964476, 31.752188, -17.608137, 0.25824708, 32.57613, -17.623884, -0.10699028, 32.5817, -17.61831, -0.11964476, 31.752188, -17.602564, 0.24559253, 31.74662, -17.608137, 0.25824708, 32.57613, -15.944313, 0.18493778, 32.52186, -15.949944, 0.09722847, 32.775352, -16.361544, 0.20588332, 32.72502, -15.944313, 0.18493778, 32.52186, -14.133524, 0.109446704, 32.741302, -15.949944, 0.09722847, 32.775352, -15.944313, 0.18493778, 32.52186, -10.055107, -0.069462895, 32.667473, -14.133524, 0.109446704, 32.741302, -16.361544, 0.20588332, 32.72502, -15.939232, 0.17359239, 31.765654, -15.944313, 0.18493778, 32.52186, -16.361544, 0.20588332, 32.72502, -17.602564, 0.24559253, 31.74662, -15.939232, 0.17359239, 31.765654, -17.602564, 0.24559253, 31.74662, -16.361544, 0.20588332, 32.72502, -17.608137, 0.25824708, 32.57613, -8.68903, -0.14408118, 31.659903, -15.944313, 0.18493778, 32.52186, -15.939232, 0.17359239, 31.765654, -15.944313, 0.18493778, 32.52186, -8.68903, -0.14408118, 31.659903, -10.055107, -0.069462895, 32.667473, -10.055107, -0.069462895, 32.667473, -8.68903, -0.14408118, 31.659903, -8.693983, -0.13273573, 32.396904, -10.055107, -0.069462895, 32.667473, -8.693983, -0.13273573, 32.396904, -8.69957, -0.2204451, 32.643856, -17.608137, 0.25824708, 32.57613, -15.961756, -0.1768086, 32.779636, -17.623884, -0.10699028, 32.5817, -15.961756, -0.1768086, 32.779636, -17.608137, 0.25824708, 32.57613, -15.949944, 0.09722847, 32.775352, -15.949944, 0.09722847, 32.775352, -17.608137, 0.25824708, 32.57613, -16.361544, 0.20588332, 32.72502, -15.949944, 0.09722847, 32.775352, -8.69957, -0.2204451, 32.643856, -15.961756, -0.1768086, 32.779636, -15.961756, -0.1768086, 32.779636, -8.69957, -0.2204451, 32.643856, -8.71138, -0.49448207, 32.64814, -8.69957, -0.2204451, 32.643856, -15.949944, 0.09722847, 32.775352, -10.055107, -0.069462895, 32.667473, -10.055107, -0.069462895, 32.667473, -15.949944, 0.09722847, 32.775352, -14.133524, 0.109446704, 32.741302, -8.69957, -0.2204451, 32.643856, -8.704777, -0.50931835, 31.66547, -8.71138, -0.49448207, 32.64814, -8.704777, -0.50931835, 31.66547, -8.69957, -0.2204451, 32.643856, -8.68903, -0.14408118, 31.659903, -8.68903, -0.14408118, 31.659903, -8.69957, -0.2204451, 32.643856, -8.693983, -0.13273573, 32.396904, -13.166946, -0.5581908, 28.64937, -17.27211, -0.19208091, 28.80724, -13.166946, -0.19208091, 28.64937, -13.166946, -0.5581908, 28.64937, -17.27211, -0.5581909, 28.80724, -17.27211, -0.19208091, 28.80724, -8.932932, -0.19208091, 28.863285, -13.166946, -0.5581908, 28.64937, -13.166946, -0.19208091, 28.64937, -8.932932, -0.19208091, 28.863285, -8.932932, -0.5581909, 28.863285, -13.166946, -0.5581908, 28.64937, -13.160368, -0.5581907, 27.670628, -17.27211, -0.5581909, 28.80724, -13.166946, -0.5581908, 28.64937, -13.160368, -0.5581907, 27.670628, -17.266533, -0.5581908, 27.977295, -17.27211, -0.5581909, 28.80724, -13.166946, -0.5581908, 28.64937, -8.925314, -0.55819076, 27.729633, -13.160368, -0.5581907, 27.670628, -13.166946, -0.5581908, 28.64937, -8.929188, -0.5581908, 28.306059, -8.925314, -0.55819076, 27.729633, -8.929188, -0.5581908, 28.306059, -13.166946, -0.5581908, 28.64937, -8.932932, -0.5581909, 28.863285, -17.266533, -0.5581908, 27.977295, -17.27211, -0.19208091, 28.80724, -17.27211, -0.5581909, 28.80724, -17.266533, -0.5581908, 27.977295, -17.266533, -0.1920808, 27.977295, -17.27211, -0.19208091, 28.80724, -8.929636, -0.19208086, 28.372822, -9.089121, -0.30160832, 28.274002, -8.929188, -0.5581908, 28.306059, -13.16201, -0.1920808, 27.914986, -13.160368, -0.28371733, 27.670628, -12.101822, -0.1920808, 27.685158, -13.16201, -0.1920808, 27.914986, -14.1868, -0.1920808, 27.747076, -13.160368, -0.28371733, 27.670628, -13.16201, -0.1920808, 27.914986, -17.266533, -0.1920808, 27.977295, -14.1868, -0.1920808, 27.747076, -13.16201, -0.1920808, 27.914986, -17.27211, -0.19208091, 28.80724, -17.266533, -0.1920808, 27.977295, -17.27211, -0.19208091, 28.80724, -13.16201, -0.1920808, 27.914986, -13.166946, -0.19208091, 28.64937, -12.101822, -0.1920808, 27.685158, -13.166946, -0.19208091, 28.64937, -13.16201, -0.1920808, 27.914986, -13.166946, -0.19208091, 28.64937, -8.929636, -0.19208086, 28.372822, -8.932932, -0.19208091, 28.863285, -13.166946, -0.19208091, 28.64937, -9.656805, -0.19208086, 28.268005, -8.929636, -0.19208086, 28.372822, -12.101822, -0.1920808, 27.685158, -9.656805, -0.19208086, 28.268005, -13.166946, -0.19208091, 28.64937, -9.089121, -0.30160832, 28.274002, -8.929636, -0.19208086, 28.372822, -9.656805, -0.19208086, 28.268005, -8.92778, -0.19208086, 28.09661, -9.089121, -0.30160832, 28.274002, -9.656805, -0.19208086, 28.268005, -9.656805, -0.19208086, 28.268005, -12.101822, -0.1920808, 27.685158, -8.92778, -0.19208086, 28.09661, -8.92778, -0.19208086, 28.09661, -12.101822, -0.1920808, 27.685158, -8.925314, -0.1920808, 27.729633, -8.932932, -0.5581909, 28.863285, -8.929636, -0.19208086, 28.372822, -8.929188, -0.5581908, 28.306059, -8.932932, -0.5581909, 28.863285, -8.932932, -0.19208091, 28.863285, -8.929636, -0.19208086, 28.372822, -8.92778, -0.19208086, 28.09661, -8.925314, -0.55819076, 27.729633, -8.929188, -0.5581908, 28.306059, -8.92778, -0.19208086, 28.09661, -8.925314, -0.1920808, 27.729633, -8.925314, -0.55819076, 27.729633, -9.089121, -0.30160832, 28.274002, -8.92778, -0.19208086, 28.09661, -8.929188, -0.5581908, 28.306059, -8.925314, -0.1920808, 27.729633, -13.160368, -0.5581907, 27.670628, -8.925314, -0.55819076, 27.729633, -13.160368, -0.5581907, 27.670628, -8.925314, -0.1920808, 27.729633, -12.101822, -0.1920808, 27.685158, -13.160368, -0.5581907, 27.670628, -12.101822, -0.1920808, 27.685158, -13.160368, -0.28371733, 27.670628, -13.160368, -0.28371733, 27.670628, -17.266533, -0.5581908, 27.977295, -13.160368, -0.5581907, 27.670628, -13.160368, -0.28371733, 27.670628, -17.266533, -0.1920808, 27.977295, -17.266533, -0.5581908, 27.977295, -17.266533, -0.1920808, 27.977295, -13.160368, -0.28371733, 27.670628, -14.1868, -0.1920808, 27.747076, -16.346624, -0.1920805, 25.245663, -17.248005, -0.55819046, 25.220404, -16.346624, -0.55819046, 25.245663, -16.346624, -0.1920805, 25.245663, -17.248005, -0.1920805, 25.220404, -17.248005, -0.55819046, 25.220404, -16.346624, -0.55819046, 25.245663, -10.768602, -0.1920805, 25.26875, -16.346624, -0.1920805, 25.245663, -16.346624, -0.55819046, 25.245663, -8.908827, -0.28371704, 25.276447, -10.768602, -0.1920805, 25.26875, -8.908827, -0.28371704, 25.276447, -16.346624, -0.55819046, 25.245663, -8.908827, -0.55819046, 25.276447, -17.254608, -0.19208062, 26.203075, -16.346624, -0.1920805, 25.245663, -16.353252, -0.19208062, 26.231823, -10.768602, -0.1920805, 25.26875, -16.353252, -0.19208062, 26.231823, -16.346624, -0.1920805, 25.245663, -10.768602, -0.1920805, 25.26875, -8.914402, -0.19208062, 26.105959, -16.353252, -0.19208062, 26.231823, -8.914402, -0.19208062, 26.105959, -10.768602, -0.1920805, 25.26875, -8.910217, -0.1920805, 25.48328, -8.910217, -0.1920805, 25.48328, -10.768602, -0.1920805, 25.26875, -8.908827, -0.28371704, 25.276447, -16.346624, -0.1920805, 25.245663, -17.254608, -0.19208062, 26.203075, -16.7707, -0.19208056, 25.68835, -16.7707, -0.19208056, 25.68835, -17.254608, -0.19208062, 26.203075, -17.251945, -0.19208056, 25.806864, -17.251945, -0.19208056, 25.806864, -17.169178, -0.33171725, 25.698326, -16.7707, -0.19208056, 25.68835, -17.169178, -0.33171725, 25.698326, -17.250105, -0.1920805, 25.532833, -16.7707, -0.19208056, 25.68835, -16.346624, -0.1920805, 25.245663, -16.7707, -0.19208056, 25.68835, -17.250105, -0.1920805, 25.532833, -16.346624, -0.1920805, 25.245663, -17.250105, -0.1920805, 25.532833, -17.248005, -0.1920805, 25.220404, -16.353252, -0.5581906, 26.231823, -17.254608, -0.19208062, 26.203075, -16.353252, -0.19208062, 26.231823, -16.353252, -0.5581906, 26.231823, -17.254608, -0.5581906, 26.203075, -17.254608, -0.19208062, 26.203075, -8.914402, -0.19208062, 26.105959, -16.353252, -0.5581906, 26.231823, -16.353252, -0.19208062, 26.231823, -8.914402, -0.19208062, 26.105959, -8.914402, -0.5581906, 26.105959, -16.353252, -0.5581906, 26.231823, -17.248005, -0.55819046, 25.220404, -16.353252, -0.5581906, 26.231823, -16.346624, -0.55819046, 25.245663, -16.353252, -0.5581906, 26.231823, -17.248005, -0.55819046, 25.220404, -17.251574, -0.5581905, 25.751448, -16.353252, -0.5581906, 26.231823, -17.251574, -0.5581905, 25.751448, -17.254608, -0.5581906, 26.203075, -8.914402, -0.5581906, 26.105959, -16.346624, -0.55819046, 25.245663, -16.353252, -0.5581906, 26.231823, -8.914402, -0.5581906, 26.105959, -8.908827, -0.55819046, 25.276447, -16.346624, -0.55819046, 25.245663, -17.250105, -0.1920805, 25.532833, -17.169178, -0.33171725, 25.698326, -17.251574, -0.5581905, 25.751448, -17.169178, -0.33171725, 25.698326, -17.251945, -0.19208056, 25.806864, -17.251574, -0.5581905, 25.751448, -17.248005, -0.55819046, 25.220404, -17.250105, -0.1920805, 25.532833, -17.251574, -0.5581905, 25.751448, -17.248005, -0.55819046, 25.220404, -17.248005, -0.1920805, 25.220404, -17.250105, -0.1920805, 25.532833, -17.251945, -0.19208056, 25.806864, -17.254608, -0.5581906, 26.203075, -17.251574, -0.5581905, 25.751448, -17.251945, -0.19208056, 25.806864, -17.254608, -0.19208062, 26.203075, -17.254608, -0.5581906, 26.203075, -8.914402, -0.19208062, 26.105959, -8.908827, -0.55819046, 25.276447, -8.914402, -0.5581906, 26.105959, -8.908827, -0.55819046, 25.276447, -8.914402, -0.19208062, 26.105959, -8.910217, -0.1920805, 25.48328, -8.908827, -0.55819046, 25.276447, -8.910217, -0.1920805, 25.48328, -8.908827, -0.28371704, 25.276447, -16.566906, -0.19208038, 24.06379, -17.53112, -0.55819035, 24.038109, -16.566906, -0.55819035, 24.06379, -16.566906, -0.19208038, 24.06379, -17.53112, -0.19208038, 24.038109, -17.53112, -0.55819035, 24.038109, -16.566906, -0.55819035, 24.06379, -10.599218, -0.19208038, 24.089493, -16.566906, -0.19208038, 24.06379, -16.566906, -0.55819035, 24.06379, -8.610281, -0.28371692, 24.09806, -10.599218, -0.19208038, 24.089493, -8.610281, -0.28371692, 24.09806, -16.566906, -0.55819035, 24.06379, -8.610281, -0.55819035, 24.09806, -17.537725, -0.19208044, 25.020779, -16.566906, -0.19208038, 24.06379, -16.573536, -0.1920805, 25.050386, -10.599218, -0.19208038, 24.089493, -16.573536, -0.1920805, 25.050386, -16.566906, -0.19208038, 24.06379, -10.599218, -0.19208038, 24.089493, -8.61586, -0.19208044, 24.928009, -16.573536, -0.1920805, 25.050386, -8.61586, -0.19208044, 24.928009, -10.599218, -0.19208038, 24.089493, -8.611677, -0.19208038, 24.305765, -8.611677, -0.19208038, 24.305765, -10.599218, -0.19208038, 24.089493, -8.610281, -0.28371692, 24.09806, -16.566906, -0.19208038, 24.06379, -17.537725, -0.19208044, 25.020779, -17.02022, -0.19208038, 24.506718, -17.02022, -0.19208038, 24.506718, -17.537725, -0.19208044, 25.020779, -17.535063, -0.19208044, 24.624569, -17.535063, -0.19208044, 24.624569, -17.446188, -0.33171707, 24.51651, -17.02022, -0.19208038, 24.506718, -17.446188, -0.33171707, 24.51651, -17.533224, -0.19208038, 24.350975, -17.02022, -0.19208038, 24.506718, -16.566906, -0.19208038, 24.06379, -17.02022, -0.19208038, 24.506718, -17.533224, -0.19208038, 24.350975, -16.566906, -0.19208038, 24.06379, -17.533224, -0.19208038, 24.350975, -17.53112, -0.19208038, 24.038109, -16.573536, -0.55819046, 25.050386, -17.537725, -0.19208044, 25.020779, -16.573536, -0.1920805, 25.050386, -16.573536, -0.55819046, 25.050386, -17.537725, -0.55819046, 25.020779, -17.537725, -0.19208044, 25.020779, -8.61586, -0.19208044, 24.928009, -16.573536, -0.55819046, 25.050386, -16.573536, -0.1920805, 25.050386, -8.61586, -0.19208044, 24.928009, -8.61586, -0.5581904, 24.928009, -16.573536, -0.55819046, 25.050386, -17.53112, -0.55819035, 24.038109, -16.573536, -0.55819046, 25.050386, -16.566906, -0.55819035, 24.06379, -16.573536, -0.55819046, 25.050386, -17.53112, -0.55819035, 24.038109, -17.534693, -0.55819035, 24.569588, -16.573536, -0.55819046, 25.050386, -17.534693, -0.55819035, 24.569588, -17.537725, -0.55819046, 25.020779, -8.61586, -0.5581904, 24.928009, -16.566906, -0.55819035, 24.06379, -16.573536, -0.55819046, 25.050386, -8.61586, -0.5581904, 24.928009, -8.610281, -0.55819035, 24.09806, -16.566906, -0.55819035, 24.06379, -17.533224, -0.19208038, 24.350975, -17.446188, -0.33171707, 24.51651, -17.534693, -0.55819035, 24.569588, -17.446188, -0.33171707, 24.51651, -17.535063, -0.19208044, 24.624569, -17.534693, -0.55819035, 24.569588, -17.53112, -0.55819035, 24.038109, -17.533224, -0.19208038, 24.350975, -17.534693, -0.55819035, 24.569588, -17.53112, -0.55819035, 24.038109, -17.53112, -0.19208038, 24.038109, -17.533224, -0.19208038, 24.350975, -17.535063, -0.19208044, 24.624569, -17.537725, -0.55819046, 25.020779, -17.534693, -0.55819035, 24.569588, -17.535063, -0.19208044, 24.624569, -17.537725, -0.19208044, 25.020779, -17.537725, -0.55819046, 25.020779, -8.61586, -0.19208044, 24.928009, -8.610281, -0.55819035, 24.09806, -8.61586, -0.5581904, 24.928009, -8.610281, -0.55819035, 24.09806, -8.61586, -0.19208044, 24.928009, -8.611677, -0.19208038, 24.305765, -8.610281, -0.55819035, 24.09806, -8.611677, -0.19208038, 24.305765, -8.610281, -0.28371692, 24.09806, -10.358263, -0.5581907, 27.454252, -17.457956, -0.1920808, 27.566252, -10.358263, -0.19208074, 27.454252, -10.358263, -0.5581907, 27.454252, -17.457956, -0.5581907, 27.566252, -17.457956, -0.1920808, 27.566252, -8.72983, -0.19208074, 27.471743, -10.358263, -0.5581907, 27.454252, -10.358263, -0.19208074, 27.454252, -8.72983, -0.19208074, 27.471743, -8.72983, -0.5581907, 27.471743, -10.358263, -0.5581907, 27.454252, -10.351484, -0.5581906, 26.445835, -17.457956, -0.5581907, 27.566252, -10.358263, -0.5581907, 27.454252, -10.351484, -0.5581906, 26.445835, -17.451353, -0.5581906, 26.58358, -17.457956, -0.5581907, 27.566252, -8.72983, -0.5581907, 27.471743, -10.351484, -0.5581906, 26.445835, -10.358263, -0.5581907, 27.454252, -8.72983, -0.5581907, 27.471743, -8.724255, -0.5581906, 26.642231, -10.351484, -0.5581906, 26.445835, -8.72983, -0.5581907, 27.471743, -8.724255, -0.19208068, 26.642231, -8.724255, -0.5581906, 26.642231, -8.72983, -0.5581907, 27.471743, -8.72983, -0.19208074, 27.471743, -8.724255, -0.19208068, 26.642231, -10.35318, -0.19208068, 26.69805, -10.351484, -0.28371716, 26.445835, -9.944677, -0.19208062, 26.494825, -10.35318, -0.19208068, 26.69805, -12.1264515, -0.19208062, 26.480164, -10.351484, -0.28371716, 26.445835, -10.35318, -0.19208068, 26.69805, -16.120235, -0.19208068, 26.557617, -12.1264515, -0.19208062, 26.480164, -9.944677, -0.19208062, 26.494825, -10.358263, -0.19208074, 27.454252, -10.35318, -0.19208068, 26.69805, -9.944677, -0.19208062, 26.494825, -8.72983, -0.19208074, 27.471743, -10.358263, -0.19208074, 27.454252, -8.72983, -0.19208074, 27.471743, -9.944677, -0.19208062, 26.494825, -8.724255, -0.19208068, 26.642231, -17.457956, -0.1920808, 27.566252, -10.35318, -0.19208068, 26.69805, -10.358263, -0.19208074, 27.454252, -10.35318, -0.19208068, 26.69805, -17.457956, -0.1920808, 27.566252, -16.120235, -0.19208068, 26.557617, -16.120235, -0.19208068, 26.557617, -17.457956, -0.1920808, 27.566252, -17.453001, -0.19208068, 26.828812, -16.120235, -0.19208068, 26.557617, -17.453001, -0.19208068, 26.828812, -17.451353, -0.28371722, 26.58358, -8.724255, -0.19208068, 26.642231, -10.351484, -0.5581906, 26.445835, -8.724255, -0.5581906, 26.642231, -10.351484, -0.5581906, 26.445835, -8.724255, -0.19208068, 26.642231, -9.944677, -0.19208062, 26.494825, -10.351484, -0.5581906, 26.445835, -9.944677, -0.19208062, 26.494825, -10.351484, -0.28371716, 26.445835, -10.351484, -0.28371716, 26.445835, -17.451353, -0.5581906, 26.58358, -10.351484, -0.5581906, 26.445835, -10.351484, -0.28371716, 26.445835, -16.120235, -0.19208068, 26.557617, -17.451353, -0.5581906, 26.58358, -17.451353, -0.5581906, 26.58358, -16.120235, -0.19208068, 26.557617, -17.451353, -0.28371722, 26.58358, -16.120235, -0.19208068, 26.557617, -10.351484, -0.28371716, 26.445835, -12.1264515, -0.19208062, 26.480164, -17.451353, -0.28371722, 26.58358, -17.457956, -0.5581907, 27.566252, -17.451353, -0.5581906, 26.58358, -17.457956, -0.5581907, 27.566252, -17.451353, -0.28371722, 26.58358, -17.453001, -0.19208068, 26.828812, -17.457956, -0.5581907, 27.566252, -17.453001, -0.19208068, 26.828812, -17.457956, -0.1920808, 27.566252, -13.511387, 0.0055903792, 44.578655, -17.616987, 0.37126416, 44.736523, -13.511387, 0.37126416, 44.578655, -13.511387, 0.0055903792, 44.578655, -17.616987, 0.0055903792, 44.736523, -17.616987, 0.37126416, 44.736523, -9.277809, 0.37126416, 44.792564, -13.511387, 0.0055903792, 44.578655, -13.511387, 0.37126416, 44.578655, -9.277809, 0.37126416, 44.792564, -9.277809, 0.0055903792, 44.792564, -13.511387, 0.0055903792, 44.578655, -13.50481, 0.0055904984, 43.59991, -17.616987, 0.0055903792, 44.736523, -13.511387, 0.0055903792, 44.578655, -13.50481, 0.0055904984, 43.59991, -17.61141, 0.0055904984, 43.906574, -17.616987, 0.0055903792, 44.736523, -13.511387, 0.0055903792, 44.578655, -9.270195, 0.0055904984, 43.65935, -13.50481, 0.0055904984, 43.59991, -13.511387, 0.0055903792, 44.578655, -9.274065, 0.005590439, 44.23534, -9.270195, 0.0055904984, 43.65935, -9.274065, 0.005590439, 44.23534, -13.511387, 0.0055903792, 44.578655, -9.277809, 0.0055903792, 44.792564, -17.61141, 0.0055904984, 43.906574, -17.616987, 0.37126416, 44.736523, -17.616987, 0.0055903792, 44.736523, -17.61141, 0.0055904984, 43.906574, -17.61141, 0.37126416, 43.906574, -17.616987, 0.37126416, 44.736523, -9.274517, 0.37126416, 44.30254, -9.433565, 0.26217288, 44.20372, -9.274065, 0.005590439, 44.23534, -13.506453, 0.37126416, 43.84427, -13.50481, 0.28006393, 43.59991, -12.446262, 0.37126428, 43.61444, -13.506453, 0.37126416, 43.84427, -14.531677, 0.37126428, 43.676357, -13.50481, 0.28006393, 43.59991, -13.506453, 0.37126416, 43.84427, -17.61141, 0.37126416, 43.906574, -14.531677, 0.37126428, 43.676357, -13.506453, 0.37126416, 43.84427, -17.616987, 0.37126416, 44.736523, -17.61141, 0.37126416, 43.906574, -17.616987, 0.37126416, 44.736523, -13.506453, 0.37126416, 43.84427, -13.511387, 0.37126416, 44.578655, -12.446262, 0.37126428, 43.61444, -13.511387, 0.37126416, 44.578655, -13.506453, 0.37126416, 43.84427, -13.511387, 0.37126416, 44.578655, -9.274517, 0.37126416, 44.30254, -9.277809, 0.37126416, 44.792564, -13.511387, 0.37126416, 44.578655, -10.001682, 0.37126416, 44.19729, -9.274517, 0.37126416, 44.30254, -12.446262, 0.37126428, 43.61444, -10.001682, 0.37126416, 44.19729, -13.511387, 0.37126416, 44.578655, -9.433565, 0.26217288, 44.20372, -9.274517, 0.37126416, 44.30254, -10.001682, 0.37126416, 44.19729, -9.272657, 0.37126416, 44.02589, -9.433565, 0.26217288, 44.20372, -10.001682, 0.37126416, 44.19729, -10.001682, 0.37126416, 44.19729, -12.446262, 0.37126428, 43.61444, -9.272657, 0.37126416, 44.02589, -9.272657, 0.37126416, 44.02589, -12.446262, 0.37126428, 43.61444, -9.270195, 0.37126428, 43.65935, -9.277809, 0.0055903792, 44.792564, -9.274517, 0.37126416, 44.30254, -9.274065, 0.005590439, 44.23534, -9.277809, 0.0055903792, 44.792564, -9.277809, 0.37126416, 44.792564, -9.274517, 0.37126416, 44.30254, -9.272657, 0.37126416, 44.02589, -9.270195, 0.0055904984, 43.65935, -9.274065, 0.005590439, 44.23534, -9.272657, 0.37126416, 44.02589, -9.270195, 0.37126428, 43.65935, -9.270195, 0.0055904984, 43.65935, -9.433565, 0.26217288, 44.20372, -9.272657, 0.37126416, 44.02589, -9.274065, 0.005590439, 44.23534, -9.270195, 0.37126428, 43.65935, -13.50481, 0.0055904984, 43.59991, -9.270195, 0.0055904984, 43.65935, -13.50481, 0.0055904984, 43.59991, -9.270195, 0.37126428, 43.65935, -12.446262, 0.37126428, 43.61444, -13.50481, 0.0055904984, 43.59991, -12.446262, 0.37126428, 43.61444, -13.50481, 0.28006393, 43.59991, -13.50481, 0.28006393, 43.59991, -17.61141, 0.0055904984, 43.906574, -13.50481, 0.0055904984, 43.59991, -13.50481, 0.28006393, 43.59991, -17.61141, 0.37126416, 43.906574, -17.61141, 0.0055904984, 43.906574, -17.61141, 0.37126416, 43.906574, -13.50481, 0.28006393, 43.59991, -14.531677, 0.37126428, 43.676357, -16.691502, 0.37126452, 41.174942, -17.592882, 0.0055907965, 41.149685, -16.691502, 0.0055907965, 41.174942, -16.691502, 0.37126452, 41.174942, -17.592882, 0.37126452, 41.149685, -17.592882, 0.0055907965, 41.149685, -16.691502, 0.0055907965, 41.174942, -11.113045, 0.37126452, 41.198032, -16.691502, 0.37126452, 41.174942, -16.691502, 0.0055907965, 41.174942, -9.253704, 0.28006417, 41.20573, -11.113045, 0.37126452, 41.198032, -9.253704, 0.28006417, 41.20573, -16.691502, 0.0055907965, 41.174942, -9.253704, 0.0055907965, 41.20573, -17.599485, 0.3712644, 42.132355, -16.691502, 0.37126452, 41.174942, -16.698133, 0.3712644, 42.16154, -11.113045, 0.37126452, 41.198032, -16.698133, 0.3712644, 42.16154, -16.691502, 0.37126452, 41.174942, -11.113045, 0.37126452, 41.198032, -9.259279, 0.3712644, 42.03524, -16.698133, 0.3712644, 42.16154, -9.259279, 0.3712644, 42.03524, -11.113045, 0.37126452, 41.198032, -9.255098, 0.37126452, 41.413, -9.255098, 0.37126452, 41.413, -11.113045, 0.37126452, 41.198032, -9.253704, 0.28006417, 41.20573, -16.691502, 0.37126452, 41.174942, -17.599485, 0.3712644, 42.132355, -17.115143, 0.37126452, 41.617634, -17.115143, 0.37126452, 41.617634, -17.599485, 0.3712644, 42.132355, -17.596823, 0.37126452, 41.736145, -17.596823, 0.37126452, 41.736145, -17.513622, 0.23162776, 41.628048, -17.115143, 0.37126452, 41.617634, -17.513622, 0.23162776, 41.628048, -17.594984, 0.37126452, 41.46255, -17.115143, 0.37126452, 41.617634, -16.691502, 0.37126452, 41.174942, -17.115143, 0.37126452, 41.617634, -17.594984, 0.37126452, 41.46255, -16.691502, 0.37126452, 41.174942, -17.594984, 0.37126452, 41.46255, -17.592882, 0.37126452, 41.149685, -16.698133, 0.0055906773, 42.16154, -17.599485, 0.3712644, 42.132355, -16.698133, 0.3712644, 42.16154, -16.698133, 0.0055906773, 42.16154, -17.599485, 0.0055906773, 42.132355, -17.599485, 0.3712644, 42.132355, -9.259279, 0.3712644, 42.03524, -16.698133, 0.0055906773, 42.16154, -16.698133, 0.3712644, 42.16154, -9.259279, 0.3712644, 42.03524, -9.259279, 0.005590737, 42.03524, -16.698133, 0.0055906773, 42.16154, -17.592882, 0.0055907965, 41.149685, -16.698133, 0.0055906773, 42.16154, -16.691502, 0.0055907965, 41.174942, -16.698133, 0.0055906773, 42.16154, -17.592882, 0.0055907965, 41.149685, -17.59645, 0.005590737, 41.68073, -16.698133, 0.0055906773, 42.16154, -17.59645, 0.005590737, 41.68073, -17.599485, 0.0055906773, 42.132355, -9.259279, 0.005590737, 42.03524, -16.691502, 0.0055907965, 41.174942, -16.698133, 0.0055906773, 42.16154, -9.259279, 0.005590737, 42.03524, -9.253704, 0.0055907965, 41.20573, -16.691502, 0.0055907965, 41.174942, -17.594984, 0.37126452, 41.46255, -17.513622, 0.23162776, 41.628048, -17.59645, 0.005590737, 41.68073, -17.513622, 0.23162776, 41.628048, -17.596823, 0.37126452, 41.736145, -17.59645, 0.005590737, 41.68073, -17.592882, 0.0055907965, 41.149685, -17.594984, 0.37126452, 41.46255, -17.59645, 0.005590737, 41.68073, -17.592882, 0.0055907965, 41.149685, -17.592882, 0.37126452, 41.149685, -17.594984, 0.37126452, 41.46255, -17.596823, 0.37126452, 41.736145, -17.599485, 0.0055906773, 42.132355, -17.59645, 0.005590737, 41.68073, -17.596823, 0.37126452, 41.736145, -17.599485, 0.3712644, 42.132355, -17.599485, 0.0055906773, 42.132355, -9.259279, 0.3712644, 42.03524, -9.253704, 0.0055907965, 41.20573, -9.259279, 0.005590737, 42.03524, -9.253704, 0.0055907965, 41.20573, -9.259279, 0.3712644, 42.03524, -9.255098, 0.37126452, 41.413, -9.253704, 0.0055907965, 41.20573, -9.255098, 0.37126452, 41.413, -9.253704, 0.28006417, 41.20573, -9.466791, -0.19077104, 22.980423, -9.698496, -0.09826177, 22.978865, -9.698496, -0.19077104, 22.978865, -9.466791, -0.19077104, 22.980423, -9.466791, -0.09826177, 22.980423, -9.698496, -0.09826177, 22.978865, -9.466791, -0.09826177, 22.980423, -9.864088, -0.098264754, 47.618515, -9.698496, -0.09826177, 22.978865, -9.466791, -0.09826177, 22.980423, -9.632383, -0.098264754, 47.62007, -9.864088, -0.098264754, 47.618515, -9.632383, -0.098264754, 47.62007, -9.864088, -0.19077402, 47.618515, -9.864088, -0.098264754, 47.618515, -9.632383, -0.098264754, 47.62007, -9.632383, -0.19077402, 47.62007, -9.864088, -0.19077402, 47.618515, -9.632383, -0.19077402, 47.62007, -9.698496, -0.19077104, 22.978865, -9.864088, -0.19077402, 47.618515, -9.632383, -0.19077402, 47.62007, -9.466791, -0.19077104, 22.980423, -9.698496, -0.19077104, 22.978865, -9.698496, -0.19077104, 22.978865, -9.864088, -0.098264754, 47.618515, -9.864088, -0.19077402, 47.618515, -9.698496, -0.19077104, 22.978865, -9.698496, -0.09826177, 22.978865, -9.864088, -0.098264754, 47.618515, -9.632383, -0.19077402, 47.62007, -9.466791, -0.09826177, 22.980423, -9.466791, -0.19077104, 22.980423, -9.632383, -0.19077402, 47.62007, -9.632383, -0.098264754, 47.62007, -9.466791, -0.09826177, 22.980423, -10.65929, 0.00559026, 45.949276, -17.916506, 0.37126392, 46.05978, -10.65929, 0.37126392, 45.949276, -10.65929, 0.00559026, 45.949276, -17.916506, 0.0055902004, 46.05978, -17.916506, 0.37126392, 46.05978, -8.99464, 0.37126392, 45.967007, -10.65929, 0.00559026, 45.949276, -10.65929, 0.37126392, 45.949276, -8.99464, 0.37126392, 45.967007, -8.99464, 0.00559026, 45.967007, -10.65929, 0.00559026, 45.949276, -10.65251, 0.0055903792, 44.940422, -17.916506, 0.0055902004, 46.05978, -10.65929, 0.00559026, 45.949276, -10.65251, 0.0055903792, 44.940422, -17.909903, 0.0055903196, 45.07711, -17.916506, 0.0055902004, 46.05978, -8.99464, 0.00559026, 45.967007, -10.65251, 0.0055903792, 44.940422, -10.65929, 0.00559026, 45.949276, -8.99464, 0.00559026, 45.967007, -8.989063, 0.0055903196, 45.137062, -10.65251, 0.0055903792, 44.940422, -8.99464, 0.00559026, 45.967007, -8.989063, 0.37126404, 45.137062, -8.989063, 0.0055903196, 45.137062, -8.99464, 0.00559026, 45.967007, -8.99464, 0.37126392, 45.967007, -8.989063, 0.37126404, 45.137062, -10.654205, 0.37126404, 45.192635, -10.65251, 0.2800637, 44.940422, -10.236538, 0.37126404, 44.98947, -10.654205, 0.37126404, 45.192635, -12.466749, 0.37126404, 44.974487, -10.65251, 0.2800637, 44.940422, -10.654205, 0.37126404, 45.192635, -16.549116, 0.37126404, 45.05178, -12.466749, 0.37126404, 44.974487, -10.236538, 0.37126404, 44.98947, -10.65929, 0.37126392, 45.949276, -10.654205, 0.37126404, 45.192635, -10.236538, 0.37126404, 44.98947, -8.99464, 0.37126392, 45.967007, -10.65929, 0.37126392, 45.949276, -8.99464, 0.37126392, 45.967007, -10.236538, 0.37126404, 44.98947, -8.989063, 0.37126404, 45.137062, -17.916506, 0.37126392, 46.05978, -10.654205, 0.37126404, 45.192635, -10.65929, 0.37126392, 45.949276, -10.654205, 0.37126404, 45.192635, -17.916506, 0.37126392, 46.05978, -16.549116, 0.37126404, 45.05178, -16.549116, 0.37126404, 45.05178, -17.916506, 0.37126392, 46.05978, -17.911556, 0.37126404, 45.32321, -16.549116, 0.37126404, 45.05178, -17.911556, 0.37126404, 45.32321, -17.909903, 0.2800637, 45.07711, -8.989063, 0.37126404, 45.137062, -10.65251, 0.0055903792, 44.940422, -8.989063, 0.0055903196, 45.137062, -10.65251, 0.0055903792, 44.940422, -8.989063, 0.37126404, 45.137062, -10.236538, 0.37126404, 44.98947, -10.65251, 0.0055903792, 44.940422, -10.236538, 0.37126404, 44.98947, -10.65251, 0.2800637, 44.940422, -10.65251, 0.2800637, 44.940422, -17.909903, 0.0055903196, 45.07711, -10.65251, 0.0055903792, 44.940422, -10.65251, 0.2800637, 44.940422, -16.549116, 0.37126404, 45.05178, -17.909903, 0.0055903196, 45.07711, -17.909903, 0.0055903196, 45.07711, -16.549116, 0.37126404, 45.05178, -17.909903, 0.2800637, 45.07711, -16.549116, 0.37126404, 45.05178, -10.65251, 0.2800637, 44.940422, -12.466749, 0.37126404, 44.974487, -17.909903, 0.2800637, 45.07711, -17.916506, 0.0055902004, 46.05978, -17.909903, 0.0055903196, 45.07711, -17.916506, 0.0055902004, 46.05978, -17.909903, 0.2800637, 45.07711, -17.911556, 0.37126404, 45.32321, -17.916506, 0.0055902004, 46.05978, -17.911556, 0.37126404, 45.32321, -17.916506, 0.37126392, 46.05978, -9.086136, -3.458273, 47.63378, -9.862558, 1.270175, 47.455757, -9.690924, -3.458273, 47.629715, -9.086136, -3.458273, 47.63378, -8.912178, 1.270175, 47.462143, -9.862558, 1.270175, 47.455757, -8.912178, 1.270175, 47.462143, -9.868942, 1.2701747, 48.4057, -9.862558, 1.270175, 47.455757, -8.912178, 1.270175, 47.462143, -8.918562, 1.2701747, 48.41209, -9.868942, 1.2701747, 48.4057, -8.918562, 1.2701747, 48.41209, -9.694984, -3.458273, 48.234066, -9.868942, 1.2701747, 48.4057, -8.918562, 1.2701747, 48.41209, -9.090197, -3.458273, 48.238132, -9.694984, -3.458273, 48.234066, -9.090197, -3.458273, 48.238132, -9.690924, -3.458273, 47.629715, -9.694984, -3.458273, 48.234066, -9.090197, -3.458273, 48.238132, -9.086136, -3.458273, 47.63378, -9.690924, -3.458273, 47.629715, -9.690924, -3.458273, 47.629715, -9.868942, 1.2701747, 48.4057, -9.694984, -3.458273, 48.234066, -9.690924, -3.458273, 47.629715, -9.862558, 1.270175, 47.455757, -9.868942, 1.2701747, 48.4057, -9.090197, -3.458273, 48.238132, -8.912178, 1.270175, 47.462143, -9.086136, -3.458273, 47.63378, -9.090197, -3.458273, 48.238132, -8.918562, 1.2701747, 48.41209, -8.912178, 1.270175, 47.462143, -16.72666, 0.37126392, 46.406403, -17.628038, 0.0055902004, 46.380707, -16.72666, 0.0055902004, 46.406403, -16.72666, 0.37126392, 46.406403, -17.628038, 0.37126392, 46.380707, -17.628038, 0.0055902004, 46.380707, -16.72666, 0.0055902004, 46.406403, -11.148199, 0.37126392, 46.429054, -16.72666, 0.37126392, 46.406403, -16.72666, 0.0055902004, 46.406403, -9.28886, 0.28006357, 46.436752, -11.148199, 0.37126392, 46.429054, -9.28886, 0.28006357, 46.436752, -16.72666, 0.0055902004, 46.406403, -9.28886, 0.0055902004, 46.436752, -17.634645, 0.3712638, 47.363815, -16.72666, 0.37126392, 46.406403, -16.733288, 0.3712638, 47.392563, -11.148199, 0.37126392, 46.429054, -16.733288, 0.3712638, 47.392563, -16.72666, 0.37126392, 46.406403, -11.148199, 0.37126392, 46.429054, -9.294437, 0.3712638, 47.266697, -16.733288, 0.3712638, 47.392563, -9.294437, 0.3712638, 47.266697, -11.148199, 0.37126392, 46.429054, -9.2902565, 0.37126392, 46.644455, -9.2902565, 0.37126392, 46.644455, -11.148199, 0.37126392, 46.429054, -9.28886, 0.28006357, 46.436752, -16.72666, 0.37126392, 46.406403, -17.634645, 0.3712638, 47.363815, -17.150305, 0.3712638, 46.84953, -17.150305, 0.3712638, 46.84953, -17.634645, 0.3712638, 47.363815, -17.631985, 0.3712638, 46.96804, -17.631985, 0.3712638, 46.96804, -17.548779, 0.23162705, 46.85907, -17.150305, 0.3712638, 46.84953, -17.548779, 0.23162705, 46.85907, -17.63014, 0.37126392, 46.693573, -17.150305, 0.3712638, 46.84953, -16.72666, 0.37126392, 46.406403, -17.150305, 0.3712638, 46.84953, -17.63014, 0.37126392, 46.693573, -16.72666, 0.37126392, 46.406403, -17.63014, 0.37126392, 46.693573, -17.628038, 0.37126392, 46.380707, -16.733288, 0.005590081, 47.392563, -17.634645, 0.3712638, 47.363815, -16.733288, 0.3712638, 47.392563, -16.733288, 0.005590081, 47.392563, -17.634645, 0.005590081, 47.363815, -17.634645, 0.3712638, 47.363815, -9.294437, 0.3712638, 47.266697, -16.733288, 0.005590081, 47.392563, -16.733288, 0.3712638, 47.392563, -9.294437, 0.3712638, 47.266697, -9.294437, 0.005590081, 47.266697, -16.733288, 0.005590081, 47.392563, -17.628038, 0.0055902004, 46.380707, -16.733288, 0.005590081, 47.392563, -16.72666, 0.0055902004, 46.406403, -16.733288, 0.005590081, 47.392563, -17.628038, 0.0055902004, 46.380707, -17.631613, 0.005590141, 46.912624, -16.733288, 0.005590081, 47.392563, -17.631613, 0.005590141, 46.912624, -17.634645, 0.005590081, 47.363815, -9.294437, 0.005590081, 47.266697, -16.72666, 0.0055902004, 46.406403, -16.733288, 0.005590081, 47.392563, -9.294437, 0.005590081, 47.266697, -9.28886, 0.0055902004, 46.436752, -16.72666, 0.0055902004, 46.406403, -17.63014, 0.37126392, 46.693573, -17.548779, 0.23162705, 46.85907, -17.631613, 0.005590141, 46.912624, -17.548779, 0.23162705, 46.85907, -17.631985, 0.3712638, 46.96804, -17.631613, 0.005590141, 46.912624, -17.628038, 0.0055902004, 46.380707, -17.63014, 0.37126392, 46.693573, -17.631613, 0.005590141, 46.912624, -17.628038, 0.0055902004, 46.380707, -17.628038, 0.37126392, 46.380707, -17.63014, 0.37126392, 46.693573, -17.631985, 0.3712638, 46.96804, -17.634645, 0.005590081, 47.363815, -17.631613, 0.005590141, 46.912624, -17.631985, 0.3712638, 46.96804, -17.634645, 0.3712638, 47.363815, -17.634645, 0.005590081, 47.363815, -9.294437, 0.3712638, 47.266697, -9.28886, 0.0055902004, 46.436752, -9.294437, 0.005590081, 47.266697, -9.28886, 0.0055902004, 46.436752, -9.294437, 0.3712638, 47.266697, -9.2902565, 0.37126392, 46.644455, -9.28886, 0.0055902004, 46.436752, -9.2902565, 0.37126392, 46.644455, -9.28886, 0.28006357, 46.436752, -16.91135, 0.37126464, 39.993507, -17.875563, 0.0055909753, 39.967392, -16.91135, 0.0055909753, 39.993507, -16.91135, 0.37126464, 39.993507, -17.875563, 0.37126464, 39.967392, -17.875563, 0.0055909753, 39.967392, -16.91135, 0.0055909753, 39.993507, -10.943661, 0.37126464, 40.01878, -16.91135, 0.37126464, 39.993507, -16.91135, 0.0055909753, 39.993507, -8.954723, 0.28006428, 40.027344, -10.943661, 0.37126464, 40.01878, -8.954723, 0.28006428, 40.027344, -16.91135, 0.0055909753, 39.993507, -8.954723, 0.0055909753, 40.027344, -17.882166, 0.37126452, 40.95006, -16.91135, 0.37126464, 39.993507, -16.917978, 0.37126452, 40.97967, -10.943661, 0.37126464, 40.01878, -16.917978, 0.37126452, 40.97967, -16.91135, 0.37126464, 39.993507, -10.943661, 0.37126464, 40.01878, -8.9603, 0.37126452, 40.857292, -16.917978, 0.37126452, 40.97967, -8.9603, 0.37126452, 40.857292, -10.943661, 0.37126464, 40.01878, -8.95612, 0.37126464, 40.23505, -8.95612, 0.37126464, 40.23505, -10.943661, 0.37126464, 40.01878, -8.954723, 0.28006428, 40.027344, -16.91135, 0.37126464, 39.993507, -17.882166, 0.37126452, 40.95006, -17.364662, 0.37126464, 40.436, -17.364662, 0.37126464, 40.436, -17.882166, 0.37126452, 40.95006, -17.879507, 0.37126464, 40.55429, -17.879507, 0.37126464, 40.55429, -17.79063, 0.23162788, 40.445793, -17.364662, 0.37126464, 40.436, -17.79063, 0.23162788, 40.445793, -17.877665, 0.37126464, 40.28026, -17.364662, 0.37126464, 40.436, -16.91135, 0.37126464, 39.993507, -17.364662, 0.37126464, 40.436, -17.877665, 0.37126464, 40.28026, -16.91135, 0.37126464, 39.993507, -17.877665, 0.37126464, 40.28026, -17.875563, 0.37126464, 39.967392, -16.917978, 0.005590856, 40.97967, -17.882166, 0.37126452, 40.95006, -16.917978, 0.37126452, 40.97967, -16.917978, 0.005590856, 40.97967, -17.882166, 0.005590856, 40.95006, -17.882166, 0.37126452, 40.95006, -8.9603, 0.37126452, 40.857292, -16.917978, 0.005590856, 40.97967, -16.917978, 0.37126452, 40.97967, -8.9603, 0.37126452, 40.857292, -8.9603, 0.005590856, 40.857292, -16.917978, 0.005590856, 40.97967, -17.875563, 0.0055909753, 39.967392, -16.917978, 0.005590856, 40.97967, -16.91135, 0.0055909753, 39.993507, -16.917978, 0.005590856, 40.97967, -17.875563, 0.0055909753, 39.967392, -17.879133, 0.0055909157, 40.49887, -16.917978, 0.005590856, 40.97967, -17.879133, 0.0055909157, 40.49887, -17.882166, 0.005590856, 40.95006, -8.9603, 0.005590856, 40.857292, -16.91135, 0.0055909753, 39.993507, -16.917978, 0.005590856, 40.97967, -8.9603, 0.005590856, 40.857292, -8.954723, 0.0055909753, 40.027344, -16.91135, 0.0055909753, 39.993507, -17.877665, 0.37126464, 40.28026, -17.79063, 0.23162788, 40.445793, -17.879133, 0.0055909157, 40.49887, -17.79063, 0.23162788, 40.445793, -17.879507, 0.37126464, 40.55429, -17.879133, 0.0055909157, 40.49887, -17.875563, 0.0055909753, 39.967392, -17.877665, 0.37126464, 40.28026, -17.879133, 0.0055909157, 40.49887, -17.875563, 0.0055909753, 39.967392, -17.875563, 0.37126464, 39.967392, -17.877665, 0.37126464, 40.28026, -17.879507, 0.37126464, 40.55429, -17.882166, 0.005590856, 40.95006, -17.879133, 0.0055909157, 40.49887, -17.879507, 0.37126464, 40.55429, -17.882166, 0.37126452, 40.95006, -17.882166, 0.005590856, 40.95006, -8.9603, 0.37126452, 40.857292, -8.954723, 0.0055909753, 40.027344, -8.9603, 0.005590856, 40.857292, -8.954723, 0.0055909753, 40.027344, -8.9603, 0.37126452, 40.857292, -8.95612, 0.37126464, 40.23505, -8.954723, 0.0055909753, 40.027344, -8.95612, 0.37126464, 40.23505, -8.954723, 0.28006428, 40.027344, -13.4819145, 0.005591035, 39.478943, -17.873325, 0.37126476, 39.634453, -13.4819145, 0.37126476, 39.478943, -13.4819145, 0.005591035, 39.478943, -17.873325, 0.0055909753, 39.634453, -17.873325, 0.37126476, 39.634453, -8.952486, 0.37126476, 39.694405, -13.4819145, 0.005591035, 39.478943, -13.4819145, 0.37126476, 39.478943, -8.952486, 0.37126476, 39.694405, -8.952486, 0.0055909753, 39.694405, -13.4819145, 0.005591035, 39.478943, -13.47533, 0.005591154, 38.49933, -17.873325, 0.0055909753, 39.634453, -13.4819145, 0.005591035, 39.478943, -13.47533, 0.005591154, 38.49933, -17.867746, 0.0055910945, 38.80451, -17.873325, 0.0055909753, 39.634453, -13.4819145, 0.005591035, 39.478943, -8.944866, 0.005591154, 38.560757, -13.47533, 0.005591154, 38.49933, -13.4819145, 0.005591035, 39.478943, -8.948738, 0.005591035, 39.136745, -8.944866, 0.005591154, 38.560757, -8.948738, 0.005591035, 39.136745, -13.4819145, 0.005591035, 39.478943, -8.952486, 0.0055909753, 39.694405, -17.867746, 0.0055910945, 38.80451, -17.873325, 0.37126476, 39.634453, -17.873325, 0.0055909753, 39.634453, -17.867746, 0.0055910945, 38.80451, -17.867746, 0.37126476, 38.80451, -17.873325, 0.37126476, 39.634453, -8.949188, 0.37126476, 39.203945, -9.119587, 0.26217347, 39.105488, -8.948738, 0.005591035, 39.136745, -13.476976, 0.37126487, 38.74412, -13.47533, 0.28006452, 38.49933, -12.3426075, 0.37126487, 38.514793, -13.476976, 0.37126487, 38.74412, -14.573328, 0.37126487, 38.575733, -13.47533, 0.28006452, 38.49933, -13.476976, 0.37126487, 38.74412, -17.867746, 0.37126476, 38.80451, -14.573328, 0.37126487, 38.575733, -13.476976, 0.37126487, 38.74412, -17.873325, 0.37126476, 39.634453, -17.867746, 0.37126476, 38.80451, -17.873325, 0.37126476, 39.634453, -13.476976, 0.37126487, 38.74412, -13.4819145, 0.37126476, 39.478943, -12.3426075, 0.37126487, 38.514793, -13.4819145, 0.37126476, 39.478943, -13.476976, 0.37126487, 38.74412, -13.4819145, 0.37126476, 39.478943, -8.949188, 0.37126476, 39.203945, -8.952486, 0.37126476, 39.694405, -13.4819145, 0.37126476, 39.478943, -9.726973, 0.37126476, 39.09835, -8.949188, 0.37126476, 39.203945, -12.3426075, 0.37126487, 38.514793, -9.726973, 0.37126476, 39.09835, -13.4819145, 0.37126476, 39.478943, -9.119587, 0.26217347, 39.105488, -8.949188, 0.37126476, 39.203945, -9.726973, 0.37126476, 39.09835, -8.94733, 0.37126476, 38.927296, -9.119587, 0.26217347, 39.105488, -9.726973, 0.37126476, 39.09835, -9.726973, 0.37126476, 39.09835, -12.3426075, 0.37126487, 38.514793, -8.94733, 0.37126476, 38.927296, -8.94733, 0.37126476, 38.927296, -12.3426075, 0.37126487, 38.514793, -8.944866, 0.37126487, 38.560757, -8.952486, 0.0055909753, 39.694405, -8.949188, 0.37126476, 39.203945, -8.948738, 0.005591035, 39.136745, -8.952486, 0.0055909753, 39.694405, -8.952486, 0.37126476, 39.694405, -8.949188, 0.37126476, 39.203945, -8.94733, 0.37126476, 38.927296, -8.944866, 0.005591154, 38.560757, -8.948738, 0.005591035, 39.136745, -8.94733, 0.37126476, 38.927296, -8.944866, 0.37126487, 38.560757, -8.944866, 0.005591154, 38.560757, -9.119587, 0.26217347, 39.105488, -8.94733, 0.37126476, 38.927296, -8.948738, 0.005591035, 39.136745, -8.944866, 0.37126487, 38.560757, -13.47533, 0.005591154, 38.49933, -8.944866, 0.005591154, 38.560757, -13.47533, 0.005591154, 38.49933, -8.944866, 0.37126487, 38.560757, -12.3426075, 0.37126487, 38.514793, -13.47533, 0.005591154, 38.49933, -12.3426075, 0.37126487, 38.514793, -13.47533, 0.28006452, 38.49933, -13.47533, 0.28006452, 38.49933, -17.867746, 0.0055910945, 38.80451, -13.47533, 0.005591154, 38.49933, -13.47533, 0.28006452, 38.49933, -17.867746, 0.37126476, 38.80451, -17.867746, 0.0055910945, 38.80451, -17.867746, 0.37126476, 38.80451, -13.47533, 0.28006452, 38.49933, -14.573328, 0.37126487, 38.575733, -16.664772, 0.371265, 37.19757, -17.566149, 0.0055912733, 37.171875, -16.664772, 0.0055912733, 37.19757, -16.664772, 0.371265, 37.19757, -17.566149, 0.371265, 37.171875, -17.566149, 0.0055912733, 37.171875, -16.664772, 0.0055912733, 37.19757, -11.086311, 0.371265, 37.220222, -16.664772, 0.371265, 37.19757, -16.664772, 0.0055912733, 37.19757, -9.226971, 0.28006464, 37.227917, -11.086311, 0.371265, 37.220222, -9.226971, 0.28006464, 37.227917, -16.664772, 0.0055912733, 37.19757, -9.226971, 0.0055912733, 37.227917, -17.572756, 0.37126487, 38.154984, -16.664772, 0.371265, 37.19757, -16.6714, 0.37126487, 38.18373, -11.086311, 0.371265, 37.220222, -16.6714, 0.37126487, 38.18373, -16.664772, 0.371265, 37.19757, -11.086311, 0.371265, 37.220222, -9.23255, 0.37126487, 38.057865, -16.6714, 0.37126487, 38.18373, -9.23255, 0.37126487, 38.057865, -11.086311, 0.371265, 37.220222, -9.228367, 0.371265, 37.435623, -9.228367, 0.371265, 37.435623, -11.086311, 0.371265, 37.220222, -9.226971, 0.28006464, 37.227917, -16.664772, 0.371265, 37.19757, -17.572756, 0.37126487, 38.154984, -17.088415, 0.371265, 37.640697, -17.088415, 0.371265, 37.640697, -17.572756, 0.37126487, 38.154984, -17.570097, 0.371265, 37.75921, -17.570097, 0.371265, 37.75921, -17.486889, 0.23162824, 37.650238, -17.088415, 0.371265, 37.640697, -17.486889, 0.23162824, 37.650238, -17.56825, 0.371265, 37.48474, -17.088415, 0.371265, 37.640697, -16.664772, 0.371265, 37.19757, -17.088415, 0.371265, 37.640697, -17.56825, 0.371265, 37.48474, -16.664772, 0.371265, 37.19757, -17.56825, 0.371265, 37.48474, -17.566149, 0.371265, 37.171875, -16.6714, 0.005591154, 38.18373, -17.572756, 0.37126487, 38.154984, -16.6714, 0.37126487, 38.18373, -16.6714, 0.005591154, 38.18373, -17.572756, 0.005591154, 38.154984, -17.572756, 0.37126487, 38.154984, -9.23255, 0.37126487, 38.057865, -16.6714, 0.005591154, 38.18373, -16.6714, 0.37126487, 38.18373, -9.23255, 0.37126487, 38.057865, -9.23255, 0.0055912137, 38.057865, -16.6714, 0.005591154, 38.18373, -17.566149, 0.0055912733, 37.171875, -16.6714, 0.005591154, 38.18373, -16.664772, 0.0055912733, 37.19757, -16.6714, 0.005591154, 38.18373, -17.566149, 0.0055912733, 37.171875, -17.569721, 0.0055912137, 37.703354, -16.6714, 0.005591154, 38.18373, -17.569721, 0.0055912137, 37.703354, -17.572756, 0.005591154, 38.154984, -9.23255, 0.0055912137, 38.057865, -16.664772, 0.0055912733, 37.19757, -16.6714, 0.005591154, 38.18373, -9.23255, 0.0055912137, 38.057865, -9.226971, 0.0055912733, 37.227917, -16.664772, 0.0055912733, 37.19757, -17.56825, 0.371265, 37.48474, -17.486889, 0.23162824, 37.650238, -17.569721, 0.0055912137, 37.703354, -17.486889, 0.23162824, 37.650238, -17.570097, 0.371265, 37.75921, -17.569721, 0.0055912137, 37.703354, -17.566149, 0.0055912733, 37.171875, -17.56825, 0.371265, 37.48474, -17.569721, 0.0055912137, 37.703354, -17.566149, 0.0055912733, 37.171875, -17.566149, 0.371265, 37.171875, -17.56825, 0.371265, 37.48474, -17.570097, 0.371265, 37.75921, -17.572756, 0.005591154, 38.154984, -17.569721, 0.0055912137, 37.703354, -17.570097, 0.371265, 37.75921, -17.572756, 0.37126487, 38.154984, -17.572756, 0.005591154, 38.154984, -9.23255, 0.37126487, 38.057865, -9.226971, 0.0055912733, 37.227917, -9.23255, 0.0055912137, 38.057865, -9.226971, 0.0055912733, 37.227917, -9.23255, 0.37126487, 38.057865, -9.228367, 0.371265, 37.435623, -9.226971, 0.0055912733, 37.227917, -9.228367, 0.371265, 37.435623, -9.226971, 0.28006464, 37.227917, -16.810944, -3.458273, 47.519463, -17.46208, 1.270175, 47.59407, -17.54053, -3.458273, 47.51456, -16.810944, -3.458273, 47.519463, -16.890453, 1.270175, 47.59791, -17.46208, 1.270175, 47.59407, -16.890453, 1.270175, 47.59791, -17.465921, 1.270175, 48.165695, -17.46208, 1.270175, 47.59407, -16.890453, 1.270175, 47.59791, -16.894295, 1.270175, 48.16954, -17.465921, 1.270175, 48.165695, -16.894295, 1.270175, 48.16954, -17.545431, -3.458273, 48.24415, -17.465921, 1.270175, 48.165695, -16.894295, 1.270175, 48.16954, -16.815845, -3.458273, 48.24905, -17.545431, -3.458273, 48.24415, -16.815845, -3.458273, 48.24905, -17.54053, -3.458273, 47.51456, -17.545431, -3.458273, 48.24415, -16.815845, -3.458273, 48.24905, -16.810944, -3.458273, 47.519463, -17.54053, -3.458273, 47.51456, -17.54053, -3.458273, 47.51456, -17.465921, 1.270175, 48.165695, -17.545431, -3.458273, 48.24415, -17.54053, -3.458273, 47.51456, -17.46208, 1.270175, 47.59407, -17.465921, 1.270175, 48.165695, -16.815845, -3.458273, 48.24905, -16.890453, 1.270175, 47.59791, -16.810944, -3.458273, 47.519463, -16.815845, -3.458273, 48.24905, -16.894295, 1.270175, 48.16954, -16.890453, 1.270175, 47.59791, -9.055229, -3.45827, 22.581722, -9.785252, 1.2701778, 22.576817, -9.785252, -3.45827, 22.576817, -9.055229, -3.45827, 22.581722, -9.055229, 1.2701778, 22.581722, -9.785252, 1.2701778, 22.576817, -9.060133, -3.45827, 23.31131, -9.785252, -3.45827, 22.576817, -9.790155, -3.45827, 23.306404, -9.060133, -3.45827, 23.31131, -9.055229, -3.45827, 22.581722, -9.785252, -3.45827, 22.576817, -9.785252, -3.45827, 22.576817, -9.790155, 1.2701778, 23.306404, -9.790155, -3.45827, 23.306404, -9.785252, -3.45827, 22.576817, -9.785252, 1.2701778, 22.576817, -9.790155, 1.2701778, 23.306404, -9.060133, 0.08806592, 23.31131, -9.242529, 1.2701778, 23.310083, -9.058907, 1.2701778, 23.128912, -9.055229, -3.45827, 22.581722, -9.060133, 0.08806592, 23.31131, -9.058907, 1.2701778, 23.128912, -9.060133, 0.08806592, 23.31131, -9.055229, -3.45827, 22.581722, -9.060133, -3.45827, 23.31131, -9.055229, -3.45827, 22.581722, -9.058907, 1.2701778, 23.128912, -9.055229, 1.2701778, 22.581722, -9.058907, 1.2701778, 23.128912, -9.785252, 1.2701778, 22.576817, -9.055229, 1.2701778, 22.581722, -9.785252, 1.2701778, 22.576817, -9.058907, 1.2701778, 23.128912, -9.242529, 1.2701778, 23.310083, -9.785252, 1.2701778, 22.576817, -9.242529, 1.2701778, 23.310083, -9.790155, 1.2701778, 23.306404, -9.060133, -3.45827, 23.31131, -9.242529, 1.2701778, 23.310083, -9.060133, 0.08806592, 23.31131, -9.242529, 1.2701778, 23.310083, -9.060133, -3.45827, 23.31131, -9.790155, -3.45827, 23.306404, -9.242529, 1.2701778, 23.310083, -9.790155, -3.45827, 23.306404, -9.790155, 1.2701778, 23.306404, -13.449499, 0.0055915117, 35.369823, -17.5551, 0.37126523, 35.52769, -13.449499, 0.37126523, 35.369823, -13.449499, 0.0055915117, 35.369823, -17.5551, 0.0055915117, 35.52769, -17.5551, 0.37126523, 35.52769, -9.215921, 0.37126523, 35.583733, -13.449499, 0.0055915117, 35.369823, -13.449499, 0.37126523, 35.369823, -9.215921, 0.37126523, 35.583733, -9.215921, 0.005591452, 35.583733, -13.449499, 0.0055915117, 35.369823, -13.442921, 0.005591631, 34.39108, -17.5551, 0.0055915117, 35.52769, -13.449499, 0.0055915117, 35.369823, -13.442921, 0.005591631, 34.39108, -17.54952, 0.0055915713, 34.697742, -17.5551, 0.0055915117, 35.52769, -13.449499, 0.0055915117, 35.369823, -9.208305, 0.005591631, 34.45052, -13.442921, 0.005591631, 34.39108, -13.449499, 0.0055915117, 35.369823, -9.212177, 0.0055915713, 35.02651, -9.208305, 0.005591631, 34.45052, -9.212177, 0.0055915713, 35.02651, -13.449499, 0.0055915117, 35.369823, -9.215921, 0.005591452, 35.583733, -17.54952, 0.0055915713, 34.697742, -17.5551, 0.37126523, 35.52769, -17.5551, 0.0055915117, 35.52769, -17.54952, 0.0055915713, 34.697742, -17.54952, 0.37126535, 34.697742, -17.5551, 0.37126523, 35.52769, -9.212622, 0.37126523, 35.092834, -9.371677, 0.26217395, 34.99489, -9.212177, 0.0055915713, 35.02651, -13.444563, 0.37126535, 34.635437, -13.442921, 0.280065, 34.39108, -12.384375, 0.37126535, 34.40561, -13.444563, 0.37126535, 34.635437, -14.469788, 0.37126535, 34.467525, -13.442921, 0.280065, 34.39108, -13.444563, 0.37126535, 34.635437, -17.54952, 0.37126535, 34.697742, -14.469788, 0.37126535, 34.467525, -13.444563, 0.37126535, 34.635437, -17.5551, 0.37126523, 35.52769, -17.54952, 0.37126535, 34.697742, -17.5551, 0.37126523, 35.52769, -13.444563, 0.37126535, 34.635437, -13.449499, 0.37126523, 35.369823, -12.384375, 0.37126535, 34.40561, -13.449499, 0.37126523, 35.369823, -13.444563, 0.37126535, 34.635437, -13.449499, 0.37126523, 35.369823, -9.212622, 0.37126523, 35.092834, -9.215921, 0.37126523, 35.583733, -13.449499, 0.37126523, 35.369823, -9.939795, 0.37126523, 34.988453, -9.212622, 0.37126523, 35.092834, -12.384375, 0.37126535, 34.40561, -9.939795, 0.37126523, 34.988453, -13.449499, 0.37126523, 35.369823, -9.371677, 0.26217395, 34.99489, -9.212622, 0.37126523, 35.092834, -9.939795, 0.37126523, 34.988453, -9.21077, 0.37126535, 34.81706, -9.371677, 0.26217395, 34.99489, -9.939795, 0.37126523, 34.988453, -9.939795, 0.37126523, 34.988453, -12.384375, 0.37126535, 34.40561, -9.21077, 0.37126535, 34.81706, -9.21077, 0.37126535, 34.81706, -12.384375, 0.37126535, 34.40561, -9.208305, 0.37126535, 34.45052, -9.215921, 0.005591452, 35.583733, -9.212622, 0.37126523, 35.092834, -9.212177, 0.0055915713, 35.02651, -9.215921, 0.005591452, 35.583733, -9.215921, 0.37126523, 35.583733, -9.212622, 0.37126523, 35.092834, -9.21077, 0.37126535, 34.81706, -9.208305, 0.005591631, 34.45052, -9.212177, 0.0055915713, 35.02651, -9.21077, 0.37126535, 34.81706, -9.208305, 0.37126535, 34.45052, -9.208305, 0.005591631, 34.45052, -9.371677, 0.26217395, 34.99489, -9.21077, 0.37126535, 34.81706, -9.212177, 0.0055915713, 35.02651, -9.208305, 0.37126535, 34.45052, -13.442921, 0.005591631, 34.39108, -9.208305, 0.005591631, 34.45052, -13.442921, 0.005591631, 34.39108, -9.208305, 0.37126535, 34.45052, -12.384375, 0.37126535, 34.40561, -13.442921, 0.005591631, 34.39108, -12.384375, 0.37126535, 34.40561, -13.442921, 0.280065, 34.39108, -13.442921, 0.280065, 34.39108, -17.54952, 0.0055915713, 34.697742, -13.442921, 0.005591631, 34.39108, -13.442921, 0.280065, 34.39108, -17.54952, 0.37126535, 34.697742, -17.54952, 0.0055915713, 34.697742, -17.54952, 0.37126535, 34.697742, -13.442921, 0.280065, 34.39108, -14.469788, 0.37126535, 34.467525, -10.702703, 0.005590558, 43.383537, -17.802399, 0.37126428, 43.495537, -10.702703, 0.37126428, 43.383537, -10.702703, 0.005590558, 43.383537, -17.802399, 0.005590558, 43.495537, -17.802399, 0.37126428, 43.495537, -9.074274, 0.37126428, 43.401463, -10.702703, 0.005590558, 43.383537, -10.702703, 0.37126428, 43.383537, -9.074274, 0.37126428, 43.401463, -9.074274, 0.005590558, 43.401463, -10.702703, 0.005590558, 43.383537, -10.695927, 0.0055906773, 42.375122, -17.802399, 0.005590558, 43.495537, -10.702703, 0.005590558, 43.383537, -10.695927, 0.0055906773, 42.375122, -17.795794, 0.0055906773, 42.512863, -17.802399, 0.005590558, 43.495537, -9.074274, 0.005590558, 43.401463, -10.695927, 0.0055906773, 42.375122, -10.702703, 0.005590558, 43.383537, -9.074274, 0.005590558, 43.401463, -9.068697, 0.0055906773, 42.571514, -10.695927, 0.0055906773, 42.375122, -9.074274, 0.005590558, 43.401463, -9.068697, 0.3712644, 42.571514, -9.068697, 0.0055906773, 42.571514, -9.074274, 0.005590558, 43.401463, -9.074274, 0.37126428, 43.401463, -9.068697, 0.3712644, 42.571514, -10.697622, 0.3712644, 42.627335, -10.695927, 0.28006405, 42.375122, -10.289118, 0.3712644, 42.42411, -10.697622, 0.3712644, 42.627335, -12.470892, 0.3712644, 42.409447, -10.695927, 0.28006405, 42.375122, -10.697622, 0.3712644, 42.627335, -16.464678, 0.3712644, 42.4869, -12.470892, 0.3712644, 42.409447, -10.289118, 0.3712644, 42.42411, -10.702703, 0.37126428, 43.383537, -10.697622, 0.3712644, 42.627335, -10.289118, 0.3712644, 42.42411, -9.074274, 0.37126428, 43.401463, -10.702703, 0.37126428, 43.383537, -9.074274, 0.37126428, 43.401463, -10.289118, 0.3712644, 42.42411, -9.068697, 0.3712644, 42.571514, -17.802399, 0.37126428, 43.495537, -10.697622, 0.3712644, 42.627335, -10.702703, 0.37126428, 43.383537, -10.697622, 0.3712644, 42.627335, -17.802399, 0.37126428, 43.495537, -16.464678, 0.3712644, 42.4869, -16.464678, 0.3712644, 42.4869, -17.802399, 0.37126428, 43.495537, -17.797441, 0.3712644, 42.758095, -16.464678, 0.3712644, 42.4869, -17.797441, 0.3712644, 42.758095, -17.795794, 0.28006405, 42.512863, -9.068697, 0.3712644, 42.571514, -10.695927, 0.0055906773, 42.375122, -9.068697, 0.0055906773, 42.571514, -10.695927, 0.0055906773, 42.375122, -9.068697, 0.3712644, 42.571514, -10.289118, 0.3712644, 42.42411, -10.695927, 0.0055906773, 42.375122, -10.289118, 0.3712644, 42.42411, -10.695927, 0.28006405, 42.375122, -10.695927, 0.28006405, 42.375122, -17.795794, 0.0055906773, 42.512863, -10.695927, 0.0055906773, 42.375122, -10.695927, 0.28006405, 42.375122, -16.464678, 0.3712644, 42.4869, -17.795794, 0.0055906773, 42.512863, -17.795794, 0.0055906773, 42.512863, -16.464678, 0.3712644, 42.4869, -17.795794, 0.28006405, 42.512863, -16.464678, 0.3712644, 42.4869, -10.695927, 0.28006405, 42.375122, -12.470892, 0.3712644, 42.409447, -17.795794, 0.28006405, 42.512863, -17.802399, 0.005590558, 43.495537, -17.795794, 0.0055906773, 42.512863, -17.802399, 0.005590558, 43.495537, -17.795794, 0.28006405, 42.512863, -17.797441, 0.3712644, 42.758095, -17.802399, 0.005590558, 43.495537, -17.797441, 0.3712644, 42.758095, -17.802399, 0.37126428, 43.495537, -10.597399, 0.005591333, 36.740005, -17.854618, 0.371265, 36.850945, -10.597399, 0.3712651, 36.740005, -10.597399, 0.005591333, 36.740005, -17.854618, 0.005591333, 36.850945, -17.854618, 0.371265, 36.850945, -8.932753, 0.3712651, 36.758175, -10.597399, 0.005591333, 36.740005, -10.597399, 0.3712651, 36.740005, -8.932753, 0.3712651, 36.758175, -8.932753, 0.005591333, 36.758175, -10.597399, 0.005591333, 36.740005, -10.590622, 0.005591452, 35.73159, -17.854618, 0.005591333, 36.850945, -10.597399, 0.005591333, 36.740005, -10.590622, 0.005591452, 35.73159, -17.848013, 0.005591452, 35.868275, -17.854618, 0.005591333, 36.850945, -8.932753, 0.005591333, 36.758175, -10.590622, 0.005591452, 35.73159, -10.597399, 0.005591333, 36.740005, -8.932753, 0.005591333, 36.758175, -8.927174, 0.005591452, 35.928226, -10.590622, 0.005591452, 35.73159, -8.932753, 0.005591333, 36.758175, -8.927174, 0.3712651, 35.928226, -8.927174, 0.005591452, 35.928226, -8.932753, 0.005591333, 36.758175, -8.932753, 0.3712651, 36.758175, -8.927174, 0.3712651, 35.928226, -10.592318, 0.3712651, 35.983803, -10.590622, 0.28006488, 35.73159, -10.17465, 0.37126523, 35.78064, -10.592318, 0.3712651, 35.983803, -12.40486, 0.37126523, 35.76565, -10.590622, 0.28006488, 35.73159, -10.592318, 0.3712651, 35.983803, -16.487228, 0.3712651, 35.842945, -12.40486, 0.37126523, 35.76565, -10.17465, 0.37126523, 35.78064, -10.597399, 0.3712651, 36.740005, -10.592318, 0.3712651, 35.983803, -10.17465, 0.37126523, 35.78064, -8.932753, 0.3712651, 36.758175, -10.597399, 0.3712651, 36.740005, -8.932753, 0.3712651, 36.758175, -10.17465, 0.37126523, 35.78064, -8.927174, 0.3712651, 35.928226, -17.854618, 0.371265, 36.850945, -10.592318, 0.3712651, 35.983803, -10.597399, 0.3712651, 36.740005, -10.592318, 0.3712651, 35.983803, -17.854618, 0.371265, 36.850945, -16.487228, 0.3712651, 35.842945, -16.487228, 0.3712651, 35.842945, -17.854618, 0.371265, 36.850945, -17.849669, 0.3712651, 36.11438, -16.487228, 0.3712651, 35.842945, -17.849669, 0.3712651, 36.11438, -17.848013, 0.28006476, 35.868275, -8.927174, 0.3712651, 35.928226, -10.590622, 0.005591452, 35.73159, -8.927174, 0.005591452, 35.928226, -10.590622, 0.005591452, 35.73159, -8.927174, 0.3712651, 35.928226, -10.17465, 0.37126523, 35.78064, -10.590622, 0.005591452, 35.73159, -10.17465, 0.37126523, 35.78064, -10.590622, 0.28006488, 35.73159, -10.590622, 0.28006488, 35.73159, -17.848013, 0.005591452, 35.868275, -10.590622, 0.005591452, 35.73159, -10.590622, 0.28006488, 35.73159, -16.487228, 0.3712651, 35.842945, -17.848013, 0.005591452, 35.868275, -17.848013, 0.005591452, 35.868275, -16.487228, 0.3712651, 35.842945, -17.848013, 0.28006476, 35.868275, -16.487228, 0.3712651, 35.842945, -10.590622, 0.28006488, 35.73159, -12.40486, 0.37126523, 35.76565, -17.848013, 0.28006476, 35.868275, -17.854618, 0.005591333, 36.850945, -17.848013, 0.005591452, 35.868275, -17.854618, 0.005591333, 36.850945, -17.848013, 0.28006476, 35.868275, -17.849669, 0.3712651, 36.11438, -17.854618, 0.005591333, 36.850945, -17.849669, 0.3712651, 36.11438, -17.854618, 0.371265, 36.850945, -16.774326, -3.4582698, 22.20213, -17.504349, 1.2701781, 22.197224, -17.504349, -3.4582698, 22.197224, -16.774326, -3.4582698, 22.20213, -16.774326, 0.72166747, 22.20213, -17.504349, 1.2701781, 22.197224, -16.774326, 0.72166747, 22.20213, -17.509253, 1.2701778, 22.926807, -17.504349, 1.2701781, 22.197224, -16.774326, 0.72166747, 22.20213, -16.77923, 1.2701778, 22.931713, -17.509253, 1.2701778, 22.926807, -16.77923, 1.2701778, 22.931713, -17.509253, -3.45827, 22.926807, -17.509253, 1.2701778, 22.926807, -16.77923, 1.2701778, 22.931713, -16.77923, -3.45827, 22.931713, -17.509253, -3.45827, 22.926807, -16.77923, -3.45827, 22.931713, -17.504349, -3.4582698, 22.197224, -17.509253, -3.45827, 22.926807, -16.77923, -3.45827, 22.931713, -16.774326, -3.4582698, 22.20213, -17.504349, -3.4582698, 22.197224, -17.504349, -3.4582698, 22.197224, -17.509253, 1.2701778, 22.926807, -17.509253, -3.45827, 22.926807, -17.504349, -3.4582698, 22.197224, -17.504349, 1.2701781, 22.197224, -17.509253, 1.2701778, 22.926807, -16.77923, -3.45827, 22.931713, -16.774326, 0.72166747, 22.20213, -16.774326, -3.4582698, 22.20213, -16.77923, -3.45827, 22.931713, -16.77923, 1.2701778, 22.931713, -16.774326, 0.72166747, 22.20213, -16.92366, -0.19077104, 22.930307, -17.155365, -0.09826177, 22.928751, -17.155365, -0.19077104, 22.928751, -16.92366, -0.19077104, 22.930307, -16.92366, -0.09826177, 22.930307, -17.155365, -0.09826177, 22.928751, -16.92366, -0.09826177, 22.930307, -17.320957, -0.098264754, 47.5684, -17.155365, -0.09826177, 22.928751, -16.92366, -0.09826177, 22.930307, -17.089252, -0.098264754, 47.569958, -17.320957, -0.098264754, 47.5684, -17.089252, -0.098264754, 47.569958, -17.320957, -0.19077402, 47.5684, -17.320957, -0.098264754, 47.5684, -17.089252, -0.098264754, 47.569958, -17.089252, -0.19077402, 47.569958, -17.320957, -0.19077402, 47.5684, -17.089252, -0.19077402, 47.569958, -17.155365, -0.19077104, 22.928751, -17.320957, -0.19077402, 47.5684, -17.089252, -0.19077402, 47.569958, -16.92366, -0.19077104, 22.930307, -17.155365, -0.19077104, 22.928751, -17.155365, -0.19077104, 22.928751, -17.320957, -0.098264754, 47.5684, -17.320957, -0.19077402, 47.5684, -17.155365, -0.19077104, 22.928751, -17.155365, -0.09826177, 22.928751, -17.320957, -0.098264754, 47.5684, -17.089252, -0.19077402, 47.569958, -16.92366, -0.09826177, 22.930307, -16.92366, -0.19077104, 22.930307, -17.089252, -0.19077402, 47.569958, -17.089252, -0.098264754, 47.569958, -16.92366, -0.09826177, 22.930307, -9.130678, -2.9032154, 33.80826, -9.860701, 1.8252325, 33.803356, -9.860701, -2.9032154, 33.803352, -9.130678, -2.9032154, 33.80826, -9.130678, 1.8252325, 33.80826, -9.860701, 1.8252325, 33.803356, -9.135584, -2.9032154, 34.538284, -9.860701, -2.9032154, 33.803352, -9.865606, -2.9032154, 34.53338, -9.135584, -2.9032154, 34.538284, -9.130678, -2.9032154, 33.80826, -9.860701, -2.9032154, 33.803352, -9.860701, -2.9032154, 33.803352, -9.865606, 1.8252323, 34.53338, -9.865606, -2.9032154, 34.53338, -9.860701, -2.9032154, 33.803352, -9.860701, 1.8252325, 33.803356, -9.865606, 1.8252323, 34.53338, -9.135584, 0.64312047, 34.538284, -9.317982, 1.8252323, 34.537056, -9.134359, 1.8252323, 34.35589, -9.130678, -2.9032154, 33.80826, -9.135584, 0.64312047, 34.538284, -9.134359, 1.8252323, 34.35589, -9.135584, 0.64312047, 34.538284, -9.130678, -2.9032154, 33.80826, -9.135584, -2.9032154, 34.538284, -9.130678, -2.9032154, 33.80826, -9.134359, 1.8252323, 34.35589, -9.130678, 1.8252325, 33.80826, -9.134359, 1.8252323, 34.35589, -9.860701, 1.8252325, 33.803356, -9.130678, 1.8252325, 33.80826, -9.860701, 1.8252325, 33.803356, -9.134359, 1.8252323, 34.35589, -9.317982, 1.8252323, 34.537056, -9.860701, 1.8252325, 33.803356, -9.317982, 1.8252323, 34.537056, -9.865606, 1.8252323, 34.53338, -9.135584, -2.9032154, 34.538284, -9.317982, 1.8252323, 34.537056, -9.135584, 0.64312047, 34.538284, -9.317982, 1.8252323, 34.537056, -9.135584, -2.9032154, 34.538284, -9.865606, -2.9032154, 34.53338, -9.317982, 1.8252323, 34.537056, -9.865606, -2.9032154, 34.53338, -9.865606, 1.8252323, 34.53338, -9.18182, -3.7899094, 41.417854, -9.91184, 0.9385386, 41.41295, -9.91184, -3.7899094, 41.41295, -9.18182, -3.7899094, 41.417854, -9.18182, 0.9385386, 41.417854, -9.91184, 0.9385386, 41.41295, -9.186722, -3.7899094, 42.147438, -9.91184, -3.7899094, 41.41295, -9.916744, -3.7899094, 42.142532, -9.186722, -3.7899094, 42.147438, -9.18182, -3.7899094, 41.417854, -9.91184, -3.7899094, 41.41295, -9.91184, -3.7899094, 41.41295, -9.916744, 0.9385385, 42.142532, -9.916744, -3.7899094, 42.142532, -9.91184, -3.7899094, 41.41295, -9.91184, 0.9385386, 41.41295, -9.916744, 0.9385385, 42.142532, -9.186722, -0.24357355, 42.147438, -9.36912, 0.9385385, 42.146214, -9.185497, 0.9385385, 41.965042, -9.18182, -3.7899094, 41.417854, -9.186722, -0.24357355, 42.147438, -9.185497, 0.9385385, 41.965042, -9.186722, -0.24357355, 42.147438, -9.18182, -3.7899094, 41.417854, -9.186722, -3.7899094, 42.147438, -9.18182, -3.7899094, 41.417854, -9.185497, 0.9385385, 41.965042, -9.18182, 0.9385386, 41.417854, -9.185497, 0.9385385, 41.965042, -9.91184, 0.9385386, 41.41295, -9.18182, 0.9385386, 41.417854, -9.91184, 0.9385386, 41.41295, -9.185497, 0.9385385, 41.965042, -9.36912, 0.9385385, 42.146214, -9.91184, 0.9385386, 41.41295, -9.36912, 0.9385385, 42.146214, -9.916744, 0.9385385, 42.142532, -9.186722, -3.7899094, 42.147438, -9.36912, 0.9385385, 42.146214, -9.186722, -0.24357355, 42.147438, -9.36912, 0.9385385, 42.146214, -9.186722, -3.7899094, 42.147438, -9.916744, -3.7899094, 42.142532, -9.36912, 0.9385385, 42.146214, -9.916744, -3.7899094, 42.142532, -9.916744, 0.9385385, 42.142532, -17.459593, -3.5494714, 33.653664, -18.189178, 1.1789763, 33.64876, -18.189178, -3.5494714, 33.64876, -17.459593, -3.5494714, 33.653664, -17.459593, 1.1789763, 33.653664, -18.189178, 1.1789763, 33.64876, -17.4645, -3.5494716, 34.383686, -18.189178, -3.5494714, 33.64876, -18.194086, -3.5494716, 34.378784, -17.4645, -3.5494716, 34.383686, -17.459593, -3.5494714, 33.653664, -18.189178, -3.5494714, 33.64876, -18.189178, -3.5494714, 33.64876, -18.194086, 1.178976, 34.378784, -18.194086, -3.5494716, 34.378784, -18.189178, -3.5494714, 33.64876, -18.189178, 1.1789763, 33.64876, -18.194086, 1.178976, 34.378784, -17.4645, -0.0031356215, 34.383686, -17.646896, 1.178976, 34.38246, -17.463274, 1.1789763, 34.20129, -17.459593, -3.5494714, 33.653664, -17.4645, -0.0031356215, 34.383686, -17.463274, 1.1789763, 34.20129, -17.4645, -0.0031356215, 34.383686, -17.459593, -3.5494714, 33.653664, -17.4645, -3.5494716, 34.383686, -17.459593, -3.5494714, 33.653664, -17.463274, 1.1789763, 34.20129, -17.459593, 1.1789763, 33.653664, -17.463274, 1.1789763, 34.20129, -18.189178, 1.1789763, 33.64876, -17.459593, 1.1789763, 33.653664, -18.189178, 1.1789763, 33.64876, -17.463274, 1.1789763, 34.20129, -17.646896, 1.178976, 34.38246, -18.189178, 1.1789763, 33.64876, -17.646896, 1.178976, 34.38246, -18.194086, 1.178976, 34.378784, -17.4645, -3.5494716, 34.383686, -17.646896, 1.178976, 34.38246, -17.4645, -0.0031356215, 34.383686, -17.646896, 1.178976, 34.38246, -17.4645, -3.5494716, 34.383686, -18.194086, -3.5494716, 34.378784, -17.646896, 1.178976, 34.38246, -18.194086, -3.5494716, 34.378784, -18.194086, 1.178976, 34.378784, -17.511396, -3.5494723, 41.361874, -18.240982, 1.1789753, 41.35697, -18.240982, -3.5494723, 41.35697, -17.511396, -3.5494723, 41.361874, -17.511396, 1.1789753, 41.361874, -18.240982, 1.1789753, 41.35697, -17.5163, -3.5494723, 42.09146, -18.240982, -3.5494723, 41.35697, -18.245886, -3.5494723, 42.086555, -17.5163, -3.5494723, 42.09146, -17.511396, -3.5494723, 41.361874, -18.240982, -3.5494723, 41.35697, -18.240982, -3.5494723, 41.35697, -18.245886, 1.1789753, 42.086555, -18.245886, -3.5494723, 42.086555, -18.240982, -3.5494723, 41.35697, -18.240982, 1.1789753, 41.35697, -18.245886, 1.1789753, 42.086555, -17.5163, -0.0031365752, 42.09146, -17.698698, 1.1789753, 42.090233, -17.515074, 1.1789753, 41.909065, -17.511396, -3.5494723, 41.361874, -17.5163, -0.0031365752, 42.09146, -17.515074, 1.1789753, 41.909065, -17.5163, -0.0031365752, 42.09146, -17.511396, -3.5494723, 41.361874, -17.5163, -3.5494723, 42.09146, -17.511396, -3.5494723, 41.361874, -17.515074, 1.1789753, 41.909065, -17.511396, 1.1789753, 41.361874, -17.515074, 1.1789753, 41.909065, -18.240982, 1.1789753, 41.35697, -17.511396, 1.1789753, 41.361874, -18.240982, 1.1789753, 41.35697, -17.515074, 1.1789753, 41.909065, -17.698698, 1.1789753, 42.090233, -18.240982, 1.1789753, 41.35697, -17.698698, 1.1789753, 42.090233, -18.245886, 1.1789753, 42.086555, -17.5163, -3.5494723, 42.09146, -17.698698, 1.1789753, 42.090233, -17.5163, -0.0031365752, 42.09146, -17.698698, 1.1789753, 42.090233, -17.5163, -3.5494723, 42.09146, -18.245886, -3.5494723, 42.086555, -17.698698, 1.1789753, 42.090233, -18.245886, -3.5494723, 42.086555, -18.245886, 1.1789753, 42.086555, -12.414185, -22.456995, 9.651152, -10.526909, -22.242655, 9.475412, -12.414185, -22.326996, 9.5211525, -12.414185, -22.456995, 9.651152, -10.526909, -22.391994, 9.624752, -10.526909, -22.242655, 9.475412, -19.07084, -22.391996, 9.624752, -12.414185, -22.456995, 9.651152, -12.414185, -22.326996, 9.5211525, -19.07084, -22.391996, 9.624752, -12.414185, -22.326996, 9.5211525, -19.07084, -22.242657, 9.475412, -12.414185, -22.326996, 9.5211525, -10.526909, -22.03864, 9.420736, -12.414185, -22.14938, 9.473576, -12.414185, -22.326996, 9.5211525, -10.526909, -22.242655, 9.475412, -10.526909, -22.03864, 9.420736, -19.07084, -22.242657, 9.475412, -12.414185, -22.326996, 9.5211525, -12.414185, -22.14938, 9.473576, -19.07084, -22.242657, 9.475412, -12.414185, -22.14938, 9.473576, -19.07084, -22.03864, 9.420735, -12.414185, -22.14938, 9.473576, -10.526909, -21.834621, 9.475412, -12.414185, -21.971802, 9.5211525, -12.414185, -22.14938, 9.473576, -10.526909, -22.03864, 9.420736, -10.526909, -21.834621, 9.475412, -19.07084, -22.03864, 9.420735, -12.414185, -22.14938, 9.473576, -12.414185, -21.971802, 9.5211525, -19.07084, -22.03864, 9.420735, -12.414185, -21.971802, 9.5211525, -19.07084, -21.834623, 9.475412, -12.414185, -21.971802, 9.5211525, -10.526909, -21.685282, 9.624752, -12.414185, -21.841803, 9.651152, -12.414185, -21.971802, 9.5211525, -10.526909, -21.834621, 9.475412, -10.526909, -21.685282, 9.624752, -19.07084, -21.834623, 9.475412, -12.414185, -21.971802, 9.5211525, -12.414185, -21.841803, 9.651152, -19.07084, -21.834623, 9.475412, -12.414185, -21.841803, 9.651152, -19.07084, -21.685282, 9.624752, -12.414185, -21.841803, 9.651152, -10.526909, -21.630606, 9.82877, -12.414185, -21.794186, 9.82877, -12.414185, -21.841803, 9.651152, -10.526909, -21.685282, 9.624752, -10.526909, -21.630606, 9.82877, -19.07084, -21.685282, 9.624752, -12.414185, -21.841803, 9.651152, -12.414185, -21.794186, 9.82877, -19.07084, -21.685282, 9.624752, -12.414185, -21.794186, 9.82877, -19.07084, -21.630606, 9.828769, -12.414185, -21.794186, 9.82877, -10.526909, -21.685282, 10.032785, -12.414185, -21.841803, 10.006346, -12.414185, -21.794186, 9.82877, -10.526909, -21.630606, 9.82877, -10.526909, -21.685282, 10.032785, -19.07084, -21.630606, 9.828769, -12.414185, -21.794186, 9.82877, -12.414185, -21.841803, 10.006346, -19.07084, -21.630606, 9.828769, -12.414185, -21.841803, 10.006346, -19.07084, -21.685282, 10.032785, -12.414185, -21.841803, 10.006346, -10.526909, -21.834621, 10.182125, -12.414185, -21.971802, 10.136345, -12.414185, -21.841803, 10.006346, -10.526909, -21.685282, 10.032785, -10.526909, -21.834621, 10.182125, -19.07084, -21.685282, 10.032785, -12.414185, -21.841803, 10.006346, -12.414185, -21.971802, 10.136345, -19.07084, -21.685282, 10.032785, -12.414185, -21.971802, 10.136345, -19.07084, -21.834623, 10.182125, -12.414185, -21.971802, 10.136345, -10.526909, -22.03864, 10.236803, -12.414185, -22.149378, 10.183962, -12.414185, -21.971802, 10.136345, -10.526909, -21.834621, 10.182125, -10.526909, -22.03864, 10.236803, -19.07084, -21.834623, 10.182125, -12.414185, -21.971802, 10.136345, -12.414185, -22.149378, 10.183962, -19.07084, -21.834623, 10.182125, -12.414185, -22.149378, 10.183962, -19.07084, -22.03864, 10.236801, -12.414185, -22.149378, 10.183962, -10.526909, -22.242655, 10.182125, -12.414185, -22.326996, 10.136345, -12.414185, -22.149378, 10.183962, -10.526909, -22.03864, 10.236803, -10.526909, -22.242655, 10.182125, -19.07084, -22.03864, 10.236801, -12.414185, -22.149378, 10.183962, -12.414185, -22.326996, 10.136345, -19.07084, -22.03864, 10.236801, -12.414185, -22.326996, 10.136345, -19.07084, -22.242657, 10.182125, -12.414185, -22.326996, 10.136345, -10.526909, -22.391994, 10.032785, -12.414185, -22.456995, 10.006346, -12.414185, -22.326996, 10.136345, -10.526909, -22.242655, 10.182125, -10.526909, -22.391994, 10.032785, -19.07084, -22.242657, 10.182125, -12.414185, -22.326996, 10.136345, -12.414185, -22.456995, 10.006346, -19.07084, -22.242657, 10.182125, -12.414185, -22.456995, 10.006346, -19.07084, -22.391996, 10.032785, -12.414185, -22.456995, 10.006346, -10.526909, -22.446672, 9.82877, -12.414185, -22.504572, 9.82877, -12.414185, -22.456995, 10.006346, -10.526909, -22.391994, 10.032785, -10.526909, -22.446672, 9.82877, -19.07084, -22.391996, 10.032785, -12.414185, -22.456995, 10.006346, -12.414185, -22.504572, 9.82877, -19.07084, -22.391996, 10.032785, -12.414185, -22.504572, 9.82877, -19.07084, -22.446672, 9.828769, -12.414185, -22.504572, 9.82877, -10.526909, -22.391994, 9.624752, -12.414185, -22.456995, 9.651152, -12.414185, -22.504572, 9.82877, -10.526909, -22.446672, 9.82877, -10.526909, -22.391994, 9.624752, -19.07084, -22.446672, 9.828769, -12.414185, -22.504572, 9.82877, -12.414185, -22.456995, 9.651152, -19.07084, -22.446672, 9.828769, -12.414185, -22.456995, 9.651152, -19.07084, -22.391996, 9.624752, -19.07084, -22.242657, 9.475412, -19.693949, -22.03864, 9.828769, -19.07084, -22.391996, 9.624752, -19.07084, -22.391996, 9.624752, -19.693949, -22.03864, 9.828769, -19.07084, -22.446672, 9.828769, -19.07084, -22.446672, 9.828769, -19.693949, -22.03864, 9.828769, -19.07084, -22.391996, 10.032785, -19.07084, -22.391996, 10.032785, -19.693949, -22.03864, 9.828769, -19.07084, -22.242657, 10.182125, -19.07084, -22.03864, 9.420735, -19.693949, -22.03864, 9.828769, -19.07084, -22.242657, 9.475412, -19.07084, -21.834623, 9.475412, -19.693949, -22.03864, 9.828769, -19.07084, -22.03864, 9.420735, -19.07084, -21.685282, 9.624752, -19.693949, -22.03864, 9.828769, -19.07084, -21.834623, 9.475412, -19.07084, -21.630606, 9.828769, -19.693949, -22.03864, 9.828769, -19.07084, -21.685282, 9.624752, -19.07084, -21.685282, 10.032785, -19.693949, -22.03864, 9.828769, -19.07084, -21.630606, 9.828769, -19.07084, -21.834623, 10.182125, -19.693949, -22.03864, 9.828769, -19.07084, -21.685282, 10.032785, -19.07084, -22.03864, 10.236801, -19.693949, -22.03864, 9.828769, -19.07084, -21.834623, 10.182125, -19.07084, -22.242657, 10.182125, -19.693949, -22.03864, 9.828769, -19.07084, -22.03864, 10.236801, -10.526909, -22.391994, 9.624752, -9.89662, -22.03864, 9.82877, -10.526909, -22.242655, 9.475412, -10.526909, -22.446672, 9.82877, -9.89662, -22.03864, 9.82877, -10.526909, -22.391994, 9.624752, -10.526909, -22.391994, 10.032785, -9.89662, -22.03864, 9.82877, -10.526909, -22.446672, 9.82877, -10.526909, -22.242655, 10.182125, -9.89662, -22.03864, 9.82877, -10.526909, -22.391994, 10.032785, -10.526909, -22.242655, 9.475412, -9.89662, -22.03864, 9.82877, -10.526909, -22.03864, 9.420736, -10.526909, -22.03864, 9.420736, -9.89662, -22.03864, 9.82877, -10.526909, -21.834621, 9.475412, -10.526909, -21.834621, 9.475412, -9.89662, -22.03864, 9.82877, -10.526909, -21.685282, 9.624752, -10.526909, -21.685282, 9.624752, -9.89662, -22.03864, 9.82877, -10.526909, -21.630606, 9.82877, -10.526909, -21.630606, 9.82877, -9.89662, -22.03864, 9.82877, -10.526909, -21.685282, 10.032785, -10.526909, -21.685282, 10.032785, -9.89662, -22.03864, 9.82877, -10.526909, -21.834621, 10.182125, -10.526909, -21.834621, 10.182125, -9.89662, -22.03864, 9.82877, -10.526909, -22.03864, 10.236803, -10.526909, -22.03864, 10.236803, -9.89662, -22.03864, 9.82877, -10.526909, -22.242655, 10.182125, -14.262087, -22.348663, 7.7697797, -10.526909, -22.242655, 7.6348023, -14.262087, -22.188835, 7.609912, -14.262087, -22.348663, 7.7697797, -10.526909, -22.391994, 7.7841425, -10.526909, -22.242655, 7.6348023, -19.07084, -22.391996, 7.784142, -14.262087, -22.348663, 7.7697797, -14.262087, -22.188835, 7.609912, -19.07084, -22.391996, 7.784142, -14.262087, -22.188835, 7.609912, -19.07084, -22.242657, 7.634802, -14.262087, -22.188835, 7.609912, -10.526909, -22.03864, 7.580126, -14.262087, -21.970457, 7.5514, -14.262087, -22.188835, 7.609912, -10.526909, -22.242655, 7.6348023, -10.526909, -22.03864, 7.580126, -19.07084, -22.242657, 7.634802, -14.262087, -22.188835, 7.609912, -14.262087, -21.970457, 7.5514, -19.07084, -22.242657, 7.634802, -14.262087, -21.970457, 7.5514, -19.07084, -22.03864, 7.5801253, -14.262087, -21.970457, 7.5514, -10.526909, -21.834621, 7.6348023, -14.262087, -21.752077, 7.609912, -14.262087, -21.970457, 7.5514, -10.526909, -22.03864, 7.580126, -10.526909, -21.834621, 7.6348023, -19.07084, -22.03864, 7.5801253, -14.262087, -21.970457, 7.5514, -14.262087, -21.752077, 7.609912, -19.07084, -22.03864, 7.5801253, -14.262087, -21.752077, 7.609912, -19.07084, -21.834623, 7.634802, -14.262087, -21.752077, 7.609912, -10.526909, -21.685282, 7.7841425, -14.262087, -21.592209, 7.769779, -14.262087, -21.752077, 7.609912, -10.526909, -21.834621, 7.6348023, -10.526909, -21.685282, 7.7841425, -19.07084, -21.834623, 7.634802, -14.262087, -21.752077, 7.609912, -14.262087, -21.592209, 7.769779, -19.07084, -21.834623, 7.634802, -14.262087, -21.592209, 7.769779, -19.07084, -21.685282, 7.784142, -14.262087, -21.592209, 7.769779, -10.526909, -21.630606, 7.988159, -14.262087, -21.533697, 7.9881587, -14.262087, -21.592209, 7.769779, -10.526909, -21.685282, 7.7841425, -10.526909, -21.630606, 7.988159, -19.07084, -21.685282, 7.784142, -14.262087, -21.592209, 7.769779, -14.262087, -21.533697, 7.9881587, -19.07084, -21.685282, 7.784142, -14.262087, -21.533697, 7.9881587, -19.07084, -21.630606, 7.9881587, -14.262087, -21.533697, 7.9881587, -10.526909, -21.685282, 8.192176, -14.262087, -21.592209, 8.206538, -14.262087, -21.533697, 7.9881587, -10.526909, -21.630606, 7.988159, -10.526909, -21.685282, 8.192176, -19.07084, -21.630606, 7.9881587, -14.262087, -21.533697, 7.9881587, -14.262087, -21.592209, 8.206538, -19.07084, -21.630606, 7.9881587, -14.262087, -21.592209, 8.206538, -19.07084, -21.685282, 8.192175, -14.262087, -21.592209, 8.206538, -10.526909, -21.834621, 8.3415165, -14.262087, -21.752077, 8.366365, -14.262087, -21.592209, 8.206538, -10.526909, -21.685282, 8.192176, -10.526909, -21.834621, 8.3415165, -19.07084, -21.685282, 8.192175, -14.262087, -21.592209, 8.206538, -14.262087, -21.752077, 8.366365, -19.07084, -21.685282, 8.192175, -14.262087, -21.752077, 8.366365, -19.07084, -21.834623, 8.341516, -14.262087, -21.752077, 8.366365, -10.526909, -22.03864, 8.396193, -14.262087, -21.970457, 8.424917, -14.262087, -21.752077, 8.366365, -10.526909, -21.834621, 8.3415165, -10.526909, -22.03864, 8.396193, -19.07084, -21.834623, 8.341516, -14.262087, -21.752077, 8.366365, -14.262087, -21.970457, 8.424917, -19.07084, -21.834623, 8.341516, -14.262087, -21.970457, 8.424917, -19.07084, -22.03864, 8.396193, -14.262087, -21.970457, 8.424917, -10.526909, -22.242655, 8.3415165, -14.262087, -22.188835, 8.366365, -14.262087, -21.970457, 8.424917, -10.526909, -22.03864, 8.396193, -10.526909, -22.242655, 8.3415165, -19.07084, -22.03864, 8.396193, -14.262087, -21.970457, 8.424917, -14.262087, -22.188835, 8.366365, -19.07084, -22.03864, 8.396193, -14.262087, -22.188835, 8.366365, -19.07084, -22.242657, 8.341516, -14.262087, -22.188835, 8.366365, -10.526909, -22.391994, 8.192176, -14.262087, -22.348663, 8.206538, -14.262087, -22.188835, 8.366365, -10.526909, -22.242655, 8.3415165, -10.526909, -22.391994, 8.192176, -19.07084, -22.242657, 8.341516, -14.262087, -22.188835, 8.366365, -14.262087, -22.348663, 8.206538, -19.07084, -22.242657, 8.341516, -14.262087, -22.348663, 8.206538, -19.07084, -22.391996, 8.192175, -14.262087, -22.348663, 8.206538, -10.526909, -22.446672, 7.988159, -14.262087, -22.407175, 7.9881587, -14.262087, -22.348663, 8.206538, -10.526909, -22.391994, 8.192176, -10.526909, -22.446672, 7.988159, -19.07084, -22.391996, 8.192175, -14.262087, -22.348663, 8.206538, -14.262087, -22.407175, 7.9881587, -19.07084, -22.391996, 8.192175, -14.262087, -22.407175, 7.9881587, -19.07084, -22.446672, 7.9881587, -14.262087, -22.407175, 7.9881587, -10.526909, -22.391994, 7.7841425, -14.262087, -22.348663, 7.7697797, -14.262087, -22.407175, 7.9881587, -10.526909, -22.446672, 7.988159, -10.526909, -22.391994, 7.7841425, -19.07084, -22.446672, 7.9881587, -14.262087, -22.407175, 7.9881587, -14.262087, -22.348663, 7.7697797, -19.07084, -22.446672, 7.9881587, -14.262087, -22.348663, 7.7697797, -19.07084, -22.391996, 7.784142, -19.07084, -22.242657, 7.634802, -19.693949, -22.03864, 7.9881587, -19.07084, -22.391996, 7.784142, -19.07084, -22.391996, 7.784142, -19.693949, -22.03864, 7.9881587, -19.07084, -22.446672, 7.9881587, -19.07084, -22.446672, 7.9881587, -19.693949, -22.03864, 7.9881587, -19.07084, -22.391996, 8.192175, -19.07084, -22.391996, 8.192175, -19.693949, -22.03864, 7.9881587, -19.07084, -22.242657, 8.341516, -19.07084, -22.03864, 7.5801253, -19.693949, -22.03864, 7.9881587, -19.07084, -22.242657, 7.634802, -19.07084, -21.834623, 7.634802, -19.693949, -22.03864, 7.9881587, -19.07084, -22.03864, 7.5801253, -19.07084, -21.685282, 7.784142, -19.693949, -22.03864, 7.9881587, -19.07084, -21.834623, 7.634802, -19.07084, -21.630606, 7.9881587, -19.693949, -22.03864, 7.9881587, -19.07084, -21.685282, 7.784142, -19.07084, -21.685282, 8.192175, -19.693949, -22.03864, 7.9881587, -19.07084, -21.630606, 7.9881587, -19.07084, -21.834623, 8.341516, -19.693949, -22.03864, 7.9881587, -19.07084, -21.685282, 8.192175, -19.07084, -22.03864, 8.396193, -19.693949, -22.03864, 7.9881587, -19.07084, -21.834623, 8.341516, -19.07084, -22.242657, 8.341516, -19.693949, -22.03864, 7.9881587, -19.07084, -22.03864, 8.396193, -10.526909, -22.391994, 7.7841425, -9.89662, -22.03864, 7.988159, -10.526909, -22.242655, 7.6348023, -10.526909, -22.446672, 7.988159, -9.89662, -22.03864, 7.988159, -10.526909, -22.391994, 7.7841425, -10.526909, -22.391994, 8.192176, -9.89662, -22.03864, 7.988159, -10.526909, -22.446672, 7.988159, -10.526909, -22.242655, 8.3415165, -9.89662, -22.03864, 7.988159, -10.526909, -22.391994, 8.192176, -10.526909, -22.242655, 7.6348023, -9.89662, -22.03864, 7.988159, -10.526909, -22.03864, 7.580126, -10.526909, -22.03864, 7.580126, -9.89662, -22.03864, 7.988159, -10.526909, -21.834621, 7.6348023, -10.526909, -21.834621, 7.6348023, -9.89662, -22.03864, 7.988159, -10.526909, -21.685282, 7.7841425, -10.526909, -21.685282, 7.7841425, -9.89662, -22.03864, 7.988159, -10.526909, -21.630606, 7.988159, -10.526909, -21.630606, 7.988159, -9.89662, -22.03864, 7.988159, -10.526909, -21.685282, 8.192176, -10.526909, -21.685282, 8.192176, -9.89662, -22.03864, 7.988159, -10.526909, -21.834621, 8.3415165, -10.526909, -21.834621, 8.3415165, -9.89662, -22.03864, 7.988159, -10.526909, -22.03864, 8.396193, -10.526909, -22.03864, 8.396193, -9.89662, -22.03864, 7.988159, -10.526909, -22.242655, 8.3415165, -12.004166, -22.046726, 10.088959, -12.124956, -19.902678, 9.968168, -12.124956, -22.046726, 9.968168, -12.004166, -22.046726, 10.088959, -12.004166, -19.902678, 10.088959, -12.124956, -19.902678, 9.968168, -12.124956, -22.046726, 9.968168, -12.28997, -19.902678, 9.923944, -12.28997, -22.046726, 9.923944, -12.124956, -22.046726, 9.968168, -12.124956, -19.902678, 9.968168, -12.28997, -19.902678, 9.923944, -12.28997, -22.046726, 9.923944, -12.454985, -19.902678, 9.968168, -12.454985, -22.046726, 9.968168, -12.28997, -22.046726, 9.923944, -12.28997, -19.902678, 9.923944, -12.454985, -19.902678, 9.968168, -12.454985, -22.046726, 9.968168, -12.575773, -19.902678, 10.088959, -12.575773, -22.046726, 10.088959, -12.454985, -22.046726, 9.968168, -12.454985, -19.902678, 9.968168, -12.575773, -19.902678, 10.088959, -12.575773, -22.046726, 10.088959, -12.619997, -19.902678, 10.253973, -12.619997, -22.046726, 10.253973, -12.575773, -22.046726, 10.088959, -12.575773, -19.902678, 10.088959, -12.619997, -19.902678, 10.253973, -12.619997, -22.046726, 10.253973, -12.575773, -19.902678, 10.418985, -12.575773, -22.046726, 10.418987, -12.619997, -22.046726, 10.253973, -12.619997, -19.902678, 10.253973, -12.575773, -19.902678, 10.418985, -12.575773, -22.046726, 10.418987, -12.454985, -19.902678, 10.539776, -12.454985, -22.046726, 10.539778, -12.575773, -22.046726, 10.418987, -12.575773, -19.902678, 10.418985, -12.454985, -19.902678, 10.539776, -12.454985, -22.046726, 10.539778, -12.28997, -19.902678, 10.584, -12.28997, -22.046726, 10.584002, -12.454985, -22.046726, 10.539778, -12.454985, -19.902678, 10.539776, -12.28997, -19.902678, 10.584, -12.28997, -22.046726, 10.584002, -12.124956, -19.902678, 10.539776, -12.124956, -22.046726, 10.539778, -12.28997, -22.046726, 10.584002, -12.28997, -19.902678, 10.584, -12.124956, -19.902678, 10.539776, -12.124956, -22.046726, 10.539778, -12.004166, -19.902678, 10.418985, -12.004166, -22.046726, 10.418987, -12.124956, -22.046726, 10.539778, -12.124956, -19.902678, 10.539776, -12.004166, -19.902678, 10.418985, -12.004166, -22.046726, 10.418987, -11.959942, -19.902678, 10.253973, -11.959942, -22.046726, 10.253973, -12.004166, -22.046726, 10.418987, -12.004166, -19.902678, 10.418985, -11.959942, -19.902678, 10.253973, -11.959942, -22.046726, 10.253973, -12.004166, -19.902678, 10.088959, -12.004166, -22.046726, 10.088959, -11.959942, -22.046726, 10.253973, -11.959942, -19.902678, 10.253973, -12.004166, -19.902678, 10.088959, -12.124956, -22.046726, 9.968168, -12.28997, -22.046726, 10.253973, -12.004166, -22.046726, 10.088959, -12.004166, -22.046726, 10.088959, -12.28997, -22.046726, 10.253973, -11.959942, -22.046726, 10.253973, -12.28997, -22.046726, 9.923944, -12.28997, -22.046726, 10.253973, -12.124956, -22.046726, 9.968168, -11.959942, -22.046726, 10.253973, -12.28997, -22.046726, 10.253973, -12.004166, -22.046726, 10.418987, -12.454985, -22.046726, 9.968168, -12.28997, -22.046726, 10.253973, -12.28997, -22.046726, 9.923944, -12.004166, -22.046726, 10.418987, -12.28997, -22.046726, 10.253973, -12.124956, -22.046726, 10.539778, -12.575773, -22.046726, 10.088959, -12.28997, -22.046726, 10.253973, -12.454985, -22.046726, 9.968168, -12.124956, -22.046726, 10.539778, -12.28997, -22.046726, 10.253973, -12.28997, -22.046726, 10.584002, -12.619997, -22.046726, 10.253973, -12.28997, -22.046726, 10.253973, -12.575773, -22.046726, 10.088959, -12.28997, -22.046726, 10.584002, -12.28997, -22.046726, 10.253973, -12.454985, -22.046726, 10.539778, -12.575773, -22.046726, 10.418987, -12.28997, -22.046726, 10.253973, -12.619997, -22.046726, 10.253973, -12.454985, -22.046726, 10.539778, -12.28997, -22.046726, 10.253973, -12.575773, -22.046726, 10.418987, -12.004166, -19.902678, 10.088959, -12.28997, -19.456135, 10.253971, -12.124956, -19.902678, 9.968168, -12.124956, -19.902678, 9.968168, -12.28997, -19.456135, 10.253971, -12.28997, -19.902678, 9.923944, -11.959942, -19.902678, 10.253973, -12.28997, -19.456135, 10.253971, -12.004166, -19.902678, 10.088959, -12.28997, -19.902678, 9.923944, -12.28997, -19.456135, 10.253971, -12.454985, -19.902678, 9.968168, -12.004166, -19.902678, 10.418985, -12.28997, -19.456135, 10.253971, -11.959942, -19.902678, 10.253973, -12.454985, -19.902678, 9.968168, -12.28997, -19.456135, 10.253971, -12.575773, -19.902678, 10.088959, -12.124956, -19.902678, 10.539776, -12.28997, -19.456135, 10.253971, -12.004166, -19.902678, 10.418985, -12.575773, -19.902678, 10.088959, -12.28997, -19.456135, 10.253971, -12.619997, -19.902678, 10.253973, -12.28997, -19.902678, 10.584, -12.28997, -19.456135, 10.253971, -12.124956, -19.902678, 10.539776, -12.619997, -19.902678, 10.253973, -12.28997, -19.456135, 10.253971, -12.575773, -19.902678, 10.418985, -12.454985, -19.902678, 10.539776, -12.28997, -19.456135, 10.253971, -12.28997, -19.902678, 10.584, -12.575773, -19.902678, 10.418985, -12.28997, -19.456135, 10.253971, -12.454985, -19.902678, 10.539776, -12.004166, -22.046726, 6.859756, -12.124956, -19.902678, 6.7389646, -12.124956, -22.046726, 6.7389655, -12.004166, -22.046726, 6.859756, -12.004166, -19.902678, 6.859755, -12.124956, -19.902678, 6.7389646, -12.124956, -22.046726, 6.7389655, -12.28997, -19.902678, 6.6947412, -12.28997, -22.046726, 6.6947417, -12.124956, -22.046726, 6.7389655, -12.124956, -19.902678, 6.7389646, -12.28997, -19.902678, 6.6947412, -12.28997, -22.046726, 6.6947417, -12.454985, -19.902678, 6.7389646, -12.454985, -22.046726, 6.7389655, -12.28997, -22.046726, 6.6947417, -12.28997, -19.902678, 6.6947412, -12.454985, -19.902678, 6.7389646, -12.454985, -22.046726, 6.7389655, -12.575773, -19.902678, 6.859755, -12.575773, -22.046726, 6.859756, -12.454985, -22.046726, 6.7389655, -12.454985, -19.902678, 6.7389646, -12.575773, -19.902678, 6.859755, -12.575773, -22.046726, 6.859756, -12.619997, -19.902678, 7.024769, -12.619997, -22.046726, 7.02477, -12.575773, -22.046726, 6.859756, -12.575773, -19.902678, 6.859755, -12.619997, -19.902678, 7.024769, -12.619997, -22.046726, 7.02477, -12.575773, -19.902678, 7.1897826, -12.575773, -22.046726, 7.1897836, -12.619997, -22.046726, 7.02477, -12.619997, -19.902678, 7.024769, -12.575773, -19.902678, 7.1897826, -12.575773, -22.046726, 7.1897836, -12.454985, -19.902678, 7.310573, -12.454985, -22.046726, 7.3105736, -12.575773, -22.046726, 7.1897836, -12.575773, -19.902678, 7.1897826, -12.454985, -19.902678, 7.310573, -12.454985, -22.046726, 7.3105736, -12.28997, -19.902678, 7.354797, -12.28997, -22.046726, 7.3547974, -12.454985, -22.046726, 7.3105736, -12.454985, -19.902678, 7.310573, -12.28997, -19.902678, 7.354797, -12.28997, -22.046726, 7.3547974, -12.124956, -19.902678, 7.310573, -12.124956, -22.046726, 7.3105736, -12.28997, -22.046726, 7.3547974, -12.28997, -19.902678, 7.354797, -12.124956, -19.902678, 7.310573, -12.124956, -22.046726, 7.3105736, -12.004166, -19.902678, 7.1897826, -12.004166, -22.046726, 7.1897836, -12.124956, -22.046726, 7.3105736, -12.124956, -19.902678, 7.310573, -12.004166, -19.902678, 7.1897826, -12.004166, -22.046726, 7.1897836, -11.959942, -19.902678, 7.024769, -11.959942, -22.046726, 7.02477, -12.004166, -22.046726, 7.1897836, -12.004166, -19.902678, 7.1897826, -11.959942, -19.902678, 7.024769, -11.959942, -22.046726, 7.02477, -12.004166, -19.902678, 6.859755, -12.004166, -22.046726, 6.859756, -11.959942, -22.046726, 7.02477, -11.959942, -19.902678, 7.024769, -12.004166, -19.902678, 6.859755, -12.124956, -22.046726, 6.7389655, -12.28997, -22.046726, 7.02477, -12.004166, -22.046726, 6.859756, -12.004166, -22.046726, 6.859756, -12.28997, -22.046726, 7.02477, -11.959942, -22.046726, 7.02477, -12.28997, -22.046726, 6.6947417, -12.28997, -22.046726, 7.02477, -12.124956, -22.046726, 6.7389655, -11.959942, -22.046726, 7.02477, -12.28997, -22.046726, 7.02477, -12.004166, -22.046726, 7.1897836, -12.454985, -22.046726, 6.7389655, -12.28997, -22.046726, 7.02477, -12.28997, -22.046726, 6.6947417, -12.004166, -22.046726, 7.1897836, -12.28997, -22.046726, 7.02477, -12.124956, -22.046726, 7.3105736, -12.575773, -22.046726, 6.859756, -12.28997, -22.046726, 7.02477, -12.454985, -22.046726, 6.7389655, -12.124956, -22.046726, 7.3105736, -12.28997, -22.046726, 7.02477, -12.28997, -22.046726, 7.3547974, -12.619997, -22.046726, 7.02477, -12.28997, -22.046726, 7.02477, -12.575773, -22.046726, 6.859756, -12.28997, -22.046726, 7.3547974, -12.28997, -22.046726, 7.02477, -12.454985, -22.046726, 7.3105736, -12.575773, -22.046726, 7.1897836, -12.28997, -22.046726, 7.02477, -12.619997, -22.046726, 7.02477, -12.454985, -22.046726, 7.3105736, -12.28997, -22.046726, 7.02477, -12.575773, -22.046726, 7.1897836, -12.004166, -19.902678, 6.859755, -12.28997, -19.456135, 7.024769, -12.124956, -19.902678, 6.7389646, -12.124956, -19.902678, 6.7389646, -12.28997, -19.456135, 7.024769, -12.28997, -19.902678, 6.6947412, -11.959942, -19.902678, 7.024769, -12.28997, -19.456135, 7.024769, -12.004166, -19.902678, 6.859755, -12.28997, -19.902678, 6.6947412, -12.28997, -19.456135, 7.024769, -12.454985, -19.902678, 6.7389646, -12.004166, -19.902678, 7.1897826, -12.28997, -19.456135, 7.024769, -11.959942, -19.902678, 7.024769, -12.454985, -19.902678, 6.7389646, -12.28997, -19.456135, 7.024769, -12.575773, -19.902678, 6.859755, -12.124956, -19.902678, 7.310573, -12.28997, -19.456135, 7.024769, -12.004166, -19.902678, 7.1897826, -12.575773, -19.902678, 6.859755, -12.28997, -19.456135, 7.024769, -12.619997, -19.902678, 7.024769, -12.28997, -19.902678, 7.354797, -12.28997, -19.456135, 7.024769, -12.124956, -19.902678, 7.310573, -12.619997, -19.902678, 7.024769, -12.28997, -19.456135, 7.024769, -12.575773, -19.902678, 7.1897826, -12.454985, -19.902678, 7.310573, -12.28997, -19.456135, 7.024769, -12.28997, -19.902678, 7.354797, -12.575773, -19.902678, 7.1897826, -12.28997, -19.456135, 7.024769, -12.454985, -19.902678, 7.310573, -12.917406, -22.143438, 6.933488, -12.989674, -20.442587, 6.8666687, -12.989674, -22.143438, 6.8666697, -12.917406, -22.143438, 6.933488, -12.917406, -20.442587, 6.933487, -12.989674, -20.442587, 6.8666687, -12.989674, -22.143438, 6.8666697, -13.088398, -20.442587, 6.8422055, -13.088398, -22.143438, 6.842206, -12.989674, -22.143438, 6.8666697, -12.989674, -20.442587, 6.8666687, -13.088398, -20.442587, 6.8422055, -13.088398, -22.143438, 6.842206, -13.187122, -20.442587, 6.8666687, -13.187122, -22.143438, 6.8666697, -13.088398, -22.143438, 6.842206, -13.088398, -20.442587, 6.8422055, -13.187122, -20.442587, 6.8666687, -13.187122, -22.143438, 6.8666697, -13.259388, -20.442587, 6.933487, -13.259388, -22.143438, 6.933488, -13.187122, -22.143438, 6.8666697, -13.187122, -20.442587, 6.8666687, -13.259388, -20.442587, 6.933487, -13.259388, -22.143438, 6.933488, -13.285847, -20.442587, 7.024769, -13.285847, -22.143438, 7.02477, -13.259388, -22.143438, 6.933488, -13.259388, -20.442587, 6.933487, -13.285847, -20.442587, 7.024769, -13.285847, -22.143438, 7.02477, -13.259388, -20.442587, 7.1160507, -13.259388, -22.143438, 7.116051, -13.285847, -22.143438, 7.02477, -13.285847, -20.442587, 7.024769, -13.259388, -20.442587, 7.1160507, -13.259388, -22.143438, 7.116051, -13.187122, -20.442587, 7.182869, -13.187122, -22.143438, 7.18287, -13.259388, -22.143438, 7.116051, -13.259388, -20.442587, 7.1160507, -13.187122, -20.442587, 7.182869, -13.187122, -22.143438, 7.18287, -13.088398, -20.442587, 7.2073326, -13.088398, -22.143438, 7.207333, -13.187122, -22.143438, 7.18287, -13.187122, -20.442587, 7.182869, -13.088398, -20.442587, 7.2073326, -13.088398, -22.143438, 7.207333, -12.989674, -20.442587, 7.182869, -12.989674, -22.143438, 7.18287, -13.088398, -22.143438, 7.207333, -13.088398, -20.442587, 7.2073326, -12.989674, -20.442587, 7.182869, -12.989674, -22.143438, 7.18287, -12.917406, -20.442587, 7.1160507, -12.917406, -22.143438, 7.116051, -12.989674, -22.143438, 7.18287, -12.989674, -20.442587, 7.182869, -12.917406, -20.442587, 7.1160507, -12.917406, -22.143438, 7.116051, -12.890947, -20.442587, 7.024769, -12.890947, -22.143438, 7.02477, -12.917406, -22.143438, 7.116051, -12.917406, -20.442587, 7.1160507, -12.890947, -20.442587, 7.024769, -12.890947, -22.143438, 7.02477, -12.917406, -20.442587, 6.933487, -12.917406, -22.143438, 6.933488, -12.890947, -22.143438, 7.02477, -12.890947, -20.442587, 7.024769, -12.917406, -20.442587, 6.933487, -12.989674, -22.143438, 6.8666697, -13.088398, -22.143438, 7.02477, -12.917406, -22.143438, 6.933488, -12.917406, -22.143438, 6.933488, -13.088398, -22.143438, 7.02477, -12.890947, -22.143438, 7.02477, -13.088398, -22.143438, 6.842206, -13.088398, -22.143438, 7.02477, -12.989674, -22.143438, 6.8666697, -12.890947, -22.143438, 7.02477, -13.088398, -22.143438, 7.02477, -12.917406, -22.143438, 7.116051, -13.187122, -22.143438, 6.8666697, -13.088398, -22.143438, 7.02477, -13.088398, -22.143438, 6.842206, -12.917406, -22.143438, 7.116051, -13.088398, -22.143438, 7.02477, -12.989674, -22.143438, 7.18287, -13.259388, -22.143438, 6.933488, -13.088398, -22.143438, 7.02477, -13.187122, -22.143438, 6.8666697, -12.989674, -22.143438, 7.18287, -13.088398, -22.143438, 7.02477, -13.088398, -22.143438, 7.207333, -13.285847, -22.143438, 7.02477, -13.088398, -22.143438, 7.02477, -13.259388, -22.143438, 6.933488, -13.088398, -22.143438, 7.207333, -13.088398, -22.143438, 7.02477, -13.187122, -22.143438, 7.18287, -13.259388, -22.143438, 7.116051, -13.088398, -22.143438, 7.02477, -13.285847, -22.143438, 7.02477, -13.187122, -22.143438, 7.18287, -13.088398, -22.143438, 7.02477, -13.259388, -22.143438, 7.116051, -12.917406, -20.442587, 6.933487, -13.088398, -20.08835, 7.024769, -12.989674, -20.442587, 6.8666687, -12.989674, -20.442587, 6.8666687, -13.088398, -20.08835, 7.024769, -13.088398, -20.442587, 6.8422055, -12.890947, -20.442587, 7.024769, -13.088398, -20.08835, 7.024769, -12.917406, -20.442587, 6.933487, -13.088398, -20.442587, 6.8422055, -13.088398, -20.08835, 7.024769, -13.187122, -20.442587, 6.8666687, -12.917406, -20.442587, 7.1160507, -13.088398, -20.08835, 7.024769, -12.890947, -20.442587, 7.024769, -13.187122, -20.442587, 6.8666687, -13.088398, -20.08835, 7.024769, -13.259388, -20.442587, 6.933487, -12.989674, -20.442587, 7.182869, -13.088398, -20.08835, 7.024769, -12.917406, -20.442587, 7.1160507, -13.259388, -20.442587, 6.933487, -13.088398, -20.08835, 7.024769, -13.285847, -20.442587, 7.024769, -13.088398, -20.442587, 7.2073326, -13.088398, -20.08835, 7.024769, -12.989674, -20.442587, 7.182869, -13.285847, -20.442587, 7.024769, -13.088398, -20.08835, 7.024769, -13.259388, -20.442587, 7.1160507, -13.187122, -20.442587, 7.182869, -13.088398, -20.08835, 7.024769, -13.088398, -20.442587, 7.2073326, -13.259388, -20.442587, 7.1160507, -13.088398, -20.08835, 7.024769, -13.187122, -20.442587, 7.182869, 7.7560043, -21.843687, -1.5963855, 7.8594513, -21.712654, -1.5136316, 7.8594513, -21.889545, -1.5601864, 7.7560043, -21.843687, -1.5963855, 7.7198067, -21.712654, -1.5601864, 7.8594513, -21.712654, -1.5136316, 7.7198067, -21.889545, -1.6998296, 7.7560043, -21.843687, -1.5963855, 7.8594513, -21.889545, -1.5601864, 7.7198067, -21.889545, -1.6998296, 7.7198067, -21.712654, -1.5601864, 7.7560043, -21.843687, -1.5963855, 7.7198067, -21.712654, -1.5601864, 7.8594513, -21.392513, -1.5493045, 7.8594513, -21.712654, -1.5136316, 7.8594513, -21.712654, -1.5136316, 7.8594513, -21.392513, -1.5493045, 7.9628983, -21.392513, -1.5855265, 7.7198067, -21.712654, -1.5601864, 7.7560043, -21.392513, -1.5855265, 7.8594513, -21.392513, -1.5493045, 7.8594513, -21.712654, -1.5136316, 7.9628983, -21.392513, -1.5855265, 7.999096, -21.712654, -1.5601864, 7.8594513, -21.889545, -1.5601864, 7.8594513, -21.712654, -1.5136316, 7.999096, -21.712654, -1.5601864, 7.999096, -21.712654, -1.5601864, 7.9628983, -21.392513, -1.5855265, 7.999096, -21.392513, -1.6889691, 7.673259, -21.712656, -1.6998296, 7.7560043, -21.392513, -1.5855265, 7.7198067, -21.712654, -1.5601864, 7.7198067, -21.889545, -1.6998296, 7.673259, -21.712656, -1.6998296, 7.7198067, -21.712654, -1.5601864, 7.673259, -21.712656, -1.6998296, 7.7198067, -21.392513, -1.6889691, 7.7560043, -21.392513, -1.5855265, 7.999096, -21.712654, -1.5601864, 7.999096, -21.392513, -1.6889691, 8.045644, -21.712656, -1.6998296, 8.045644, -21.712656, -1.6998296, 7.999096, -21.392513, -1.6889691, 7.9628983, -21.392513, -1.7924135, 7.8594513, -21.889545, -1.5601864, 7.999096, -21.712654, -1.5601864, 7.9628983, -21.843687, -1.5963855, 7.9628983, -21.843687, -1.5963855, 7.999096, -21.712654, -1.5601864, 8.045644, -21.712656, -1.6998296, 7.7198067, -21.889545, -1.6998296, 7.8594513, -21.889545, -1.5601864, 7.8594513, -21.948505, -1.6998296, 7.8594513, -21.948505, -1.6998296, 7.8594513, -21.889545, -1.5601864, 7.9628983, -21.843687, -1.5963855, 7.7560043, -21.843687, -1.8032722, 7.673259, -21.712656, -1.6998296, 7.7198067, -21.889545, -1.6998296, 7.7560043, -21.843687, -1.8032722, 7.7198067, -21.889545, -1.6998296, 7.8594513, -21.948505, -1.6998296, 7.7198067, -21.712656, -1.8394728, 7.7198067, -21.392513, -1.6889691, 7.673259, -21.712656, -1.6998296, 7.7560043, -21.843687, -1.8032722, 7.7198067, -21.712656, -1.8394728, 7.673259, -21.712656, -1.6998296, 7.7198067, -21.712656, -1.8394728, 7.7560043, -21.392513, -1.7924135, 7.7198067, -21.392513, -1.6889691, 7.7560043, -21.843687, -1.8032722, 7.8594513, -21.948505, -1.6998296, 7.8594513, -21.889545, -1.8394728, 7.7198067, -21.712656, -1.8394728, 7.7560043, -21.843687, -1.8032722, 7.8594513, -21.889545, -1.8394728, 7.8594513, -21.948505, -1.6998296, 7.9628983, -21.843687, -1.5963855, 7.999096, -21.889545, -1.6998296, 7.8594513, -21.889545, -1.8394728, 7.8594513, -21.948505, -1.6998296, 7.999096, -21.889545, -1.6998296, 7.9628983, -21.843687, -1.5963855, 8.045644, -21.712656, -1.6998296, 7.999096, -21.889545, -1.6998296, 7.8594513, -21.889545, -1.8394728, 7.999096, -21.889545, -1.6998296, 7.9628983, -21.843687, -1.8032722, 7.7560043, -21.392513, -1.7924135, 7.7198067, -21.712656, -1.8394728, 7.8594513, -21.712656, -1.8860278, 7.7198067, -21.712656, -1.8394728, 7.8594513, -21.889545, -1.8394728, 7.8594513, -21.712656, -1.8860278, 7.8594513, -21.712656, -1.8860278, 7.8594513, -21.889545, -1.8394728, 7.9628983, -21.843687, -1.8032722, 7.7560043, -21.392513, -1.7924135, 7.8594513, -21.712656, -1.8860278, 7.8594513, -21.392513, -1.8286123, 7.999096, -21.889545, -1.6998296, 8.045644, -21.712656, -1.6998296, 7.999096, -21.712656, -1.8394728, 7.999096, -21.889545, -1.6998296, 7.999096, -21.712656, -1.8394728, 7.9628983, -21.843687, -1.8032722, 7.8594513, -21.712656, -1.8860278, 7.9628983, -21.843687, -1.8032722, 7.999096, -21.712656, -1.8394728, 7.8594513, -21.392513, -1.8286123, 7.8594513, -21.712656, -1.8860278, 7.999096, -21.712656, -1.8394728, 8.045644, -21.712656, -1.6998296, 7.9628983, -21.392513, -1.7924135, 7.999096, -21.712656, -1.8394728, 7.8594513, -21.392513, -1.8286123, 7.999096, -21.712656, -1.8394728, 7.9628983, -21.392513, -1.7924135, 7.597028, -21.442598, -1.34617, 7.8594513, -21.312298, -1.1490684, 7.8594513, -21.49598, -1.2661133, 7.597028, -21.442598, -1.34617, 7.505172, -21.314877, -1.2550125, 7.8594513, -21.312298, -1.1490684, 7.8594513, -21.49598, -1.2661133, 7.8594513, -21.312298, -1.1490684, 8.213731, -21.314877, -1.2550125, 7.505172, -21.409956, -1.6840334, 7.505172, -21.314877, -1.2550125, 7.597028, -21.442598, -1.34617, 7.505172, -21.409956, -1.6840334, 7.597028, -21.442598, -1.34617, 7.8594513, -21.49598, -1.2661133, 7.505172, -21.409956, -1.6840334, 7.387093, -21.22724, -1.6845381, 7.505172, -21.314877, -1.2550125, 7.8594513, -21.49598, -1.2661133, 8.213731, -21.314877, -1.2550125, 8.121874, -21.442598, -1.34617, 8.121874, -21.442598, -1.34617, 8.213731, -21.314877, -1.2550125, 8.331823, -21.22724, -1.6845381, 7.505172, -21.409956, -1.6840334, 7.8594513, -21.49598, -1.2661133, 7.8594513, -21.492252, -1.6844721, 7.8594513, -21.492252, -1.6844721, 7.8594513, -21.49598, -1.2661133, 8.121874, -21.442598, -1.34617, 7.597028, -21.339413, -1.9443204, 7.387093, -21.22724, -1.6845381, 7.505172, -21.409956, -1.6840334, 7.597028, -21.339413, -1.9443204, 7.505172, -21.409956, -1.6840334, 7.8594513, -21.492252, -1.6844721, 7.597028, -21.339413, -1.9443204, 7.505172, -21.222763, -2.0274482, 7.387093, -21.22724, -1.6845381, 8.121874, -21.442598, -1.34617, 8.331823, -21.22724, -1.6845381, 8.213731, -21.409956, -1.6840334, 7.8594513, -21.492252, -1.6844721, 8.121874, -21.442598, -1.34617, 8.213731, -21.409956, -1.6840334, 8.213731, -21.409956, -1.6840334, 8.331823, -21.22724, -1.6845381, 8.213731, -21.222763, -2.0274482, 7.597028, -21.339413, -1.9443204, 7.8594513, -21.492252, -1.6844721, 7.8594513, -21.4032, -2.0336347, 7.505172, -21.222763, -2.0274482, 7.597028, -21.339413, -1.9443204, 7.8594513, -21.4032, -2.0336347, 7.8594513, -21.4032, -2.0336347, 7.8594513, -21.492252, -1.6844721, 8.213731, -21.409956, -1.6840334, 7.505172, -21.222763, -2.0274482, 7.8594513, -21.4032, -2.0336347, 7.8594513, -21.227156, -2.1436162, 8.213731, -21.409956, -1.6840334, 8.213731, -21.222763, -2.0274482, 8.121874, -21.339413, -1.9443204, 7.8594513, -21.4032, -2.0336347, 8.213731, -21.409956, -1.6840334, 8.121874, -21.339413, -1.9443204, 7.8594513, -21.227156, -2.1436162, 8.121874, -21.339413, -1.9443204, 8.213731, -21.222763, -2.0274482, 7.8594513, -21.227156, -2.1436162, 7.8594513, -21.4032, -2.0336347, 8.121874, -21.339413, -1.9443204, 7.8594513, -21.312298, -1.1490684, 8.121874, -21.111137, -1.4137647, 8.213731, -21.314877, -1.2550125, 8.213731, -21.314877, -1.2550125, 8.121874, -21.111137, -1.4137647, 8.213731, -21.024748, -1.6911418, 7.8594513, -21.312298, -1.1490684, 7.8594513, -21.038862, -1.3319969, 8.121874, -21.111137, -1.4137647, 7.8594513, -21.038862, -1.3319969, 8.213731, -21.024748, -1.6911418, 8.121874, -21.111137, -1.4137647, 8.213731, -21.314877, -1.2550125, 8.213731, -21.024748, -1.6911418, 8.331823, -21.22724, -1.6845381, 7.505172, -21.314877, -1.2550125, 7.8594513, -21.038862, -1.3319969, 7.8594513, -21.312298, -1.1490684, 7.505172, -21.314877, -1.2550125, 7.597028, -21.111137, -1.4137647, 7.8594513, -21.038862, -1.3319969, 7.387093, -21.22724, -1.6845381, 7.597028, -21.111137, -1.4137647, 7.505172, -21.314877, -1.2550125, 7.8594513, -21.038862, -1.3319969, 7.8594513, -20.941704, -1.6914926, 8.213731, -21.024748, -1.6911418, 7.597028, -21.111137, -1.4137647, 7.8594513, -20.941704, -1.6914926, 7.8594513, -21.038862, -1.3319969, 8.331823, -21.22724, -1.6845381, 8.213731, -21.024748, -1.6911418, 8.121874, -21.092463, -1.9232578, 7.8594513, -20.941704, -1.6914926, 8.121874, -21.092463, -1.9232578, 8.213731, -21.024748, -1.6911418, 8.331823, -21.22724, -1.6845381, 8.121874, -21.092463, -1.9232578, 8.213731, -21.222763, -2.0274482, 7.387093, -21.22724, -1.6845381, 7.505172, -21.024748, -1.6911418, 7.597028, -21.111137, -1.4137647, 7.597028, -21.111137, -1.4137647, 7.505172, -21.024748, -1.6911418, 7.8594513, -20.941704, -1.6914926, 7.505172, -21.222763, -2.0274482, 7.505172, -21.024748, -1.6911418, 7.387093, -21.22724, -1.6845381, 7.8594513, -21.02841, -2.012529, 8.213731, -21.222763, -2.0274482, 8.121874, -21.092463, -1.9232578, 7.8594513, -20.941704, -1.6914926, 7.8594513, -21.02841, -2.012529, 8.121874, -21.092463, -1.9232578, 7.505172, -21.024748, -1.6911418, 7.8594513, -21.02841, -2.012529, 7.8594513, -20.941704, -1.6914926, 7.8594513, -21.02841, -2.012529, 7.8594513, -21.227156, -2.1436162, 8.213731, -21.222763, -2.0274482, 7.505172, -21.222763, -2.0274482, 7.597028, -21.092463, -1.9232578, 7.505172, -21.024748, -1.6911418, 7.505172, -21.024748, -1.6911418, 7.597028, -21.092463, -1.9232578, 7.8594513, -21.02841, -2.012529, 7.597028, -21.092463, -1.9232578, 7.505172, -21.222763, -2.0274482, 7.8594513, -21.227156, -2.1436162, 7.597028, -21.092463, -1.9232578, 7.8594513, -21.227156, -2.1436162, 7.8594513, -21.02841, -2.012529, 7.7984443, -21.95358, -2.1591706, 7.8594513, -21.864279, -2.1397543, 7.8594513, -21.971691, -2.1298366, 7.7984443, -21.95358, -2.1591706, 7.7770944, -21.873667, -2.165554, 7.8594513, -21.864279, -2.1397543, 7.7770944, -21.999872, -2.2072396, 7.7984443, -21.95358, -2.1591706, 7.8594513, -21.971691, -2.1298366, 7.7770944, -21.999872, -2.2072396, 7.7770944, -21.873667, -2.165554, 7.7984443, -21.95358, -2.1591706, 7.7770944, -21.873667, -2.165554, 7.8594513, -21.694061, -2.224133, 7.8594513, -21.864279, -2.1397543, 7.8594513, -21.864279, -2.1397543, 7.8594513, -21.694061, -2.224133, 7.9204583, -21.701368, -2.2442064, 7.7770944, -21.873667, -2.165554, 7.7984443, -21.701368, -2.2442064, 7.8594513, -21.694061, -2.224133, 7.8594513, -21.864279, -2.1397543, 7.9204583, -21.701368, -2.2442064, 7.941808, -21.873667, -2.165554, 7.8594513, -21.971691, -2.1298366, 7.8594513, -21.864279, -2.1397543, 7.941808, -21.873667, -2.165554, 7.941808, -21.873667, -2.165554, 7.9204583, -21.701368, -2.2442064, 7.941808, -21.722242, -2.3015342, 7.7496324, -21.901846, -2.2429338, 7.7984443, -21.701368, -2.2442064, 7.7770944, -21.873667, -2.165554, 7.7770944, -21.999872, -2.2072396, 7.7496324, -21.901846, -2.2429338, 7.7770944, -21.873667, -2.165554, 7.7496324, -21.901846, -2.2429338, 7.7770944, -21.722242, -2.3015342, 7.7984443, -21.701368, -2.2442064, 7.941808, -21.873667, -2.165554, 7.941808, -21.722242, -2.3015342, 7.96927, -21.901846, -2.2429338, 7.96927, -21.901846, -2.2429338, 7.941808, -21.722242, -2.3015342, 7.9204583, -21.743116, -2.3588605, 7.8594513, -21.971691, -2.1298366, 7.941808, -21.873667, -2.165554, 7.9204583, -21.95358, -2.1591706, 7.9204583, -21.95358, -2.1591706, 7.941808, -21.873667, -2.165554, 7.96927, -21.901846, -2.2429338, 7.7770944, -21.999872, -2.2072396, 7.8594513, -21.971691, -2.1298366, 7.8594513, -22.032564, -2.1953483, 7.8594513, -22.032564, -2.1953483, 7.8594513, -21.971691, -2.1298366, 7.9204583, -21.95358, -2.1591706, 7.7984443, -21.995344, -2.2738247, 7.7496324, -21.901846, -2.2429338, 7.7770944, -21.999872, -2.2072396, 7.7984443, -21.995344, -2.2738247, 7.7770944, -21.999872, -2.2072396, 7.8594513, -22.032564, -2.1953483, 7.7770944, -21.930044, -2.320335, 7.7770944, -21.722242, -2.3015342, 7.7496324, -21.901846, -2.2429338, 7.7984443, -21.995344, -2.2738247, 7.7770944, -21.930044, -2.320335, 7.7496324, -21.901846, -2.2429338, 7.7770944, -21.930044, -2.320335, 7.7984443, -21.743116, -2.3588605, 7.7770944, -21.722242, -2.3015342, 7.7984443, -21.995344, -2.2738247, 7.8594513, -22.032564, -2.1953483, 7.8594513, -22.02807, -2.2846193, 7.7770944, -21.930044, -2.320335, 7.7984443, -21.995344, -2.2738247, 7.8594513, -22.02807, -2.2846193, 7.8594513, -22.032564, -2.1953483, 7.9204583, -21.95358, -2.1591706, 7.941808, -21.999872, -2.2072396, 7.8594513, -22.02807, -2.2846193, 7.8594513, -22.032564, -2.1953483, 7.941808, -21.999872, -2.2072396, 7.9204583, -21.95358, -2.1591706, 7.96927, -21.901846, -2.2429338, 7.941808, -21.999872, -2.2072396, 7.8594513, -22.02807, -2.2846193, 7.941808, -21.999872, -2.2072396, 7.9204583, -21.995344, -2.2738247, 7.7984443, -21.743116, -2.3588605, 7.7770944, -21.930044, -2.320335, 7.8594513, -21.939434, -2.3461366, 7.7770944, -21.930044, -2.320335, 7.8594513, -22.02807, -2.2846193, 7.8594513, -21.939434, -2.3461366, 7.8594513, -21.939434, -2.3461366, 7.8594513, -22.02807, -2.2846193, 7.9204583, -21.995344, -2.2738247, 7.7984443, -21.743116, -2.3588605, 7.8594513, -21.939434, -2.3461366, 7.8594513, -21.750423, -2.3789139, 7.941808, -21.999872, -2.2072396, 7.96927, -21.901846, -2.2429338, 7.941808, -21.930044, -2.320335, 7.941808, -21.999872, -2.2072396, 7.941808, -21.930044, -2.320335, 7.9204583, -21.995344, -2.2738247, 7.8594513, -21.939434, -2.3461366, 7.9204583, -21.995344, -2.2738247, 7.941808, -21.930044, -2.320335, 7.8594513, -21.750423, -2.3789139, 7.8594513, -21.939434, -2.3461366, 7.941808, -21.930044, -2.320335, 7.96927, -21.901846, -2.2429338, 7.9204583, -21.743116, -2.3588605, 7.941808, -21.930044, -2.320335, 7.8594513, -21.750423, -2.3789139, 7.941808, -21.930044, -2.320335, 7.9204583, -21.743116, -2.3588605, 7.7046704, -21.680813, -2.1014476, 7.8594513, -21.56882, -2.0185182, 7.8594513, -21.694244, -2.0463152, 7.7046704, -21.680813, -2.1014476, 7.6505113, -21.591642, -2.0767002, 7.8594513, -21.56882, -2.0185182, 7.8594513, -21.694244, -2.0463152, 7.8594513, -21.56882, -2.0185182, 8.068392, -21.591642, -2.0767002, 7.6505113, -21.730915, -2.2952814, 7.6505113, -21.591642, -2.0767002, 7.7046704, -21.680813, -2.1014476, 7.6505113, -21.730915, -2.2952814, 7.7046704, -21.680813, -2.1014476, 7.8594513, -21.694244, -2.0463152, 7.6505113, -21.730915, -2.2952814, 7.5808535, -21.62976, -2.3324466, 7.6505113, -21.591642, -2.0767002, 7.8594513, -21.694244, -2.0463152, 8.068392, -21.591642, -2.0767002, 8.014233, -21.680813, -2.1014476, 8.014233, -21.680813, -2.1014476, 8.068392, -21.591642, -2.0767002, 8.138048, -21.62976, -2.3324466, 7.6505113, -21.730915, -2.2952814, 7.8594513, -21.694244, -2.0463152, 7.8594513, -21.776608, -2.278893, 7.8594513, -21.776608, -2.278893, 7.8594513, -21.694244, -2.0463152, 8.014233, -21.680813, -2.1014476, 7.7046704, -21.744349, -2.4537702, 7.5808535, -21.62976, -2.3324466, 7.6505113, -21.730915, -2.2952814, 7.7046704, -21.744349, -2.4537702, 7.6505113, -21.730915, -2.2952814, 7.8594513, -21.776608, -2.278893, 7.7046704, -21.744349, -2.4537702, 7.6505113, -21.696476, -2.5233836, 7.5808535, -21.62976, -2.3324466, 8.014233, -21.680813, -2.1014476, 8.138048, -21.62976, -2.3324466, 8.068392, -21.730915, -2.2952814, 7.8594513, -21.776608, -2.278893, 8.014233, -21.680813, -2.1014476, 8.068392, -21.730915, -2.2952814, 8.068392, -21.730915, -2.2952814, 8.138048, -21.62976, -2.3324466, 8.068392, -21.696476, -2.5233836, 7.7046704, -21.744349, -2.4537702, 7.8594513, -21.776608, -2.278893, 7.8594513, -21.79773, -2.490386, 7.6505113, -21.696476, -2.5233836, 7.7046704, -21.744349, -2.4537702, 7.8594513, -21.79773, -2.490386, 7.8594513, -21.79773, -2.490386, 7.8594513, -21.776608, -2.278893, 8.068392, -21.730915, -2.2952814, 7.6505113, -21.696476, -2.5233836, 7.8594513, -21.79773, -2.490386, 7.8594513, -21.722359, -2.586853, 8.068392, -21.730915, -2.2952814, 8.068392, -21.696476, -2.5233836, 8.014233, -21.744349, -2.4537702, 7.8594513, -21.79773, -2.490386, 8.068392, -21.730915, -2.2952814, 8.014233, -21.744349, -2.4537702, 7.8594513, -21.722359, -2.586853, 8.014233, -21.744349, -2.4537702, 8.068392, -21.696476, -2.5233836, 7.8594513, -21.722359, -2.586853, 7.8594513, -21.79773, -2.490386, 8.014233, -21.744349, -2.4537702, 7.8594513, -21.56882, -2.0185182, 8.014233, -21.510761, -2.2058134, 8.068392, -21.591642, -2.0767002, 8.068392, -21.591642, -2.0767002, 8.014233, -21.510761, -2.2058134, 8.068392, -21.51887, -2.3769612, 7.8594513, -21.56882, -2.0185182, 7.8594513, -21.4542, -2.1750765, 8.014233, -21.510761, -2.2058134, 7.8594513, -21.4542, -2.1750765, 8.068392, -21.51887, -2.3769612, 8.014233, -21.510761, -2.2058134, 8.068392, -21.591642, -2.0767002, 8.068392, -21.51887, -2.3769612, 8.138048, -21.62976, -2.3324466, 7.6505113, -21.591642, -2.0767002, 7.8594513, -21.4542, -2.1750765, 7.8594513, -21.56882, -2.0185182, 7.6505113, -21.591642, -2.0767002, 7.7046704, -21.510761, -2.2058134, 7.8594513, -21.4542, -2.1750765, 7.5808535, -21.62976, -2.3324466, 7.7046704, -21.510761, -2.2058134, 7.6505113, -21.591642, -2.0767002, 7.8594513, -21.4542, -2.1750765, 7.8594513, -21.47291, -2.3939195, 8.068392, -21.51887, -2.3769612, 7.7046704, -21.510761, -2.2058134, 7.8594513, -21.47291, -2.3939195, 7.8594513, -21.4542, -2.1750765, 8.138048, -21.62976, -2.3324466, 8.068392, -21.51887, -2.3769612, 8.014233, -21.603245, -2.491922, 7.8594513, -21.47291, -2.3939195, 8.014233, -21.603245, -2.491922, 8.068392, -21.51887, -2.3769612, 8.138048, -21.62976, -2.3324466, 8.014233, -21.603245, -2.491922, 8.068392, -21.696476, -2.5233836, 7.5808535, -21.62976, -2.3324466, 7.6505113, -21.51887, -2.3769612, 7.7046704, -21.510761, -2.2058134, 7.7046704, -21.510761, -2.2058134, 7.6505113, -21.51887, -2.3769612, 7.8594513, -21.47291, -2.3939195, 7.6505113, -21.696476, -2.5233836, 7.6505113, -21.51887, -2.3769612, 7.5808535, -21.62976, -2.3324466, 7.8594513, -21.585766, -2.5543175, 8.068392, -21.696476, -2.5233836, 8.014233, -21.603245, -2.491922, 7.8594513, -21.47291, -2.3939195, 7.8594513, -21.585766, -2.5543175, 8.014233, -21.603245, -2.491922, 7.6505113, -21.51887, -2.3769612, 7.8594513, -21.585766, -2.5543175, 7.8594513, -21.47291, -2.3939195, 7.8594513, -21.585766, -2.5543175, 7.8594513, -21.722359, -2.586853, 8.068392, -21.696476, -2.5233836, 7.6505113, -21.696476, -2.5233836, 7.7046704, -21.603245, -2.491922, 7.6505113, -21.51887, -2.3769612, 7.6505113, -21.51887, -2.3769612, 7.7046704, -21.603245, -2.491922, 7.8594513, -21.585766, -2.5543175, 7.7046704, -21.603245, -2.491922, 7.6505113, -21.696476, -2.5233836, 7.8594513, -21.722359, -2.586853, 7.7046704, -21.603245, -2.491922, 7.8594513, -21.722359, -2.586853, 7.8594513, -21.585766, -2.5543175, -3.1935778, 3.8995895, -1.8595574, -2.9311557, 3.6870894, -1.6457057, -2.9311557, 3.8599355, -1.9618971, -3.1935778, 3.8995895, -1.8595574, -3.2854238, 3.7667134, -1.6248863, -2.9311557, 3.6870894, -1.6457057, -2.9311557, 3.8599355, -1.9618971, -2.9311557, 3.6870894, -1.6457057, -2.5768762, 3.7667134, -1.6248863, -2.9311557, 3.8599355, -1.9618971, -2.5768762, 3.7667134, -1.6248863, -2.6687322, 3.8995895, -1.8595574, -2.9311557, 3.6870894, -1.6457057, -2.6687322, 3.6513314, -1.305656, -2.5768762, 3.7667134, -1.6248863, -2.9311557, 3.6870894, -1.6457057, -2.9311557, 3.579767, -1.2413225, -2.6687322, 3.6513314, -1.305656, -3.2854238, 3.7667134, -1.6248863, -2.9311557, 3.579767, -1.2413225, -2.9311557, 3.6870894, -1.6457057, -3.2854238, 3.7667134, -1.6248863, -3.1935778, 3.6513314, -1.305656, -2.9311557, 3.579767, -1.2413225, -3.0859365, 3.6335888, -2.4534812, -2.9311557, 3.5589857, -2.2920804, -2.9311557, 3.5909512, -2.5021868, -3.0859365, 3.6335888, -2.4534812, -3.1401029, 3.6073158, -2.2966237, -2.9311557, 3.5589857, -2.2920804, -2.9311557, 3.5909512, -2.5021868, -2.9311557, 3.5589857, -2.2920804, -2.722207, 3.6073158, -2.2966237, -2.9311557, 3.5909512, -2.5021868, -2.722207, 3.6073158, -2.2966237, -2.7763748, 3.6335888, -2.4534812, -2.9311557, 3.5589857, -2.2920804, -2.7763748, 3.6077993, -2.096417, -2.722207, 3.6073158, -2.2966237, -2.9311557, 3.5589857, -2.2920804, -2.9311557, 3.5811186, -2.046321, -2.7763748, 3.6077993, -2.096417, -3.1401029, 3.6073158, -2.2966237, -2.9311557, 3.5811186, -2.046321, -2.9311557, 3.5589857, -2.2920804, -3.1401029, 3.6073158, -2.2966237, -3.0859365, 3.6077993, -2.096417, -2.9311557, 3.5811186, -2.046321, -3.1935806, 3.6513338, -1.3056598, -2.931157, 3.7287045, -1.0824075, -2.931157, 3.5797653, -1.2413273, -2.931157, 3.5797653, -1.2413273, -2.931157, 3.7287045, -1.0824075, -2.5768776, 3.7524617, -1.1856833, -3.1935806, 3.6513338, -1.3056598, -3.2854366, 3.7524617, -1.1856833, -2.931157, 3.7287045, -1.0824075, -2.931157, 3.5797653, -1.2413273, -2.5768776, 3.7524617, -1.1856833, -2.668735, 3.6513338, -1.3056598, -3.2854366, 3.766717, -1.6248941, -3.2854366, 3.7524617, -1.1856833, -3.1935806, 3.6513338, -1.3056598, -2.668735, 3.6513338, -1.3056598, -2.5768776, 3.7524617, -1.1856833, -2.4587846, 3.9438598, -1.5800798, -3.2854366, 3.766717, -1.6248941, -3.4035153, 3.9438598, -1.5800798, -3.2854366, 3.7524617, -1.1856833, -2.668735, 3.6513338, -1.3056598, -2.4587846, 3.9438598, -1.5800798, -2.5768776, 3.766717, -1.6248941, -3.1935806, 3.8995967, -1.8595555, -3.4035153, 3.9438598, -1.5800798, -3.2854366, 3.766717, -1.6248941, -2.5768776, 3.766717, -1.6248941, -2.4587846, 3.9438598, -1.5800798, -2.5768776, 4.0332108, -1.9111595, -3.1935806, 3.8995967, -1.8595555, -3.2854366, 4.0332108, -1.9111595, -3.4035153, 3.9438598, -1.5800798, -2.5768776, 3.766717, -1.6248941, -2.5768776, 4.0332108, -1.9111595, -2.668735, 3.8995967, -1.8595555, -3.2854366, 4.0332108, -1.9111595, -3.1935806, 3.8995967, -1.8595555, -2.931157, 3.8599317, -1.9618936, -2.931157, 4.0577416, -2.0247965, -2.668735, 3.8995967, -1.8595555, -2.5768776, 4.0332108, -1.9111595, -3.2854366, 4.0332108, -1.9111595, -2.931157, 3.8599317, -1.9618936, -2.931157, 4.0577416, -2.0247965, -2.931157, 4.0577416, -2.0247965, -2.931157, 3.8599317, -1.9618936, -2.668735, 3.8995967, -1.8595555, -2.931157, 3.7287045, -1.0824075, -2.668735, 3.9892077, -1.288969, -2.5768776, 3.7524617, -1.1856833, -2.5768776, 3.7524617, -1.1856833, -2.668735, 3.9892077, -1.288969, -2.5768776, 4.141674, -1.5362737, -2.931157, 3.7287045, -1.0824075, -2.931157, 4.0389485, -1.191823, -2.668735, 3.9892077, -1.288969, -2.931157, 4.0389485, -1.191823, -2.5768776, 4.141674, -1.5362737, -2.668735, 3.9892077, -1.288969, -2.5768776, 3.7524617, -1.1856833, -2.5768776, 4.141674, -1.5362737, -2.4587846, 3.9438598, -1.5800798, -3.2854366, 3.7524617, -1.1856833, -2.931157, 4.0389485, -1.191823, -2.931157, 3.7287045, -1.0824075, -3.2854366, 3.7524617, -1.1856833, -3.1935806, 3.9892077, -1.288969, -2.931157, 4.0389485, -1.191823, -3.4035153, 3.9438598, -1.5800798, -3.1935806, 3.9892077, -1.288969, -3.2854366, 3.7524617, -1.1856833, -2.931157, 4.0389485, -1.191823, -2.931157, 4.2222066, -1.5160246, -2.5768776, 4.141674, -1.5362737, -3.1935806, 3.9892077, -1.288969, -2.931157, 4.2222066, -1.5160246, -2.931157, 4.0389485, -1.191823, -2.4587846, 3.9438598, -1.5800798, -2.5768776, 4.141674, -1.5362737, -2.668735, 4.1336055, -1.777915, -2.931157, 4.2222066, -1.5160246, -2.668735, 4.1336055, -1.777915, -2.5768776, 4.141674, -1.5362737, -2.4587846, 3.9438598, -1.5800798, -2.668735, 4.1336055, -1.777915, -2.5768776, 4.0332108, -1.9111595, -3.4035153, 3.9438598, -1.5800798, -3.2854366, 4.141674, -1.5362737, -3.1935806, 3.9892077, -1.288969, -3.1935806, 3.9892077, -1.288969, -3.2854366, 4.141674, -1.5362737, -2.931157, 4.2222066, -1.5160246, -3.2854366, 4.0332108, -1.9111595, -3.2854366, 4.141674, -1.5362737, -3.4035153, 3.9438598, -1.5800798, -2.931157, 4.2177896, -1.8485272, -2.5768776, 4.0332108, -1.9111595, -2.668735, 4.1336055, -1.777915, -2.931157, 4.2222066, -1.5160246, -2.931157, 4.2177896, -1.8485272, -2.668735, 4.1336055, -1.777915, -3.2854366, 4.141674, -1.5362737, -2.931157, 4.2177896, -1.8485272, -2.931157, 4.2222066, -1.5160246, -2.931157, 4.2177896, -1.8485272, -2.931157, 4.0577416, -2.0247965, -2.5768776, 4.0332108, -1.9111595, -3.2854366, 4.0332108, -1.9111595, -3.1935806, 4.1336055, -1.777915, -3.2854366, 4.141674, -1.5362737, -3.2854366, 4.141674, -1.5362737, -3.1935806, 4.1336055, -1.777915, -2.931157, 4.2177896, -1.8485272, -3.1935806, 4.1336055, -1.777915, -3.2854366, 4.0332108, -1.9111595, -2.931157, 4.0577416, -2.0247965, -3.1935806, 4.1336055, -1.777915, -2.931157, 4.0577416, -2.0247965, -2.931157, 4.2177896, -1.8485272, -3.034594, 3.324797, -1.6474974, -2.9311528, 3.431215, -1.5348387, -2.9311528, 3.2713995, -1.6237905, -3.034594, 3.324797, -1.6474974, -3.0708017, 3.4427564, -1.5799403, -2.9311528, 3.431215, -1.5348387, -3.0708017, 3.3060207, -1.7590828, -3.034594, 3.324797, -1.6474974, -2.9311528, 3.2713995, -1.6237905, -3.0708017, 3.3060207, -1.7590828, -3.0708017, 3.4427564, -1.5799403, -3.034594, 3.324797, -1.6474974, -3.0708017, 3.4427564, -1.5799403, -2.9311528, 3.7502108, -1.490046, -2.9311528, 3.431215, -1.5348387, -2.9311528, 3.431215, -1.5348387, -2.9311528, 3.7502108, -1.490046, -2.8277116, 3.759186, -1.5251193, -3.0708017, 3.4427564, -1.5799403, -3.034594, 3.759186, -1.5251193, -2.9311528, 3.7502108, -1.490046, -2.9311528, 3.431215, -1.5348387, -2.8277116, 3.759186, -1.5251193, -2.791504, 3.4427564, -1.5799403, -2.9311528, 3.2713995, -1.6237905, -2.9311528, 3.431215, -1.5348387, -2.791504, 3.4427564, -1.5799403, -2.791504, 3.4427564, -1.5799403, -2.8277116, 3.759186, -1.5251193, -2.791504, 3.784829, -1.6253259, -3.1173468, 3.477378, -1.7152324, -3.034594, 3.759186, -1.5251193, -3.0708017, 3.4427564, -1.5799403, -3.0708017, 3.3060207, -1.7590828, -3.1173468, 3.477378, -1.7152324, -3.0708017, 3.4427564, -1.5799403, -3.1173468, 3.477378, -1.7152324, -3.0708017, 3.784829, -1.6253259, -3.034594, 3.759186, -1.5251193, -2.791504, 3.4427564, -1.5799403, -2.791504, 3.784829, -1.6253259, -2.7449532, 3.477378, -1.7152324, -2.7449532, 3.477378, -1.7152324, -2.791504, 3.784829, -1.6253259, -2.8277116, 3.8104749, -1.7255449, -2.9311528, 3.2713995, -1.6237905, -2.791504, 3.4427564, -1.5799403, -2.8277116, 3.324797, -1.6474974, -2.8277116, 3.324797, -1.6474974, -2.791504, 3.4427564, -1.5799403, -2.7449532, 3.477378, -1.7152324, -3.0708017, 3.3060207, -1.7590828, -2.9311528, 3.2713995, -1.6237905, -2.9311528, 3.2488918, -1.7737021, -2.9311528, 3.2488918, -1.7737021, -2.9311528, 3.2713995, -1.6237905, -2.8277116, 3.324797, -1.6474974, -3.034594, 3.3760855, -1.8479218, -3.1173468, 3.477378, -1.7152324, -3.0708017, 3.3060207, -1.7590828, -3.034594, 3.3760855, -1.8479218, -3.0708017, 3.3060207, -1.7590828, -2.9311528, 3.2488918, -1.7737021, -3.0708017, 3.511996, -1.8505123, -3.0708017, 3.784829, -1.6253259, -3.1173468, 3.477378, -1.7152324, -3.034594, 3.3760855, -1.8479218, -3.0708017, 3.511996, -1.8505123, -3.1173468, 3.477378, -1.7152324, -3.0708017, 3.511996, -1.8505123, -3.034594, 3.8104749, -1.7255449, -3.0708017, 3.784829, -1.6253259, -3.034594, 3.3760855, -1.8479218, -2.9311528, 3.2488918, -1.7737021, -2.9311528, 3.3406389, -1.8943624, -3.0708017, 3.511996, -1.8505123, -3.034594, 3.3760855, -1.8479218, -2.9311528, 3.3406389, -1.8943624, -2.9311528, 3.2488918, -1.7737021, -2.8277116, 3.324797, -1.6474974, -2.791504, 3.3060207, -1.7590828, -2.9311528, 3.3406389, -1.8943624, -2.9311528, 3.2488918, -1.7737021, -2.791504, 3.3060207, -1.7590828, -2.8277116, 3.324797, -1.6474974, -2.7449532, 3.477378, -1.7152324, -2.791504, 3.3060207, -1.7590828, -2.9311528, 3.3406389, -1.8943624, -2.791504, 3.3060207, -1.7590828, -2.8277116, 3.3760855, -1.8479218, -3.034594, 3.8104749, -1.7255449, -3.0708017, 3.511996, -1.8505123, -2.9311528, 3.5235374, -1.8956139, -3.0708017, 3.511996, -1.8505123, -2.9311528, 3.3406389, -1.8943624, -2.9311528, 3.5235374, -1.8956139, -2.9311528, 3.5235374, -1.8956139, -2.9311528, 3.3406389, -1.8943624, -2.8277116, 3.3760855, -1.8479218, -3.034594, 3.8104749, -1.7255449, -2.9311528, 3.5235374, -1.8956139, -2.9311528, 3.8194501, -1.7606182, -2.791504, 3.3060207, -1.7590828, -2.7449532, 3.477378, -1.7152324, -2.791504, 3.511996, -1.8505123, -2.791504, 3.3060207, -1.7590828, -2.791504, 3.511996, -1.8505123, -2.8277116, 3.3760855, -1.8479218, -2.9311528, 3.5235374, -1.8956139, -2.8277116, 3.3760855, -1.8479218, -2.791504, 3.511996, -1.8505123, -2.9311528, 3.8194501, -1.7606182, -2.9311528, 3.5235374, -1.8956139, -2.791504, 3.511996, -1.8505123, -2.7449532, 3.477378, -1.7152324, -2.8277116, 3.8104749, -1.7255449, -2.791504, 3.511996, -1.8505123, -2.9311528, 3.8194501, -1.7606182, -2.791504, 3.511996, -1.8505123, -2.8277116, 3.8104749, -1.7255449, -2.9921641, 3.357846, -2.219953, -2.9311528, 3.439559, -2.179008, -2.9311528, 3.3330348, -2.1960335, -2.9921641, 3.357846, -2.219953, -3.0135126, 3.436851, -2.2063189, -2.9311528, 3.439559, -2.179008, -3.0135126, 3.324916, -2.2780023, -2.9921641, 3.357846, -2.219953, -2.9311528, 3.3330348, -2.1960335, -3.0135126, 3.324916, -2.2780023, -3.0135126, 3.436851, -2.2063189, -2.9921641, 3.357846, -2.219953, -3.0135126, 3.436851, -2.2063189, -2.9311528, 3.6253839, -2.2185574, -2.9311528, 3.439559, -2.179008, -2.9311528, 3.439559, -2.179008, -2.9311528, 3.6253839, -2.2185574, -2.8701415, 3.6232789, -2.239798, -3.0135126, 3.436851, -2.2063189, -2.9921641, 3.6232789, -2.239798, -2.9311528, 3.6253839, -2.2185574, -2.9311528, 3.439559, -2.179008, -2.8701415, 3.6232789, -2.239798, -2.848793, 3.436851, -2.2063189, -2.9311528, 3.3330348, -2.1960335, -2.9311528, 3.439559, -2.179008, -2.848793, 3.436851, -2.2063189, -2.848793, 3.436851, -2.2063189, -2.8701415, 3.6232789, -2.239798, -2.848793, 3.617262, -2.3005147, -3.0409703, 3.428742, -2.2882862, -2.9921641, 3.6232789, -2.239798, -3.0135126, 3.436851, -2.2063189, -3.0135126, 3.324916, -2.2780023, -3.0409703, 3.428742, -2.2882862, -3.0135126, 3.436851, -2.2063189, -3.0409703, 3.428742, -2.2882862, -3.0135126, 3.617262, -2.3005147, -2.9921641, 3.6232789, -2.239798, -2.848793, 3.436851, -2.2063189, -2.848793, 3.617262, -2.3005147, -2.8213353, 3.428742, -2.2882862, -2.8213353, 3.428742, -2.2882862, -2.848793, 3.617262, -2.3005147, -2.8701415, 3.6112561, -2.361228, -2.9311528, 3.3330348, -2.1960335, -2.848793, 3.436851, -2.2063189, -2.8701415, 3.357846, -2.219953, -2.8701415, 3.357846, -2.219953, -2.848793, 3.436851, -2.2063189, -2.8213353, 3.428742, -2.2882862, -3.0135126, 3.324916, -2.2780023, -2.9311528, 3.3330348, -2.1960335, -2.9311528, 3.2903087, -2.274579, -2.9311528, 3.2903087, -2.274579, -2.9311528, 3.3330348, -2.1960335, -2.8701415, 3.357846, -2.219953, -2.9921641, 3.345823, -2.3413825, -3.0409703, 3.428742, -2.2882862, -3.0135126, 3.324916, -2.2780023, -2.9921641, 3.345823, -2.3413825, -3.0135126, 3.324916, -2.2780023, -2.9311528, 3.2903087, -2.274579, -3.0135126, 3.4206212, -2.370242, -3.0135126, 3.617262, -2.3005147, -3.0409703, 3.428742, -2.2882862, -2.9921641, 3.345823, -2.3413825, -3.0135126, 3.4206212, -2.370242, -3.0409703, 3.428742, -2.2882862, -3.0135126, 3.4206212, -2.370242, -2.9921641, 3.6112561, -2.361228, -3.0135126, 3.617262, -2.3005147, -2.9921641, 3.345823, -2.3413825, -2.9311528, 3.2903087, -2.274579, -2.9311528, 3.3167953, -2.3599591, -3.0135126, 3.4206212, -2.370242, -2.9921641, 3.345823, -2.3413825, -2.9311528, 3.3167953, -2.3599591, -2.9311528, 3.2903087, -2.274579, -2.8701415, 3.357846, -2.219953, -2.848793, 3.324916, -2.2780023, -2.9311528, 3.3167953, -2.3599591, -2.9311528, 3.2903087, -2.274579, -2.848793, 3.324916, -2.2780023, -2.8701415, 3.357846, -2.219953, -2.8213353, 3.428742, -2.2882862, -2.848793, 3.324916, -2.2780023, -2.9311528, 3.3167953, -2.3599591, -2.848793, 3.324916, -2.2780023, -2.8701415, 3.345823, -2.3413825, -2.9921641, 3.6112561, -2.361228, -3.0135126, 3.4206212, -2.370242, -2.9311528, 3.417915, -2.3975658, -3.0135126, 3.4206212, -2.370242, -2.9311528, 3.3167953, -2.3599591, -2.9311528, 3.417915, -2.3975658, -2.9311528, 3.417915, -2.3975658, -2.9311528, 3.3167953, -2.3599591, -2.8701415, 3.345823, -2.3413825, -2.9921641, 3.6112561, -2.361228, -2.9311528, 3.417915, -2.3975658, -2.9311528, 3.609141, -2.3824706, -2.848793, 3.324916, -2.2780023, -2.8213353, 3.428742, -2.2882862, -2.848793, 3.4206212, -2.370242, -2.848793, 3.324916, -2.2780023, -2.848793, 3.4206212, -2.370242, -2.8701415, 3.345823, -2.3413825, -2.9311528, 3.417915, -2.3975658, -2.8701415, 3.345823, -2.3413825, -2.848793, 3.4206212, -2.370242, -2.9311528, 3.609141, -2.3824706, -2.9311528, 3.417915, -2.3975658, -2.848793, 3.4206212, -2.370242, -2.8213353, 3.428742, -2.2882862, -2.8701415, 3.6112561, -2.361228, -2.848793, 3.4206212, -2.370242, -2.9311528, 3.609141, -2.3824706, -2.848793, 3.4206212, -2.370242, -2.8701415, 3.6112561, -2.361228, -3.085928, 3.6077986, -2.0964143, -2.9311543, 3.695728, -1.988307, -2.9311543, 3.5811217, -2.0463157, -2.9311543, 3.5811217, -2.0463157, -2.9311543, 3.695728, -1.988307, -2.7222042, 3.6880543, -2.0503378, -3.085928, 3.6077986, -2.0964143, -3.1401043, 3.6880543, -2.0503378, -2.9311543, 3.695728, -1.988307, -2.9311543, 3.5811217, -2.0463157, -2.7222042, 3.6880543, -2.0503378, -2.7763805, 3.6077986, -2.0964143, -3.1401043, 3.6073084, -2.2966123, -3.1401043, 3.6880543, -2.0503378, -3.085928, 3.6077986, -2.0964143, -2.7763805, 3.6077986, -2.0964143, -2.7222042, 3.6880543, -2.0503378, -2.6525536, 3.7145243, -2.3075337, -3.1401043, 3.6073084, -2.2966123, -3.2097464, 3.7145243, -2.3075337, -3.1401043, 3.6880543, -2.0503378, -2.7763805, 3.6077986, -2.0964143, -2.6525536, 3.7145243, -2.3075337, -2.7222042, 3.6073084, -2.2966123, -3.085928, 3.6335886, -2.4534798, -3.2097464, 3.7145243, -2.3075337, -3.1401043, 3.6073084, -2.2966123, -2.7222042, 3.6073084, -2.2966123, -2.6525536, 3.7145243, -2.3075337, -2.7222042, 3.697219, -2.5090442, -3.085928, 3.6335886, -2.4534798, -3.1401043, 3.697219, -2.5090442, -3.2097464, 3.7145243, -2.3075337, -2.7222042, 3.6073084, -2.2966123, -2.7222042, 3.697219, -2.5090442, -2.7763805, 3.6335886, -2.4534798, -3.1401043, 3.697219, -2.5090442, -3.085928, 3.6335886, -2.4534798, -2.9311543, 3.5909529, -2.5021935, -2.9311543, 3.687878, -2.5769706, -2.7763805, 3.6335886, -2.4534798, -2.7222042, 3.697219, -2.5090442, -3.1401043, 3.697219, -2.5090442, -2.9311543, 3.5909529, -2.5021935, -2.9311543, 3.687878, -2.5769706, -2.9311543, 3.687878, -2.5769706, -2.9311543, 3.5909529, -2.5021935, -2.7763805, 3.6335886, -2.4534798, -2.9311543, 3.695728, -1.988307, -2.7763805, 3.7984102, -2.1553535, -2.7222042, 3.6880543, -2.0503378, -2.7222042, 3.6880543, -2.0503378, -2.7763805, 3.7984102, -2.1553535, -2.7222042, 3.8329906, -2.3231764, -2.9311543, 3.695728, -1.988307, -2.9311543, 3.845578, -2.1115575, -2.7763805, 3.7984102, -2.1553535, -2.9311543, 3.845578, -2.1115575, -2.7222042, 3.8329906, -2.3231764, -2.7763805, 3.7984102, -2.1553535, -2.7222042, 3.6880543, -2.0503378, -2.7222042, 3.8329906, -2.3231764, -2.6525536, 3.7145243, -2.3075337, -3.1401043, 3.6880543, -2.0503378, -2.9311543, 3.845578, -2.1115575, -2.9311543, 3.695728, -1.988307, -3.1401043, 3.6880543, -2.0503378, -3.085928, 3.7984102, -2.1553535, -2.9311543, 3.845578, -2.1115575, -3.2097464, 3.7145243, -2.3075337, -3.085928, 3.7984102, -2.1553535, -3.1401043, 3.6880543, -2.0503378, -2.9311543, 3.845578, -2.1115575, -2.9311543, 3.8817086, -2.3282094, -2.7222042, 3.8329906, -2.3231764, -3.085928, 3.7984102, -2.1553535, -2.9311543, 3.8817086, -2.3282094, -2.9311543, 3.845578, -2.1115575, -2.6525536, 3.7145243, -2.3075337, -2.7222042, 3.8329906, -2.3231764, -2.7763805, 3.7797475, -2.4554658, -2.9311543, 3.8817086, -2.3282094, -2.7763805, 3.7797475, -2.4554658, -2.7222042, 3.8329906, -2.3231764, -2.6525536, 3.7145243, -2.3075337, -2.7763805, 3.7797475, -2.4554658, -2.7222042, 3.697219, -2.5090442, -3.2097464, 3.7145243, -2.3075337, -3.1401043, 3.8329906, -2.3231764, -3.085928, 3.7984102, -2.1553535, -3.085928, 3.7984102, -2.1553535, -3.1401043, 3.8329906, -2.3231764, -2.9311543, 3.8817086, -2.3282094, -3.1401043, 3.697219, -2.5090442, -3.1401043, 3.8329906, -2.3231764, -3.2097464, 3.7145243, -2.3075337, -2.9311543, 3.812152, -2.5115814, -2.7222042, 3.697219, -2.5090442, -2.7763805, 3.7797475, -2.4554658, -2.9311543, 3.8817086, -2.3282094, -2.9311543, 3.812152, -2.5115814, -2.7763805, 3.7797475, -2.4554658, -3.1401043, 3.8329906, -2.3231764, -2.9311543, 3.812152, -2.5115814, -2.9311543, 3.8817086, -2.3282094, -2.9311543, 3.812152, -2.5115814, -2.9311543, 3.687878, -2.5769706, -2.7222042, 3.697219, -2.5090442, -3.1401043, 3.697219, -2.5090442, -3.085928, 3.7797475, -2.4554658, -3.1401043, 3.8329906, -2.3231764, -3.1401043, 3.8329906, -2.3231764, -3.085928, 3.7797475, -2.4554658, -2.9311543, 3.812152, -2.5115814, -3.085928, 3.7797475, -2.4554658, -3.1401043, 3.697219, -2.5090442, -2.9311543, 3.687878, -2.5769706, -3.085928, 3.7797475, -2.4554658, -2.9311543, 3.687878, -2.5769706, -2.9311543, 3.812152, -2.5115814, -16.306217, 3.8995876, -8.693363, -16.639778, 3.6870875, -8.751106, -16.484772, 3.8599336, -8.475517, -16.306217, 3.8995876, -8.693363, -16.341208, 3.7667115, -8.942926, -16.639778, 3.6870875, -8.751106, -16.484772, 3.8599336, -8.475517, -16.639778, 3.6870875, -8.751106, -16.958773, 3.7667115, -8.595573, -16.484772, 3.8599336, -8.475517, -16.958773, 3.7667115, -8.595573, -16.763668, 3.8995876, -8.436068, -16.639778, 3.6870875, -8.751106, -17.035208, 3.6513293, -8.918842, -16.958773, 3.7667115, -8.595573, -16.639778, 3.6870875, -8.751106, -16.83802, 3.579765, -9.103563, -17.035208, 3.6513293, -8.918842, -16.341208, 3.7667115, -8.942926, -16.83802, 3.579765, -9.103563, -16.639778, 3.6870875, -8.751106, -16.341208, 3.7667115, -8.942926, -16.577757, 3.6513293, -9.17614, -16.83802, 3.579765, -9.103563, -16.108875, 3.6335871, -8.122934, -16.322905, 3.5589838, -8.187733, -16.219904, 3.5909493, -8.004604, -16.108875, 3.6335871, -8.122934, -16.138561, 3.607314, -8.286205, -16.322905, 3.5589838, -8.187733, -16.219904, 3.5909493, -8.004604, -16.322905, 3.5589838, -8.187733, -16.502796, 3.607314, -8.081339, -16.219904, 3.5909493, -8.004604, -16.502796, 3.607314, -8.081339, -16.378687, 3.6335871, -7.9711776, -16.322905, 3.5589838, -8.187733, -16.553732, 3.6077974, -8.2823925, -16.502796, 3.607314, -8.081339, -16.322905, 3.5589838, -8.187733, -16.443384, 3.5811167, -8.401934, -16.553732, 3.6077974, -8.2823925, -16.138561, 3.607314, -8.286205, -16.443384, 3.5811167, -8.401934, -16.322905, 3.5589838, -8.187733, -16.138561, 3.607314, -8.286205, -16.28392, 3.6077974, -8.434149, -16.443384, 3.5811167, -8.401934, -16.577753, 3.651332, -9.176138, -16.915924, 3.7287025, -9.242073, -16.838017, 3.5797634, -9.1035595, -16.838017, 3.5797634, -9.1035595, -16.915924, 3.7287025, -9.242073, -17.174082, 3.7524595, -8.97838, -16.577753, 3.651332, -9.176138, -16.556507, 3.7524595, -9.325739, -16.915924, 3.7287025, -9.242073, -16.838017, 3.5797634, -9.1035595, -17.174082, 3.7524595, -8.97838, -17.035204, 3.651332, -8.91884, -16.341192, 3.766715, -8.942926, -16.556507, 3.7524595, -9.325739, -16.577753, 3.651332, -9.176138, -17.035204, 3.651332, -8.91884, -17.174082, 3.7524595, -8.97838, -17.083666, 3.943858, -8.576735, -16.341192, 3.766715, -8.942926, -16.260246, 3.943858, -9.039871, -16.556507, 3.7524595, -9.325739, -17.035204, 3.651332, -8.91884, -17.083666, 3.943858, -8.576735, -16.958767, 3.7667148, -8.595568, -16.306215, 3.8995948, -8.693367, -16.260246, 3.943858, -9.039871, -16.341192, 3.766715, -8.942926, -16.958767, 3.7667148, -8.595568, -17.083666, 3.943858, -8.576735, -16.81843, 4.033209, -8.346062, -16.306215, 3.8995948, -8.693367, -16.200855, 4.033209, -8.6934185, -16.260246, 3.943858, -9.039871, -16.958767, 3.7667148, -8.595568, -16.81843, 4.033209, -8.346062, -16.763666, 3.8995945, -8.4360695, -16.200855, 4.033209, -8.6934185, -16.306215, 3.8995948, -8.693367, -16.484772, 3.8599298, -8.475521, -16.453936, 4.0577397, -8.420694, -16.763666, 3.8995945, -8.4360695, -16.81843, 4.033209, -8.346062, -16.200855, 4.033209, -8.6934185, -16.484772, 3.8599298, -8.475521, -16.453936, 4.0577397, -8.420694, -16.453936, 4.0577397, -8.420694, -16.484772, 3.8599298, -8.475521, -16.763666, 3.8995945, -8.4360695, -16.915924, 3.7287025, -9.242073, -17.043386, 3.9892056, -8.933388, -17.174082, 3.7524595, -8.97838, -17.174082, 3.7524595, -8.97838, -17.043386, 3.9892056, -8.933388, -17.00221, 4.141672, -8.672808, -16.915924, 3.7287025, -9.242073, -16.862286, 4.038946, -9.146708, -17.043386, 3.9892056, -8.933388, -16.862286, 4.038946, -9.146708, -17.00221, 4.141672, -8.672808, -17.043386, 3.9892056, -8.933388, -17.174082, 3.7524595, -8.97838, -17.00221, 4.141672, -8.672808, -17.083666, 3.943858, -8.576735, -16.556507, 3.7524595, -9.325739, -16.862286, 4.038946, -9.146708, -16.915924, 3.7287025, -9.242073, -16.556507, 3.7524595, -9.325739, -16.585936, 3.9892056, -9.190685, -16.862286, 4.038946, -9.146708, -16.260246, 3.943858, -9.039871, -16.585936, 3.9892056, -9.190685, -16.556507, 3.7524595, -9.325739, -16.862286, 4.038946, -9.146708, -16.703352, 4.2222047, -8.864136, -17.00221, 4.141672, -8.672808, -16.585936, 3.9892056, -9.190685, -16.703352, 4.2222047, -8.864136, -16.862286, 4.038946, -9.146708, -17.083666, 3.943858, -8.576735, -17.00221, 4.141672, -8.672808, -16.803688, 4.1336036, -8.507227, -16.703352, 4.2222047, -8.864136, -16.803688, 4.1336036, -8.507227, -17.00221, 4.141672, -8.672808, -17.083666, 3.943858, -8.576735, -16.803688, 4.1336036, -8.507227, -16.81843, 4.033209, -8.346062, -16.260246, 3.943858, -9.039871, -16.384638, 4.141672, -9.020166, -16.585936, 3.9892056, -9.190685, -16.585936, 3.9892056, -9.190685, -16.384638, 4.141672, -9.020166, -16.703352, 4.2222047, -8.864136, -16.200855, 4.033209, -8.6934185, -16.384638, 4.141672, -9.020166, -16.260246, 3.943858, -9.039871, -16.540348, 4.2177877, -8.574329, -16.81843, 4.033209, -8.346062, -16.803688, 4.1336036, -8.507227, -16.703352, 4.2222047, -8.864136, -16.540348, 4.2177877, -8.574329, -16.803688, 4.1336036, -8.507227, -16.384638, 4.141672, -9.020166, -16.540348, 4.2177877, -8.574329, -16.703352, 4.2222047, -8.864136, -16.540348, 4.2177877, -8.574329, -16.453936, 4.0577397, -8.420694, -16.81843, 4.033209, -8.346062, -16.200855, 4.033209, -8.6934185, -16.346237, 4.1336036, -8.764523, -16.384638, 4.141672, -9.020166, -16.384638, 4.141672, -9.020166, -16.346237, 4.1336036, -8.764523, -16.540348, 4.2177877, -8.574329, -16.346237, 4.1336036, -8.764523, -16.200855, 4.033209, -8.6934185, -16.453936, 4.0577397, -8.420694, -16.346237, 4.1336036, -8.764523, -16.453936, 4.0577397, -8.420694, -16.540348, 4.2177877, -8.574329, -16.548744, 3.324795, -8.800255, -16.694132, 3.4312131, -8.847736, -16.650524, 3.2713976, -8.770206, -16.548744, 3.324795, -8.800255, -16.550304, 3.4427547, -8.876886, -16.694132, 3.4312131, -8.847736, -16.462482, 3.3060188, -8.720747, -16.548744, 3.324795, -8.800255, -16.650524, 3.2713976, -8.770206, -16.462482, 3.3060188, -8.720747, -16.550304, 3.4427547, -8.876886, -16.548744, 3.324795, -8.800255, -16.550304, 3.4427547, -8.876886, -16.716091, 3.7502089, -8.886778, -16.694132, 3.4312131, -8.847736, -16.694132, 3.4312131, -8.847736, -16.716091, 3.7502089, -8.886778, -16.789055, 3.7591841, -8.805498, -16.550304, 3.4427547, -8.876886, -16.608738, 3.7591841, -8.906918, -16.716091, 3.7502089, -8.886778, -16.694132, 3.4312131, -8.847736, -16.789055, 3.7591841, -8.805498, -16.793737, 3.4427545, -8.739965, -16.650524, 3.2713976, -8.770206, -16.694132, 3.4312131, -8.847736, -16.793737, 3.4427545, -8.739965, -16.793737, 3.4427545, -8.739965, -16.789055, 3.7591841, -8.805498, -16.771488, 3.784827, -8.700407, -16.44341, 3.477376, -8.781784, -16.608738, 3.7591841, -8.906918, -16.550304, 3.4427547, -8.876886, -16.462482, 3.3060188, -8.720747, -16.44341, 3.477376, -8.781784, -16.550304, 3.4427547, -8.876886, -16.44341, 3.477376, -8.781784, -16.528055, 3.784827, -8.837328, -16.608738, 3.7591841, -8.906918, -16.793737, 3.4427545, -8.739965, -16.771488, 3.784827, -8.700407, -16.767986, 3.477376, -8.599226, -16.767986, 3.477376, -8.599226, -16.771488, 3.784827, -8.700407, -16.6908, 3.810473, -8.630808, -16.650524, 3.2713976, -8.770206, -16.793737, 3.4427545, -8.739965, -16.729061, 3.324795, -8.698833, -16.729061, 3.324795, -8.698833, -16.793737, 3.4427545, -8.739965, -16.767986, 3.477376, -8.599226, -16.462482, 3.3060188, -8.720747, -16.650524, 3.2713976, -8.770206, -16.577034, 3.2488902, -8.639545, -16.577034, 3.2488902, -8.639545, -16.650524, 3.2713976, -8.770206, -16.729061, 3.324795, -8.698833, -16.450489, 3.3760836, -8.6255665, -16.44341, 3.477376, -8.781784, -16.462482, 3.3060188, -8.720747, -16.450489, 3.3760836, -8.6255665, -16.462482, 3.3060188, -8.720747, -16.577034, 3.2488902, -8.639545, -16.417662, 3.5119941, -8.641058, -16.528055, 3.784827, -8.837328, -16.44341, 3.477376, -8.781784, -16.450489, 3.3760836, -8.6255665, -16.417662, 3.5119941, -8.641058, -16.44341, 3.477376, -8.781784, -16.417662, 3.5119941, -8.641058, -16.510483, 3.810473, -8.732229, -16.528055, 3.784827, -8.837328, -16.450489, 3.3760836, -8.6255665, -16.577034, 3.2488902, -8.639545, -16.517881, 3.340637, -8.534378, -16.417662, 3.5119941, -8.641058, -16.450489, 3.3760836, -8.6255665, -16.517881, 3.340637, -8.534378, -16.577034, 3.2488902, -8.639545, -16.729061, 3.324795, -8.698833, -16.705917, 3.3060188, -8.583826, -16.517881, 3.340637, -8.534378, -16.577034, 3.2488902, -8.639545, -16.705917, 3.3060188, -8.583826, -16.729061, 3.324795, -8.698833, -16.767986, 3.477376, -8.599226, -16.705917, 3.3060188, -8.583826, -16.517881, 3.340637, -8.534378, -16.705917, 3.3060188, -8.583826, -16.630806, 3.3760836, -8.524145, -16.510483, 3.810473, -8.732229, -16.417662, 3.5119941, -8.641058, -16.517267, 3.5235355, -8.533287, -16.417662, 3.5119941, -8.641058, -16.517881, 3.340637, -8.534378, -16.517267, 3.5235355, -8.533287, -16.517267, 3.5235355, -8.533287, -16.517881, 3.340637, -8.534378, -16.630806, 3.3760836, -8.524145, -16.510483, 3.810473, -8.732229, -16.517267, 3.5235355, -8.533287, -16.583447, 3.8194482, -8.6509495, -16.705917, 3.3060188, -8.583826, -16.767986, 3.477376, -8.599226, -16.661095, 3.5119941, -8.504137, -16.705917, 3.3060188, -8.583826, -16.661095, 3.5119941, -8.504137, -16.630806, 3.3760836, -8.524145, -16.517267, 3.5235355, -8.533287, -16.630806, 3.3760836, -8.524145, -16.661095, 3.5119941, -8.504137, -16.583447, 3.8194482, -8.6509495, -16.517267, 3.5235355, -8.533287, -16.661095, 3.5119941, -8.504137, -16.767986, 3.477376, -8.599226, -16.6908, 3.810473, -8.630808, -16.661095, 3.5119941, -8.504137, -16.583447, 3.8194482, -8.6509495, -16.661095, 3.5119941, -8.504137, -16.6908, 3.810473, -8.630808, -16.30509, 3.357844, -8.280506, -16.37834, 3.439557, -8.2862835, -16.369993, 3.333033, -8.271444, -16.30509, 3.357844, -8.280506, -16.293167, 3.436849, -8.302855, -16.37834, 3.439557, -8.2862835, -16.258024, 3.324914, -8.240377, -16.30509, 3.357844, -8.280506, -16.369993, 3.333033, -8.271444, -16.258024, 3.324914, -8.240377, -16.293167, 3.436849, -8.302855, -16.30509, 3.357844, -8.280506, -16.293167, 3.436849, -8.302855, -16.358952, 3.625382, -8.251812, -16.37834, 3.439557, -8.2862835, -16.37834, 3.439557, -8.2862835, -16.358952, 3.625382, -8.251812, -16.401714, 3.6232772, -8.20339, -16.293167, 3.436849, -8.302855, -16.29536, 3.6232772, -8.263208, -16.358952, 3.625382, -8.251812, -16.37834, 3.439557, -8.2862835, -16.401714, 3.6232772, -8.20339, -16.436735, 3.436849, -8.222103, -16.369993, 3.333033, -8.271444, -16.37834, 3.439557, -8.2862835, -16.436735, 3.436849, -8.222103, -16.436735, 3.436849, -8.222103, -16.401714, 3.6232772, -8.20339, -16.390556, 3.61726, -8.140003, -16.229052, 3.4287403, -8.244873, -16.29536, 3.6232772, -8.263208, -16.293167, 3.436849, -8.302855, -16.258024, 3.324914, -8.240377, -16.229052, 3.4287403, -8.244873, -16.293167, 3.436849, -8.302855, -16.229052, 3.4287403, -8.244873, -16.246988, 3.61726, -8.220755, -16.29536, 3.6232772, -8.263208, -16.436735, 3.436849, -8.222103, -16.390556, 3.61726, -8.140003, -16.420483, 3.4287403, -8.137201, -16.420483, 3.4287403, -8.137201, -16.390556, 3.61726, -8.140003, -16.342186, 3.6112542, -8.097551, -16.369993, 3.333033, -8.271444, -16.436735, 3.436849, -8.222103, -16.411444, 3.357844, -8.220686, -16.411444, 3.357844, -8.220686, -16.436735, 3.436849, -8.222103, -16.420483, 3.4287403, -8.137201, -16.258024, 3.324914, -8.240377, -16.369993, 3.333033, -8.271444, -16.331488, 3.290307, -8.202984, -16.331488, 3.290307, -8.202984, -16.369993, 3.333033, -8.271444, -16.411444, 3.357844, -8.220686, -16.245562, 3.3458211, -8.174669, -16.229052, 3.4287403, -8.244873, -16.258024, 3.324914, -8.240377, -16.245562, 3.3458211, -8.174669, -16.258024, 3.324914, -8.240377, -16.331488, 3.290307, -8.202984, -16.212805, 3.4206195, -8.159981, -16.246988, 3.61726, -8.220755, -16.229052, 3.4287403, -8.244873, -16.245562, 3.3458211, -8.174669, -16.212805, 3.4206195, -8.159981, -16.229052, 3.4287403, -8.244873, -16.212805, 3.4206195, -8.159981, -16.235832, 3.6112542, -8.1573715, -16.246988, 3.61726, -8.220755, -16.245562, 3.3458211, -8.174669, -16.331488, 3.290307, -8.202984, -16.28963, 3.3167934, -8.128569, -16.212805, 3.4206195, -8.159981, -16.245562, 3.3458211, -8.174669, -16.28963, 3.3167934, -8.128569, -16.331488, 3.290307, -8.202984, -16.411444, 3.357844, -8.220686, -16.401594, 3.324914, -8.159626, -16.28963, 3.3167934, -8.128569, -16.331488, 3.290307, -8.202984, -16.401594, 3.324914, -8.159626, -16.411444, 3.357844, -8.220686, -16.420483, 3.4287403, -8.137201, -16.401594, 3.324914, -8.159626, -16.28963, 3.3167934, -8.128569, -16.401594, 3.324914, -8.159626, -16.351915, 3.3458211, -8.114849, -16.235832, 3.6112542, -8.1573715, -16.212805, 3.4206195, -8.159981, -16.271194, 3.4179132, -8.095791, -16.212805, 3.4206195, -8.159981, -16.28963, 3.3167934, -8.128569, -16.271194, 3.4179132, -8.095791, -16.271194, 3.4179132, -8.095791, -16.28963, 3.3167934, -8.128569, -16.351915, 3.3458211, -8.114849, -16.235832, 3.6112542, -8.1573715, -16.271194, 3.4179132, -8.095791, -16.278595, 3.6091392, -8.108948, -16.401594, 3.324914, -8.159626, -16.420483, 3.4287403, -8.137201, -16.356375, 3.4206195, -8.079229, -16.401594, 3.324914, -8.159626, -16.356375, 3.4206195, -8.079229, -16.351915, 3.3458211, -8.114849, -16.271194, 3.4179132, -8.095791, -16.351915, 3.3458211, -8.114849, -16.356375, 3.4206195, -8.079229, -16.278595, 3.6091392, -8.108948, -16.271194, 3.4179132, -8.095791, -16.356375, 3.4206195, -8.079229, -16.420483, 3.4287403, -8.137201, -16.342186, 3.6112542, -8.097551, -16.356375, 3.4206195, -8.079229, -16.278595, 3.6091392, -8.108948, -16.356375, 3.4206195, -8.079229, -16.342186, 3.6112542, -8.097551, -16.283928, 3.6077967, -8.434147, -16.471827, 3.6957262, -8.4524975, -16.443388, 3.5811198, -8.4019375, -16.443388, 3.5811198, -8.4019375, -16.471827, 3.6957262, -8.4524975, -16.623535, 3.6880524, -8.295998, -16.283928, 3.6077967, -8.434147, -16.259296, 3.6880524, -8.500866, -16.471827, 3.6957262, -8.4524975, -16.443388, 3.5811198, -8.4019375, -16.623535, 3.6880524, -8.295998, -16.553728, 3.6077967, -8.282396, -16.138565, 3.6073067, -8.286215, -16.259296, 3.6880524, -8.500866, -16.283928, 3.6077967, -8.434147, -16.553728, 3.6077967, -8.282396, -16.623535, 3.6880524, -8.295998, -16.558157, 3.7145224, -8.0376835, -16.138565, 3.6073067, -8.286215, -16.072512, 3.7145224, -8.310837, -16.259296, 3.6880524, -8.500866, -16.553728, 3.6077967, -8.282396, -16.558157, 3.7145224, -8.0376835, -16.502804, 3.6073067, -8.0813465, -16.108883, 3.6335866, -8.122932, -16.072512, 3.7145224, -8.310837, -16.138565, 3.6073067, -8.286215, -16.502804, 3.6073067, -8.0813465, -16.558157, 3.7145224, -8.0376835, -16.398663, 3.697217, -7.896193, -16.108883, 3.6335866, -8.122932, -16.034424, 3.697217, -8.101061, -16.072512, 3.7145224, -8.310837, -16.502804, 3.6073067, -8.0813465, -16.398663, 3.697217, -7.896193, -16.378683, 3.6335866, -7.9711814, -16.034424, 3.697217, -8.101061, -16.108883, 3.6335866, -8.122932, -16.219902, 3.5909512, -8.004599, -16.183245, 3.687876, -7.939423, -16.378683, 3.6335866, -7.9711814, -16.398663, 3.697217, -7.896193, -16.034424, 3.697217, -8.101061, -16.219902, 3.5909512, -8.004599, -16.183245, 3.687876, -7.939423, -16.183245, 3.687876, -7.939423, -16.219902, 3.5909512, -8.004599, -16.378683, 3.6335866, -7.9711814, -16.471827, 3.6957262, -8.4524975, -16.524834, 3.7984083, -8.231026, -16.623535, 3.6880524, -8.295998, -16.623535, 3.6880524, -8.295998, -16.524834, 3.7984083, -8.231026, -16.48978, 3.8329887, -8.058193, -16.471827, 3.6957262, -8.4524975, -16.411404, 3.845576, -8.345074, -16.524834, 3.7984083, -8.231026, -16.411404, 3.845576, -8.345074, -16.48978, 3.8329887, -8.058193, -16.524834, 3.7984083, -8.231026, -16.623535, 3.6880524, -8.295998, -16.48978, 3.8329887, -8.058193, -16.558157, 3.7145224, -8.0376835, -16.259296, 3.6880524, -8.500866, -16.411404, 3.845576, -8.345074, -16.471827, 3.6957262, -8.4524975, -16.259296, 3.6880524, -8.500866, -16.255035, 3.7984083, -8.382776, -16.411404, 3.845576, -8.345074, -16.072512, 3.7145224, -8.310837, -16.255035, 3.7984083, -8.382776, -16.259296, 3.6880524, -8.500866, -16.411404, 3.845576, -8.345074, -16.305195, 3.881707, -8.156242, -16.48978, 3.8329887, -8.058193, -16.255035, 3.7984083, -8.382776, -16.305195, 3.881707, -8.156242, -16.411404, 3.845576, -8.345074, -16.558157, 3.7145224, -8.0376835, -16.48978, 3.8329887, -8.058193, -16.377708, 3.7797456, -7.9694505, -16.305195, 3.881707, -8.156242, -16.377708, 3.7797456, -7.9694505, -16.48978, 3.8329887, -8.058193, -16.558157, 3.7145224, -8.0376835, -16.377708, 3.7797456, -7.9694505, -16.398663, 3.697217, -7.896193, -16.072512, 3.7145224, -8.310837, -16.125544, 3.8329887, -8.263062, -16.255035, 3.7984083, -8.382776, -16.255035, 3.7984083, -8.382776, -16.125544, 3.8329887, -8.263062, -16.305195, 3.881707, -8.156242, -16.034424, 3.697217, -8.101061, -16.125544, 3.8329887, -8.263062, -16.072512, 3.7145224, -8.310837, -16.2153, 3.8121502, -7.9964156, -16.398663, 3.697217, -7.896193, -16.377708, 3.7797456, -7.9694505, -16.305195, 3.881707, -8.156242, -16.2153, 3.8121502, -7.9964156, -16.377708, 3.7797456, -7.9694505, -16.125544, 3.8329887, -8.263062, -16.2153, 3.8121502, -7.9964156, -16.305195, 3.881707, -8.156242, -16.2153, 3.8121502, -7.9964156, -16.183245, 3.687876, -7.939423, -16.398663, 3.697217, -7.896193, -16.034424, 3.697217, -8.101061, -16.10791, 3.7797456, -8.121201, -16.125544, 3.8329887, -8.263062, -16.125544, 3.8329887, -8.263062, -16.10791, 3.7797456, -8.121201, -16.2153, 3.8121502, -7.9964156, -16.10791, 3.7797456, -8.121201, -16.034424, 3.697217, -8.101061, -16.183245, 3.687876, -7.939423, -16.10791, 3.7797456, -8.121201, -16.183245, 3.687876, -7.939423, -16.2153, 3.8121502, -7.9964156, -7.086538, 9.0531025, 23.60468, -7.939819, 8.776951, 23.477636, -7.89693, 8.906714, 23.382841, -7.086538, 9.0531025, 23.60468, -7.1825204, 8.414909, 24.04463, -7.939819, 8.776951, 23.477636, -6.961786, 8.872082, 22.884235, -7.2900796, 8.958991, 23.246565, -7.2749104, 8.879946, 22.997782, -6.961786, 8.872082, 22.884235, -7.086538, 9.0531025, 23.60468, -7.2900796, 8.958991, 23.246565, -3.8860898, 6.9690914, 23.62356, -4.5446486, 7.7101564, 23.798996, -4.6237864, 7.655897, 23.206228, -3.8860898, 6.9690914, 23.62356, -3.8783517, 6.953966, 23.7307, -4.5446486, 7.7101564, 23.798996, -4.5446486, 7.7101564, 23.798996, -4.823362, 7.815732, 23.123234, -4.6237864, 7.655897, 23.206228, -4.5446486, 7.7101564, 23.798996, -4.899761, 7.976705, 23.460878, -4.823362, 7.815732, 23.123234, -4.899761, 7.976705, 23.460878, -4.5446486, 7.7101564, 23.798996, -5.2783494, 8.404766, 23.83276, -4.899761, 7.976705, 23.460878, -5.2783494, 8.404766, 23.83276, -5.3587403, 8.23149, 22.899555, -4.899761, 7.976705, 23.460878, -5.3587403, 8.23149, 22.899555, -5.0027404, 7.957439, 23.048418, -5.3587403, 8.23149, 22.899555, -5.5498886, 8.408549, 23.109316, -5.46656, 8.274823, 22.889973, -5.3587403, 8.23149, 22.899555, -5.2783494, 8.404766, 23.83276, -5.5498886, 8.408549, 23.109316, -6.134132, 8.667774, 22.83608, -6.545529, 8.861699, 23.146437, -6.347387, 8.715011, 22.84835, -6.134132, 8.667774, 22.83608, -6.2255015, 8.901371, 23.757235, -6.545529, 8.861699, 23.146437, -3.9216104, 6.895205, 23.810205, -4.5446486, 7.7101564, 23.798996, -3.8783517, 6.953966, 23.7307, -4.5446486, 7.7101564, 23.798996, -3.9216104, 6.895205, 23.810205, -4.32818, 7.252925, 23.874067, -4.32818, 7.252925, 23.874067, -3.9216104, 6.895205, 23.810205, -4.2883744, 7.0421944, 23.9518, -4.5446486, 7.7101564, 23.798996, -5.1261964, 7.754751, 24.078827, -5.2783494, 8.404766, 23.83276, -4.5446486, 7.7101564, 23.798996, -4.9056582, 7.542821, 24.083008, -5.1261964, 7.754751, 24.078827, -5.6396194, 7.681168, 24.366714, -6.2255015, 8.901371, 23.757235, -5.2783494, 8.404766, 23.83276, -6.2255015, 8.901371, 23.757235, -5.6396194, 7.681168, 24.366714, -6.046368, 7.8717294, 24.331638, -6.2255015, 8.901371, 23.757235, -6.046368, 7.8717294, 24.331638, -6.119331, 8.248653, 24.137571, -6.7146773, 8.708108, 23.8794, -7.086538, 9.0531025, 23.60468, -6.2255015, 8.901371, 23.757235, -7.086538, 9.0531025, 23.60468, -6.7146773, 8.708108, 23.8794, -7.0044417, 8.349307, 24.104824, -7.086538, 9.0531025, 23.60468, -7.0044417, 8.349307, 24.104824, -7.1825204, 8.414909, 24.04463, -6.2255015, 8.901371, 23.757235, -5.5498886, 8.408549, 23.109316, -5.2783494, 8.404766, 23.83276, -5.953386, 8.703066, 23.382, -5.5498886, 8.408549, 23.109316, -6.2255015, 8.901371, 23.757235, -5.5498886, 8.408549, 23.109316, -5.953386, 8.703066, 23.382, -5.8336997, 8.425533, 22.857294, -5.5498886, 8.408549, 23.109316, -5.8336997, 8.425533, 22.857294, -5.650786, 8.361013, 22.874142, -6.2255015, 8.901371, 23.757235, -5.9328055, 8.570662, 22.853338, -5.953386, 8.703066, 23.382, -6.2255015, 8.901371, 23.757235, -6.134132, 8.667774, 22.83608, -5.9328055, 8.570662, 22.853338, -6.119331, 8.248653, 24.137571, -6.4117084, 8.071957, 24.301369, -6.2255015, 8.901371, 23.757235, -6.119331, 8.248653, 24.137571, -6.237124, 7.9882894, 24.31635, -6.4117084, 8.071957, 24.301369, -7.086538, 9.0531025, 23.60468, -6.545529, 8.861699, 23.146437, -6.2255015, 8.901371, 23.757235, -6.545529, 8.861699, 23.146437, -7.086538, 9.0531025, 23.60468, -6.961786, 8.872082, 22.884235, -6.545529, 8.861699, 23.146437, -6.961786, 8.872082, 22.884235, -6.66091, 8.812481, 22.867199, -6.7146773, 8.708108, 23.8794, -6.4117084, 8.071957, 24.301369, -6.875569, 8.223388, 24.143358, -6.7146773, 8.708108, 23.8794, -6.2255015, 8.901371, 23.757235, -6.4117084, 8.071957, 24.301369, -7.89693, 8.906714, 23.382841, -7.2900796, 8.958991, 23.246565, -7.086538, 9.0531025, 23.60468, -7.2900796, 8.958991, 23.246565, -7.89693, 8.906714, 23.382841, -7.890431, 8.871303, 23.22045, -7.2900796, 8.958991, 23.246565, -7.890431, 8.871303, 23.22045, -7.484878, 8.8925495, 23.073898, -5.1261964, 7.754751, 24.078827, -5.6396194, 7.681168, 24.366714, -5.2783494, 8.404766, 23.83276, -5.1261964, 7.754751, 24.078827, -5.328693, 7.544381, 24.286783, -5.6396194, 7.681168, 24.366714, -4.9056582, 7.542821, 24.083008, -5.1280284, 7.4066963, 24.234045, -5.1261964, 7.754751, 24.078827, -4.9056582, 7.542821, 24.083008, -5.0212817, 7.379748, 24.207062, -5.1280284, 7.4066963, 24.234045, -4.9056582, 7.542821, 24.083008, -4.5446486, 7.7101564, 23.798996, -4.804087, 7.2903175, 24.15141, -4.32818, 7.252925, 23.874067, -4.804087, 7.2903175, 24.15141, -4.5446486, 7.7101564, 23.798996, -4.32818, 7.252925, 23.874067, -4.4737935, 7.144829, 24.02375, -4.804087, 7.2903175, 24.15141, -8.469263, 8.833129, 23.903402, -7.7564616, 8.8668, 23.1668, -7.7024093, 8.948188, 23.326448, -8.469263, 8.833129, 23.903402, -8.792166, 8.398045, 23.2395, -7.7564616, 8.8668, 23.1668, -7.939649, 8.319658, 24.36807, -8.019506, 8.693222, 23.9122, -7.814714, 8.502601, 24.04807, -7.939649, 8.319658, 24.36807, -8.469263, 8.833129, 23.903402, -8.019506, 8.693222, 23.9122, -10.742025, 5.3900876, 25.65433, -10.427696, 6.4519587, 25.310745, -9.86124, 6.2139215, 25.59392, -10.742025, 5.3900876, 25.65433, -10.839937, 5.4104342, 25.586737, -10.427696, 6.4519587, 25.310745, -10.427696, 6.4519587, 25.310745, -9.6490345, 6.4258494, 25.54795, -9.86124, 6.2139215, 25.59392, -10.427696, 6.4519587, 25.310745, -9.8865795, 6.741735, 25.338587, -9.6490345, 6.4258494, 25.54795, -9.8865795, 6.741735, 25.338587, -10.427696, 6.4519587, 25.310745, -9.939594, 7.468337, 24.97506, -9.8865795, 6.741735, 25.338587, -9.939594, 7.468337, 24.97506, -9.078133, 6.986556, 25.424217, -9.8865795, 6.741735, 25.338587, -9.078133, 6.986556, 25.424217, -9.458067, 6.6153927, 25.506601, -9.078133, 6.986556, 25.424217, -9.126747, 7.324047, 25.200722, -8.992649, 7.0699244, 25.362774, -9.078133, 6.986556, 25.424217, -9.939594, 7.468337, 24.97506, -9.126747, 7.324047, 25.200722, -8.48115, 7.732603, 24.99367, -8.460228, 8.226148, 24.537516, -8.340681, 7.875271, 24.831814, -8.48115, 7.732603, 24.99367, -9.20825, 8.35639, 24.448666, -8.460228, 8.226148, 24.537516, -10.878963, 5.39891, 25.472626, -10.427696, 6.4519587, 25.310745, -10.839937, 5.4104342, 25.586737, -10.427696, 6.4519587, 25.310745, -10.878963, 5.39891, 25.472626, -10.647385, 5.933868, 25.242573, -10.647385, 5.933868, 25.242573, -10.878963, 5.39891, 25.472626, -10.741941, 5.7328005, 25.138784, -10.427696, 6.4519587, 25.310745, -10.264824, 6.8368664, 24.649649, -9.939594, 7.468337, 24.97506, -10.427696, 6.4519587, 25.310745, -10.423819, 6.5334654, 24.740517, -10.264824, 6.8368664, 24.649649, -10.154926, 7.080727, 23.99825, -9.20825, 8.35639, 24.448666, -9.939594, 7.468337, 24.97506, -9.20825, 8.35639, 24.448666, -10.154926, 7.080727, 23.99825, -9.836353, 7.4272223, 23.768997, -9.20825, 8.35639, 24.448666, -9.836353, 7.4272223, 23.768997, -9.618614, 7.784581, 23.992184, -8.975733, 8.430757, 23.866367, -8.469263, 8.833129, 23.903402, -9.20825, 8.35639, 24.448666, -8.469263, 8.833129, 23.903402, -8.975733, 8.430757, 23.866367, -8.970964, 8.277404, 23.316208, -8.469263, 8.833129, 23.903402, -8.970964, 8.277404, 23.316208, -8.792166, 8.398045, 23.2395, -9.20825, 8.35639, 24.448666, -9.126747, 7.324047, 25.200722, -9.939594, 7.468337, 24.97506, -9.079264, 7.896097, 24.814846, -9.126747, 7.324047, 25.200722, -9.20825, 8.35639, 24.448666, -9.126747, 7.324047, 25.200722, -9.079264, 7.896097, 24.814846, -8.702198, 7.3584385, 25.153973, -9.126747, 7.324047, 25.200722, -8.702198, 7.3584385, 25.153973, -8.848383, 7.227356, 25.258986, -9.20825, 8.35639, 24.448666, -8.638075, 7.5534444, 25.106686, -9.079264, 7.896097, 24.814846, -9.20825, 8.35639, 24.448666, -8.48115, 7.732603, 24.99367, -8.638075, 7.5534444, 25.106686, -9.618614, 7.784581, 23.992184, -9.555359, 7.787944, 23.5661, -9.20825, 8.35639, 24.448666, -9.618614, 7.784581, 23.992184, -9.691614, 7.632971, 23.664253, -9.555359, 7.787944, 23.5661, -8.469263, 8.833129, 23.903402, -8.460228, 8.226148, 24.537516, -9.20825, 8.35639, 24.448666, -8.460228, 8.226148, 24.537516, -8.469263, 8.833129, 23.903402, -7.939649, 8.319658, 24.36807, -8.460228, 8.226148, 24.537516, -7.939649, 8.319658, 24.36807, -8.1381, 8.120274, 24.596785, -8.975733, 8.430757, 23.866367, -9.555359, 7.787944, 23.5661, -9.08618, 8.070576, 23.364449, -8.975733, 8.430757, 23.866367, -9.20825, 8.35639, 24.448666, -9.555359, 7.787944, 23.5661, -7.7024093, 8.948188, 23.326448, -8.019506, 8.693222, 23.9122, -8.469263, 8.833129, 23.903402, -8.019506, 8.693222, 23.9122, -7.7024093, 8.948188, 23.326448, -7.56801, 8.854256, 23.41686, -8.019506, 8.693222, 23.9122, -7.56801, 8.854256, 23.41686, -7.7330556, 8.652529, 23.835022, -10.264824, 6.8368664, 24.649649, -10.154926, 7.080727, 23.99825, -9.939594, 7.468337, 24.97506, -10.264824, 6.8368664, 24.649649, -10.304508, 6.7830296, 24.250587, -10.154926, 7.080727, 23.99825, -10.423819, 6.5334654, 24.740517, -10.394926, 6.530945, 24.408104, -10.264824, 6.8368664, 24.649649, -10.423819, 6.5334654, 24.740517, -10.448719, 6.45382, 24.497004, -10.394926, 6.530945, 24.408104, -10.423819, 6.5334654, 24.740517, -10.427696, 6.4519587, 25.310745, -10.554361, 6.258996, 24.674118, -10.647385, 5.933868, 25.242573, -10.554361, 6.258996, 24.674118, -10.427696, 6.4519587, 25.310745, -10.647385, 5.933868, 25.242573, -10.675802, 5.9358487, 24.97307, -10.554361, 6.258996, 24.674118, -8.476179, 9.272415, 23.238873, -7.6357913, 8.757383, 23.254288, -7.649511, 8.880784, 23.39443, -8.476179, 9.272415, 23.238873, -8.520136, 8.706246, 22.59189, -7.6357913, 8.757383, 23.254288, -8.663195, 8.882344, 23.984976, -8.29641, 9.024534, 23.588913, -8.32843, 8.858036, 23.843124, -8.663195, 8.882344, 23.984976, -8.476179, 9.272415, 23.238873, -8.29641, 9.024534, 23.588913, -12.152782, 7.7485704, 22.986092, -11.339409, 8.419413, 22.91118, -11.277941, 8.172669, 23.538235, -12.152782, 7.7485704, 22.986092, -12.161711, 7.7648354, 22.866318, -11.339409, 8.419413, 22.91118, -11.339409, 8.419413, 22.91118, -11.045532, 8.272723, 23.64941, -11.277941, 8.172669, 23.538235, -11.339409, 8.419413, 22.91118, -10.934807, 8.523428, 23.311256, -11.045532, 8.272723, 23.64941, -10.934807, 8.523428, 23.311256, -11.339409, 8.419413, 22.91118, -10.46002, 9.004967, 22.97543, -10.934807, 8.523428, 23.311256, -10.46002, 9.004967, 22.97543, -10.420095, 8.532848, 23.947876, -10.934807, 8.523428, 23.311256, -10.420095, 8.532848, 23.947876, -10.836201, 8.361673, 23.749374, -10.420095, 8.532848, 23.947876, -10.197205, 8.742559, 23.74126, -10.297283, 8.550915, 23.961588, -10.420095, 8.532848, 23.947876, -10.46002, 9.004967, 22.97543, -10.197205, 8.742559, 23.74126, -9.557905, 8.814163, 24.051355, -9.097107, 9.033176, 23.725506, -9.326338, 8.822926, 24.033926, -9.557905, 8.814163, 24.051355, -9.396812, 9.323492, 23.09922, -9.097107, 9.033176, 23.725506, -12.129438, 7.7163987, 22.76176, -11.339409, 8.419413, 22.91118, -12.161711, 7.7648354, 22.866318, -11.339409, 8.419413, 22.91118, -12.129438, 7.7163987, 22.76176, -11.6488285, 8.007675, 22.739779, -11.6488285, 8.007675, 22.739779, -12.129438, 7.7163987, 22.76176, -11.723551, 7.814352, 22.611177, -11.339409, 8.419413, 22.91118, -10.748026, 8.414791, 22.562681, -10.46002, 9.004967, 22.97543, -11.339409, 8.419413, 22.91118, -11.012053, 8.2395935, 22.52716, -10.748026, 8.414791, 22.562681, -10.238874, 8.305614, 22.187637, -9.396812, 9.323492, 23.09922, -10.46002, 9.004967, 22.97543, -9.396812, 9.323492, 23.09922, -10.238874, 8.305614, 22.187637, -9.780174, 8.406629, 22.240479, -9.396812, 9.323492, 23.09922, -9.780174, 8.406629, 22.240479, -9.642679, 8.753303, 22.532566, -8.943731, 9.065945, 22.87788, -8.476179, 9.272415, 23.238873, -9.396812, 9.323492, 23.09922, -8.476179, 9.272415, 23.238873, -8.943731, 9.065945, 22.87788, -8.7198925, 8.690582, 22.52244, -8.476179, 9.272415, 23.238873, -8.7198925, 8.690582, 22.52244, -8.520136, 8.706246, 22.59189, -9.396812, 9.323492, 23.09922, -10.197205, 8.742559, 23.74126, -10.46002, 9.004967, 22.97543, -9.730011, 9.053403, 23.47832, -10.197205, 8.742559, 23.74126, -9.396812, 9.323492, 23.09922, -10.197205, 8.742559, 23.74126, -9.730011, 9.053403, 23.47832, -9.879805, 8.6168785, 24.008417, -10.197205, 8.742559, 23.74126, -9.879805, 8.6168785, 24.008417, -10.089674, 8.597132, 23.985575, -9.396812, 9.323492, 23.09922, -9.784039, 8.756422, 24.024965, -9.730011, 9.053403, 23.47832, -9.396812, 9.323492, 23.09922, -9.557905, 8.814163, 24.051355, -9.784039, 8.756422, 24.024965, -9.642679, 8.753303, 22.532566, -9.374041, 8.548035, 22.28962, -9.396812, 9.323492, 23.09922, -9.642679, 8.753303, 22.532566, -9.570545, 8.498457, 22.26675, -9.374041, 8.548035, 22.28962, -8.476179, 9.272415, 23.238873, -9.097107, 9.033176, 23.725506, -9.396812, 9.323492, 23.09922, -9.097107, 9.033176, 23.725506, -8.476179, 9.272415, 23.238873, -8.663195, 8.882344, 23.984976, -9.097107, 9.033176, 23.725506, -8.663195, 8.882344, 23.984976, -8.990536, 8.872201, 24.00973, -8.943731, 9.065945, 22.87788, -9.374041, 8.548035, 22.28962, -8.84972, 8.556498, 22.469631, -8.943731, 9.065945, 22.87788, -9.396812, 9.323492, 23.09922, -9.374041, 8.548035, 22.28962, -7.649511, 8.880784, 23.39443, -8.29641, 9.024534, 23.588913, -8.476179, 9.272415, 23.238873, -8.29641, 9.024534, 23.588913, -7.649511, 8.880784, 23.39443, -7.667808, 8.801199, 23.562954, -8.29641, 9.024534, 23.588913, -7.667808, 8.801199, 23.562954, -8.106319, 8.869322, 23.749159, -10.748026, 8.414791, 22.562681, -10.238874, 8.305614, 22.187637, -10.46002, 9.004967, 22.97543, -10.748026, 8.414791, 22.562681, -10.583004, 8.204302, 22.268263, -10.238874, 8.305614, 22.187637, -11.012053, 8.2395935, 22.52716, -10.796698, 8.076697, 22.316998, -10.748026, 8.414791, 22.562681, -11.012053, 8.2395935, 22.52716, -10.918375, 8.067935, 22.346127, -10.796698, 8.076697, 22.316998, -11.012053, 8.2395935, 22.52716, -11.339409, 8.419413, 22.91118, -11.160143, 8.010557, 22.403072, -11.6488285, 8.007675, 22.739779, -11.160143, 8.010557, 22.403072, -11.339409, 8.419413, 22.91118, -11.6488285, 8.007675, 22.739779, -11.5230255, 7.8992805, 22.537394, -11.160143, 8.010557, 22.403072, -8.041105, 9.099136, 22.774368, -7.6169944, 8.80678, 23.443369, -7.7392535, 8.897409, 23.481745, -8.041105, 9.099136, 22.774368, -7.5259223, 8.667595, 22.517582, -7.6169944, 8.80678, 23.443369, -8.604433, 8.708588, 23.005474, -8.175547, 8.90155, 23.08837, -8.352665, 8.7482605, 23.189362, -8.604433, 8.708588, 23.005474, -8.041105, 9.099136, 22.774368, -8.175547, 8.90155, 23.08837, -9.354351, 7.2679915, 20.05077, -9.012537, 7.976405, 20.566238, -9.399052, 7.74749, 20.912569, -9.354351, 7.2679915, 20.05077, -9.275749, 7.2862973, 19.989244, -9.012537, 7.976405, 20.566238, -9.012537, 7.976405, 20.566238, -9.383741, 7.8629084, 21.12708, -9.399052, 7.74749, 20.912569, -9.012537, 7.976405, 20.566238, -9.122709, 8.10707, 21.038918, -9.383741, 7.8629084, 21.12708, -9.122709, 8.10707, 21.038918, -9.012537, 7.976405, 20.566238, -8.725763, 8.597912, 21.202042, -9.122709, 8.10707, 21.038918, -8.725763, 8.597912, 21.202042, -9.341703, 8.161026, 21.703133, -9.122709, 8.10707, 21.038918, -9.341703, 8.161026, 21.703133, -9.369871, 7.9649425, 21.320047, -9.341703, 8.161026, 21.703133, -9.114939, 8.379078, 21.764282, -9.299545, 8.194818, 21.797089, -9.341703, 8.161026, 21.703133, -8.725763, 8.597912, 21.202042, -9.114939, 8.379078, 21.764282, -9.049728, 8.516345, 22.375086, -8.632957, 8.781511, 22.557295, -8.934408, 8.559859, 22.53762, -9.049728, 8.516345, 22.375086, -8.364826, 9.012768, 22.024788, -8.632957, 8.781511, 22.557295, -9.180586, 7.258568, 19.97211, -9.012537, 7.976405, 20.566238, -9.275749, 7.2862973, 19.989244, -9.012537, 7.976405, 20.566238, -9.180586, 7.258568, 19.97211, -8.98185, 7.591978, 20.29632, -8.98185, 7.591978, 20.29632, -9.180586, 7.258568, 19.97211, -8.903774, 7.424042, 20.197647, -9.012537, 7.976405, 20.566238, -8.491125, 8.092604, 20.86079, -8.725763, 8.597912, 21.202042, -9.012537, 7.976405, 20.566238, -8.566002, 7.9071417, 20.66232, -8.491125, 8.092604, 20.86079, -7.9790645, 8.107249, 21.086702, -8.364826, 9.012768, 22.024788, -8.725763, 8.597912, 21.202042, -8.364826, 9.012768, 22.024788, -7.9790645, 8.107249, 21.086702, -7.822355, 8.259277, 21.4402, -8.364826, 9.012768, 22.024788, -7.822355, 8.259277, 21.4402, -8.002892, 8.544012, 21.647385, -7.964889, 8.880724, 22.295406, -8.041105, 9.099136, 22.774368, -8.364826, 9.012768, 22.024788, -8.041105, 9.099136, 22.774368, -7.964889, 8.880724, 22.295406, -7.5640388, 8.629003, 22.34164, -8.041105, 9.099136, 22.774368, -7.5640388, 8.629003, 22.34164, -7.5259223, 8.667595, 22.517582, -8.364826, 9.012768, 22.024788, -9.114939, 8.379078, 21.764282, -8.725763, 8.597912, 21.202042, -8.749628, 8.718971, 21.97248, -9.114939, 8.379078, 21.764282, -8.364826, 9.012768, 22.024788, -9.114939, 8.379078, 21.764282, -8.749628, 8.718971, 21.97248, -9.156193, 8.312938, 22.116726, -9.114939, 8.379078, 21.764282, -9.156193, 8.312938, 22.116726, -9.228645, 8.264259, 21.957209, -8.364826, 9.012768, 22.024788, -9.126802, 8.436518, 22.200216, -8.749628, 8.718971, 21.97248, -8.364826, 9.012768, 22.024788, -9.049728, 8.516345, 22.375086, -9.126802, 8.436518, 22.200216, -8.002892, 8.544012, 21.647385, -7.684996, 8.432556, 21.75747, -8.364826, 9.012768, 22.024788, -8.002892, 8.544012, 21.647385, -7.7519794, 8.363534, 21.605556, -7.684996, 8.432556, 21.75747, -8.041105, 9.099136, 22.774368, -8.632957, 8.781511, 22.557295, -8.364826, 9.012768, 22.024788, -8.632957, 8.781511, 22.557295, -8.041105, 9.099136, 22.774368, -8.604433, 8.708588, 23.005474, -8.632957, 8.781511, 22.557295, -8.604433, 8.708588, 23.005474, -8.767645, 8.649889, 22.776098, -7.964889, 8.880724, 22.295406, -7.684996, 8.432556, 21.75747, -7.5840287, 8.511244, 22.2162, -7.964889, 8.880724, 22.295406, -8.364826, 9.012768, 22.024788, -7.684996, 8.432556, 21.75747, -7.7392535, 8.897409, 23.481745, -8.175547, 8.90155, 23.08837, -8.041105, 9.099136, 22.774368, -8.175547, 8.90155, 23.08837, -7.7392535, 8.897409, 23.481745, -7.856048, 8.8193245, 23.551592, -8.175547, 8.90155, 23.08837, -7.856048, 8.8193245, 23.551592, -8.1856785, 8.794295, 23.313171, -8.491125, 8.092604, 20.86079, -7.9790645, 8.107249, 21.086702, -8.725763, 8.597912, 21.202042, -8.491125, 8.092604, 20.86079, -8.181248, 7.964281, 20.873774, -7.9790645, 8.107249, 21.086702, -8.566002, 7.9071417, 20.66232, -8.305813, 7.8208942, 20.736774, -8.491125, 8.092604, 20.86079, -8.566002, 7.9071417, 20.66232, -8.377768, 7.7919035, 20.663399, -8.305813, 7.8208942, 20.736774, -8.566002, 7.9071417, 20.66232, -9.012537, 7.976405, 20.566238, -8.519827, 7.7004347, 20.51477, -8.98185, 7.591978, 20.29632, -8.519827, 7.7004347, 20.51477, -9.012537, 7.976405, 20.566238, -8.98185, 7.591978, 20.29632, -8.76738, 7.536939, 20.311623, -8.519827, 7.7004347, 20.51477, -7.2060475, 9.208552, 22.897741, -7.80905, 8.817884, 23.60042, -7.899529, 8.892069, 23.45427, -7.2060475, 9.208552, 22.897741, -6.7061973, 8.855337, 23.494766, -7.80905, 8.817884, 23.60042, -7.4934087, 8.594069, 22.34334, -7.539685, 8.893689, 22.84897, -7.65863, 8.6545105, 22.680805, -7.4934087, 8.594069, 22.34334, -7.2060475, 9.208552, 22.897741, -7.539685, 8.893689, 22.84897, -4.068832, 7.336172, 20.780975, -4.714312, 8.10803, 21.257643, -5.132072, 7.6965923, 20.921396, -4.068832, 7.336172, 20.780975, -3.9881473, 7.385569, 20.85462, -4.714312, 8.10803, 21.257643, -4.714312, 8.10803, 21.257643, -5.3909154, 7.790823, 20.987743, -5.132072, 7.6965923, 20.921396, -4.714312, 8.10803, 21.257643, -5.2879725, 8.145002, 21.248096, -5.3909154, 7.790823, 20.987743, -5.2879725, 8.145002, 21.248096, -4.714312, 8.10803, 21.257643, -5.489513, 8.767287, 21.711481, -5.2879725, 8.145002, 21.248096, -5.489513, 8.767287, 21.711481, -6.085754, 8.035226, 21.16589, -5.2879725, 8.145002, 21.248096, -6.085754, 8.035226, 21.16589, -5.623652, 7.8744917, 21.047535, -6.085754, 8.035226, 21.16589, -6.1478257, 8.328001, 21.433554, -6.1905837, 8.063794, 21.235548, -6.085754, 8.035226, 21.16589, -5.489513, 8.767287, 21.711481, -6.1478257, 8.328001, 21.433554, -6.841514, 8.391562, 21.667154, -7.0118194, 8.758585, 22.183632, -7.009152, 8.4349575, 21.841381, -6.841514, 8.391562, 21.667154, -6.416242, 9.177944, 22.323563, -7.0118194, 8.758585, 22.183632, -3.9419742, 7.374406, 20.966194, -4.714312, 8.10803, 21.257643, -3.9881473, 7.385569, 20.85462, -4.714312, 8.10803, 21.257643, -3.9419742, 7.374406, 20.966194, -4.32668, 7.7234807, 21.258965, -4.32668, 7.7234807, 21.258965, -3.9419742, 7.374406, 20.966194, -4.1686845, 7.564969, 21.337112, -4.714312, 8.10803, 21.257643, -4.9478, 8.286828, 21.951738, -5.489513, 8.767287, 21.711481, -4.714312, 8.10803, 21.257643, -4.704924, 8.094104, 21.826122, -4.9478, 8.286828, 21.951738, -5.0908885, 8.360533, 22.620289, -6.416242, 9.177944, 22.323563, -5.489513, 8.767287, 21.711481, -6.416242, 9.177944, 22.323563, -5.0908885, 8.360533, 22.620289, -5.486716, 8.50116, 22.883144, -6.416242, 9.177944, 22.323563, -5.486716, 8.50116, 22.883144, -5.8117423, 8.768128, 22.703707, -6.5980697, 9.059402, 22.89238, -7.2060475, 9.208552, 22.897741, -6.416242, 9.177944, 22.323563, -7.2060475, 9.208552, 22.897741, -6.5980697, 9.059402, 22.89238, -6.507363, 8.83523, 23.408497, -7.2060475, 9.208552, 22.897741, -6.507363, 8.83523, 23.408497, -6.7061973, 8.855337, 23.494766, -6.416242, 9.177944, 22.323563, -6.1478257, 8.328001, 21.433554, -5.489513, 8.767287, 21.711481, -6.3719687, 8.766026, 21.89067, -6.1478257, 8.328001, 21.433554, -6.416242, 9.177944, 22.323563, -6.1478257, 8.328001, 21.433554, -6.3719687, 8.766026, 21.89067, -6.5476384, 8.165707, 21.472878, -6.1478257, 8.328001, 21.433554, -6.5476384, 8.165707, 21.472878, -6.369827, 8.1280155, 21.354618, -6.416242, 9.177944, 22.323563, -6.645247, 8.314198, 21.536797, -6.3719687, 8.766026, 21.89067, -6.416242, 9.177944, 22.323563, -6.841514, 8.391562, 21.667154, -6.645247, 8.314198, 21.536797, -5.8117423, 8.768128, 22.703707, -5.8438954, 8.677977, 23.119993, -6.416242, 9.177944, 22.323563, -5.8117423, 8.768128, 22.703707, -5.6736007, 8.611357, 23.006952, -5.8438954, 8.677977, 23.119993, -7.2060475, 9.208552, 22.897741, -7.0118194, 8.758585, 22.183632, -6.416242, 9.177944, 22.323563, -7.0118194, 8.758585, 22.183632, -7.2060475, 9.208552, 22.897741, -7.4934087, 8.594069, 22.34334, -7.0118194, 8.758585, 22.183632, -7.4934087, 8.594069, 22.34334, -7.256623, 8.534951, 22.097307, -6.5980697, 9.059402, 22.89238, -5.8438954, 8.677977, 23.119993, -6.361113, 8.698025, 23.343914, -6.5980697, 9.059402, 22.89238, -6.416242, 9.177944, 22.323563, -5.8438954, 8.677977, 23.119993, -7.899529, 8.892069, 23.45427, -7.539685, 8.893689, 22.84897, -7.2060475, 9.208552, 22.897741, -7.539685, 8.893689, 22.84897, -7.899529, 8.892069, 23.45427, -7.983706, 8.764886, 23.345701, -7.539685, 8.893689, 22.84897, -7.983706, 8.764886, 23.345701, -7.7710595, 8.722573, 22.907578, -4.9478, 8.286828, 21.951738, -5.0908885, 8.360533, 22.620289, -5.489513, 8.767287, 21.711481, -4.9478, 8.286828, 21.951738, -4.867348, 8.1940365, 22.336489, -5.0908885, 8.360533, 22.620289, -4.704924, 8.094104, 21.826122, -4.7211885, 8.02508, 22.154125, -4.9478, 8.286828, 21.951738, -4.704924, 8.094104, 21.826122, -4.6452146, 7.9936295, 22.056368, -4.7211885, 8.02508, 22.154125, -4.704924, 8.094104, 21.826122, -4.714312, 8.10803, 21.257643, -4.489761, 7.8906374, 21.858315, -4.32668, 7.7234807, 21.258965, -4.489761, 7.8906374, 21.858315, -4.714312, 8.10803, 21.257643, -4.32668, 7.7234807, 21.258965, -4.284569, 7.6962314, 21.524137, -4.489761, 7.8906374, 21.858315, -7.4770913, 9.070688, 24.137522, -7.9792457, 8.86656, 23.280897, -7.798713, 8.910673, 23.258356, -7.4770913, 9.070688, 24.137522, -8.267395, 8.83601, 24.344084, -7.9792457, 8.86656, 23.280897, -6.969793, 8.361493, 23.999329, -7.3750834, 8.766386, 23.809656, -7.2131476, 8.500739, 23.731937, -6.969793, 8.361493, 23.999329, -7.4770913, 9.070688, 24.137522, -7.3750834, 8.766386, 23.809656, -6.8215938, 6.6485844, 27.584244, -6.934275, 7.533578, 26.905539, -6.54954, 7.0755653, 26.591766, -6.8215938, 6.6485844, 27.584244, -6.908404, 6.709503, 27.638208, -6.934275, 7.533578, 26.905539, -6.934275, 7.533578, 26.905539, -6.512714, 7.1929045, 26.339361, -6.54954, 7.0755653, 26.591766, -6.934275, 7.533578, 26.905539, -6.735351, 7.5916777, 26.38232, -6.512714, 7.1929045, 26.339361, -6.735351, 7.5916777, 26.38232, -6.934275, 7.533578, 26.905539, -7.004352, 8.309274, 26.101746, -6.735351, 7.5916777, 26.38232, -7.004352, 8.309274, 26.101746, -6.413199, 7.4994855, 25.660645, -6.735351, 7.5916777, 26.38232, -6.413199, 7.4994855, 25.660645, -6.4796042, 7.2974, 26.112244, -6.413199, 7.4994855, 25.660645, -6.6031613, 7.8468814, 25.541204, -6.4362464, 7.5467825, 25.541874, -6.413199, 7.4994855, 25.660645, -7.004352, 8.309274, 26.101746, -6.6031613, 7.8468814, 25.541204, -6.5875335, 7.9890084, 24.822481, -6.9586644, 8.467667, 24.52183, -6.6856866, 8.076338, 24.609621, -6.5875335, 7.9890084, 24.822481, -7.210817, 8.882824, 25.071764, -6.9586644, 8.467667, 24.52183, -7.030128, 6.721147, 27.639519, -6.934275, 7.533578, 26.905539, -6.908404, 6.709503, 27.638208, -6.934275, 7.533578, 26.905539, -7.030128, 6.721147, 27.639519, -7.122934, 7.142128, 27.21883, -7.122934, 7.142128, 27.21883, -7.030128, 6.721147, 27.639519, -7.2680945, 6.9975395, 27.31846, -6.934275, 7.533578, 26.905539, -7.48521, 7.8700495, 26.46049, -7.004352, 8.309274, 26.101746, -6.934275, 7.533578, 26.905539, -7.476253, 7.6441336, 26.707956, -7.48521, 7.8700495, 26.46049, -8.052195, 8.094164, 26.095999, -7.210817, 8.882824, 25.071764, -7.004352, 8.309274, 26.101746, -7.210817, 8.882824, 25.071764, -8.052195, 8.094164, 26.095999, -8.141077, 8.305193, 25.651417, -7.210817, 8.882824, 25.071764, -8.141077, 8.305193, 25.651417, -7.8344674, 8.5407715, 25.446497, -7.7096314, 8.899331, 24.684853, -7.4770913, 9.070688, 24.137522, -7.210817, 8.882824, 25.071764, -7.4770913, 9.070688, 24.137522, -7.7096314, 8.899331, 24.684853, -8.258791, 8.789434, 24.556768, -7.4770913, 9.070688, 24.137522, -8.258791, 8.789434, 24.556768, -8.267395, 8.83601, 24.344084, -7.210817, 8.882824, 25.071764, -6.6031613, 7.8468814, 25.541204, -7.004352, 8.309274, 26.101746, -6.8924146, 8.3843, 25.220247, -6.6031613, 7.8468814, 25.541204, -7.210817, 8.882824, 25.071764, -6.6031613, 7.8468814, 25.541204, -6.8924146, 8.3843, 25.220247, -6.5150523, 7.712496, 25.137945, -6.6031613, 7.8468814, 25.541204, -6.5150523, 7.712496, 25.137945, -6.4764667, 7.6428747, 25.340647, -7.210817, 8.882824, 25.071764, -6.543115, 7.8768907, 25.041906, -6.8924146, 8.3843, 25.220247, -7.210817, 8.882824, 25.071764, -6.5875335, 7.9890084, 24.822481, -6.543115, 7.8768907, 25.041906, -7.8344674, 8.5407715, 25.446497, -8.223946, 8.544853, 25.256237, -7.210817, 8.882824, 25.071764, -7.8344674, 8.5407715, 25.446497, -8.185364, 8.448042, 25.446867, -8.223946, 8.544853, 25.256237, -7.4770913, 9.070688, 24.137522, -6.9586644, 8.467667, 24.52183, -7.210817, 8.882824, 25.071764, -6.9586644, 8.467667, 24.52183, -7.4770913, 9.070688, 24.137522, -6.969793, 8.361493, 23.999329, -6.9586644, 8.467667, 24.52183, -6.969793, 8.361493, 23.999329, -6.8314404, 8.240372, 24.300121, -7.7096314, 8.899331, 24.684853, -8.223946, 8.544853, 25.256237, -8.244255, 8.633982, 24.69929, -7.7096314, 8.899331, 24.684853, -7.210817, 8.882824, 25.071764, -8.223946, 8.544853, 25.256237, -7.798713, 8.910673, 23.258356, -7.3750834, 8.766386, 23.809656, -7.4770913, 9.070688, 24.137522, -7.3750834, 8.766386, 23.809656, -7.798713, 8.910673, 23.258356, -7.6923747, 8.766327, 23.204716, -7.3750834, 8.766386, 23.809656, -7.6923747, 8.766327, 23.204716, -7.3779397, 8.62144, 23.55421, -7.48521, 7.8700495, 26.46049, -8.052195, 8.094164, 26.095999, -7.004352, 8.309274, 26.101746, -7.48521, 7.8700495, 26.46049, -7.886131, 7.860207, 26.385628, -8.052195, 8.094164, 26.095999, -7.476253, 7.6441336, 26.707956, -7.776336, 7.6484556, 26.566654, -7.48521, 7.8700495, 26.46049, -7.476253, 7.6441336, 26.707956, -7.720366, 7.5934772, 26.668566, -7.776336, 7.6484556, 26.566654, -7.476253, 7.6441336, 26.707956, -6.934275, 7.533578, 26.905539, -7.604976, 7.443248, 26.871801, -7.122934, 7.142128, 27.21883, -7.604976, 7.443248, 26.871801, -6.934275, 7.533578, 26.905539, -7.122934, 7.142128, 27.21883, -7.3896413, 7.171777, 27.159313, -7.604976, 7.443248, 26.871801, -6.0571437, 2.278272, 22.798777, -6.422816, 4.561778, 23.067327, -6.255482, 4.6069903, 22.866049, -6.0571437, 2.278272, 22.798777, -6.2640643, 2.2363775, 23.040073, -6.422816, 4.561778, 23.067327, -6.229696, -0.29725552, 22.954578, -6.2640643, 2.2363775, 23.040073, -6.0571437, 2.278272, 22.798777, -6.229696, -0.29725552, 22.954578, -6.4595838, -0.294353, 23.230642, -6.2640643, 2.2363775, 23.040073, -6.8122897, 2.1787193, 22.89551, -6.9267097, 4.515596, 22.675344, -6.886102, 4.499142, 22.937313, -6.8122897, 2.1787193, 22.89551, -6.8565516, 2.1943433, 22.589626, -6.9267097, 4.515596, 22.675344, -6.34096, 2.278272, 22.29763, -6.2974906, 4.6231685, 22.603962, -6.5006537, 4.606991, 22.43314, -6.34096, 2.278272, 22.29763, -6.0964046, 2.2940338, 22.49008, -6.2974906, 4.6231685, 22.603962, -6.886102, 4.499142, 22.937313, -7.330835, 6.6817102, 22.999025, -7.3017764, 6.6674695, 23.172724, -6.886102, 4.499142, 22.937313, -6.9267097, 4.515596, 22.675344, -7.330835, 6.6817102, 22.999025, -6.255482, 4.6069903, 22.866049, -6.9959083, 6.7194586, 23.261988, -6.883434, 6.7554083, 23.12764, -6.255482, 4.6069903, 22.866049, -6.422816, 4.561778, 23.067327, -6.9959083, 6.7194586, 23.261988, -6.883434, 6.7554083, 23.12764, -6.9959083, 6.7194586, 23.261988, -7.3726697, 7.8587894, 23.408417, -6.883434, 6.7554083, 23.12764, -7.3726697, 7.8587894, 23.408417, -7.2935386, 7.8998566, 23.310549, -6.2640643, 2.2363775, 23.040073, -6.6823287, 4.515596, 23.106857, -6.422816, 4.561778, 23.067327, -6.2640643, 2.2363775, 23.040073, -6.5727344, 2.1943433, 23.090773, -6.6823287, 4.515596, 23.106857, -6.422816, 4.561778, 23.067327, -6.6823287, 4.515596, 23.106857, -7.1677566, 6.6817102, 23.28698, -6.422816, 4.561778, 23.067327, -7.1677566, 6.6817102, 23.28698, -6.9959083, 6.7194586, 23.261988, -6.8565516, 2.1943433, 22.589626, -6.759332, 4.561778, 22.473125, -6.9267097, 4.515596, 22.675344, -6.8565516, 2.1943433, 22.589626, -6.6543117, 2.2363775, 22.350998, -6.759332, 4.561778, 22.473125, -6.9267097, 4.515596, 22.675344, -6.759332, 4.561778, 22.473125, -7.2210193, 6.7194586, 22.864502, -6.9267097, 4.515596, 22.675344, -7.2210193, 6.7194586, 22.864502, -7.330835, 6.6817102, 22.999025, -6.5006537, 4.606991, 22.43314, -6.9114146, 6.7684054, 22.951649, -7.0479617, 6.7554083, 22.837128, -6.5006537, 4.606991, 22.43314, -6.2974906, 4.6231685, 22.603962, -6.9114146, 6.7684054, 22.951649, -6.0964046, 2.2940338, 22.49008, -6.255482, 4.6069903, 22.866049, -6.2974906, 4.6231685, 22.603962, -6.0964046, 2.2940338, 22.49008, -6.0571437, 2.278272, 22.798777, -6.255482, 4.6069903, 22.866049, -6.2974906, 4.6231685, 22.603962, -6.883434, 6.7554083, 23.12764, -6.9114146, 6.7684054, 22.951649, -6.2974906, 4.6231685, 22.603962, -6.255482, 4.6069903, 22.866049, -6.883434, 6.7554083, 23.12764, -6.6543117, 2.2363775, 22.350998, -6.5006537, 4.606991, 22.43314, -6.759332, 4.561778, 22.473125, -6.6543117, 2.2363775, 22.350998, -6.34096, 2.278272, 22.29763, -6.5006537, 4.606991, 22.43314, -6.9183087, -0.294353, 22.420652, -6.34096, 2.278272, 22.29763, -6.6543117, 2.2363775, 22.350998, -6.9183087, -0.294353, 22.420652, -6.563316, -0.29725552, 22.365494, -6.34096, 2.278272, 22.29763, -6.759332, 4.561778, 22.473125, -7.0479617, 6.7554083, 22.837128, -7.2210193, 6.7194586, 22.864502, -6.759332, 4.561778, 22.473125, -6.5006537, 4.606991, 22.43314, -7.0479617, 6.7554083, 22.837128, -7.2210193, 6.7194586, 22.864502, -7.0479617, 6.7554083, 22.837128, -7.4157386, 7.8998566, 23.094776, -7.2210193, 6.7194586, 22.864502, -7.4157386, 7.8998566, 23.094776, -7.5403686, 7.8587894, 23.112305, -7.596041, 7.8030686, 23.339376, -7.8122087, 8.670566, 23.565685, -7.4973254, 7.8185544, 23.425323, -7.596041, 7.8030686, 23.339376, -7.884003, 8.657431, 23.502459, -7.8122087, 8.670566, 23.565685, -7.3726697, 7.8587894, 23.408417, -7.6698213, 8.747442, 23.485046, -7.2935386, 7.8998566, 23.310549, -7.3726697, 7.8587894, 23.408417, -7.724811, 8.710249, 23.555187, -7.6698213, 8.747442, 23.485046, -7.4973254, 7.8185544, 23.425323, -7.724811, 8.710249, 23.555187, -7.3726697, 7.8587894, 23.408417, -7.4973254, 7.8185544, 23.425323, -7.8122087, 8.670566, 23.565685, -7.724811, 8.710249, 23.555187, -7.3161235, 7.9149265, 23.18085, -7.7589135, 8.747442, 23.327732, -7.4157386, 7.8998566, 23.094776, -7.3161235, 7.9149265, 23.18085, -7.6881547, 8.763065, 23.391544, -7.7589135, 8.747442, 23.327732, -7.2935386, 7.8998566, 23.310549, -7.6881547, 8.763065, 23.391544, -7.3161235, 7.9149265, 23.18085, -7.2935386, 7.8998566, 23.310549, -7.6698213, 8.747442, 23.485046, -7.6881547, 8.763065, 23.391544, -7.5403686, 7.8587894, 23.112305, -7.901301, 8.670566, 23.408371, -7.61898, 7.8185544, 23.210514, -7.5403686, 7.8587894, 23.112305, -7.8473487, 8.710249, 23.338818, -7.901301, 8.670566, 23.408371, -7.4157386, 7.8998566, 23.094776, -7.8473487, 8.710249, 23.338818, -7.5403686, 7.8587894, 23.112305, -7.4157386, 7.8998566, 23.094776, -7.7589135, 8.747442, 23.327732, -7.8473487, 8.710249, 23.338818, -6.8164535, -0.2914486, 23.28688, -6.8122897, 2.1787193, 22.89551, -6.5727344, 2.1943433, 23.090773, -6.8164535, -0.2914486, 23.28688, -7.092834, -0.29034328, 23.054392, -6.8122897, 2.1787193, 22.89551, -7.61898, 7.8185544, 23.210514, -7.884003, 8.657431, 23.502459, -7.596041, 7.8030686, 23.339376, -7.61898, 7.8185544, 23.210514, -7.901301, 8.670566, 23.408371, -7.884003, 8.657431, 23.502459, -6.5727344, 2.1943433, 23.090773, -6.886102, 4.499142, 22.937313, -6.6823287, 4.515596, 23.106857, -6.5727344, 2.1943433, 23.090773, -6.8122897, 2.1787193, 22.89551, -6.886102, 4.499142, 22.937313, -7.092834, -0.29034328, 23.054392, -6.8565516, 2.1943433, 22.589626, -6.8122897, 2.1787193, 22.89551, -7.092834, -0.29034328, 23.054392, -7.1500726, -0.2914486, 22.697796, -6.8565516, 2.1943433, 22.589626, -6.563316, -0.29725552, 22.365494, -6.0964046, 2.2940338, 22.49008, -6.34096, 2.278272, 22.29763, -6.563316, -0.29725552, 22.365494, -6.288299, -0.2983632, 22.598755, -6.0964046, 2.2940338, 22.49008, -6.4595838, -0.294353, 23.230642, -6.5727344, 2.1943433, 23.090773, -6.2640643, 2.2363775, 23.040073, -6.4595838, -0.294353, 23.230642, -6.8164535, -0.2914486, 23.28688, -6.5727344, 2.1943433, 23.090773, -7.1500726, -0.2914486, 22.697796, -6.6543117, 2.2363775, 22.350998, -6.8565516, 2.1943433, 22.589626, -7.1500726, -0.2914486, 22.697796, -6.9183087, -0.294353, 22.420652, -6.6543117, 2.2363775, 22.350998, -6.288299, -0.2983632, 22.598755, -6.0571437, 2.278272, 22.798777, -6.0964046, 2.2940338, 22.49008, -6.288299, -0.2983632, 22.598755, -6.229696, -0.29725552, 22.954578, -6.0571437, 2.278272, 22.798777, -6.6823287, 4.515596, 23.106857, -7.3017764, 6.6674695, 23.172724, -7.1677566, 6.6817102, 23.28698, -6.6823287, 4.515596, 23.106857, -6.886102, 4.499142, 22.937313, -7.3017764, 6.6674695, 23.172724, -7.1677566, 6.6817102, 23.28698, -7.596041, 7.8030686, 23.339376, -7.4973254, 7.8185544, 23.425323, -7.1677566, 6.6817102, 23.28698, -7.3017764, 6.6674695, 23.172724, -7.596041, 7.8030686, 23.339376, -7.3017764, 6.6674695, 23.172724, -7.61898, 7.8185544, 23.210514, -7.596041, 7.8030686, 23.339376, -7.3017764, 6.6674695, 23.172724, -7.330835, 6.6817102, 22.999025, -7.61898, 7.8185544, 23.210514, -6.9959083, 6.7194586, 23.261988, -7.4973254, 7.8185544, 23.425323, -7.3726697, 7.8587894, 23.408417, -6.9959083, 6.7194586, 23.261988, -7.1677566, 6.6817102, 23.28698, -7.4973254, 7.8185544, 23.425323, -7.0479617, 6.7554083, 22.837128, -7.3161235, 7.9149265, 23.18085, -7.4157386, 7.8998566, 23.094776, -7.0479617, 6.7554083, 22.837128, -6.9114146, 6.7684054, 22.951649, -7.3161235, 7.9149265, 23.18085, -6.9114146, 6.7684054, 22.951649, -7.2935386, 7.8998566, 23.310549, -7.3161235, 7.9149265, 23.18085, -6.9114146, 6.7684054, 22.951649, -6.883434, 6.7554083, 23.12764, -7.2935386, 7.8998566, 23.310549, -7.330835, 6.6817102, 22.999025, -7.5403686, 7.8587894, 23.112305, -7.61898, 7.8185544, 23.210514, -7.330835, 6.6817102, 22.999025, -7.2210193, 6.7194586, 22.864502, -7.5403686, 7.8587894, 23.112305, -5.520864, 5.50994, 24.639458, -6.0238147, 5.3727474, 24.921116, -6.027886, 5.4678555, 24.868423, -5.520864, 5.50994, 24.639458, -5.4553275, 5.043026, 24.83633, -6.0238147, 5.3727474, 24.921116, -5.780359, 5.49264, 24.220936, -5.780785, 5.505308, 24.545547, -5.885944, 5.489472, 24.411573, -5.780359, 5.49264, 24.220936, -5.520864, 5.50994, 24.639458, -5.780785, 5.505308, 24.545547, -4.172077, 4.097315, 23.081501, -4.3400836, 4.5620866, 23.516861, -4.6323843, 4.612814, 23.259392, -4.172077, 4.097315, 23.081501, -4.1252418, 4.0723934, 23.12863, -4.3400836, 4.5620866, 23.516861, -4.3400836, 4.5620866, 23.516861, -4.7473187, 4.731488, 23.319653, -4.6323843, 4.612814, 23.259392, -4.3400836, 4.5620866, 23.516861, -4.6260543, 4.7886457, 23.530365, -4.7473187, 4.731488, 23.319653, -4.6260543, 4.7886457, 23.530365, -4.3400836, 4.5620866, 23.516861, -4.6092978, 5.019003, 23.91192, -4.6260543, 4.7886457, 23.530365, -4.6092978, 5.019003, 23.91192, -5.05752, 5.0416813, 23.479633, -4.6260543, 4.7886457, 23.530365, -5.05752, 5.0416813, 23.479633, -4.8509307, 4.836937, 23.373531, -5.05752, 5.0416813, 23.479633, -5.044219, 5.1299534, 23.679413, -5.109679, 5.073374, 23.52538, -5.05752, 5.0416813, 23.479633, -4.6092978, 5.019003, 23.91192, -5.044219, 5.1299534, 23.679413, -5.416506, 5.348407, 23.822437, -5.4675636, 5.4387703, 24.16735, -5.5109153, 5.382139, 23.924562, -5.416506, 5.348407, 23.822437, -5.0510015, 5.370203, 24.32837, -5.4675636, 5.4387703, 24.16735, -4.119844, 4.0247717, 23.180462, -4.3400836, 4.5620866, 23.516861, -4.1252418, 4.0723934, 23.12863, -4.3400836, 4.5620866, 23.516861, -4.119844, 4.0247717, 23.180462, -4.253202, 4.254257, 23.418776, -4.253202, 4.254257, 23.418776, -4.119844, 4.0247717, 23.180462, -4.22462, 4.107806, 23.420288, -4.3400836, 4.5620866, 23.516861, -4.504075, 4.5656476, 23.906963, -4.6092978, 5.019003, 23.91192, -4.3400836, 4.5620866, 23.516861, -4.4175224, 4.424284, 23.794865, -4.504075, 4.5656476, 23.906963, -4.644364, 4.490939, 24.260977, -5.0510015, 5.370203, 24.32837, -4.6092978, 5.019003, 23.91192, -5.0510015, 5.370203, 24.32837, -4.644364, 4.490939, 24.260977, -4.8376765, 4.627639, 24.436502, -5.0510015, 5.370203, 24.32837, -4.8376765, 4.627639, 24.436502, -4.9126344, 4.897375, 24.407942, -5.261839, 5.2418065, 24.581022, -5.520864, 5.50994, 24.639458, -5.0510015, 5.370203, 24.32837, -5.520864, 5.50994, 24.639458, -5.261839, 5.2418065, 24.581022, -5.3500004, 4.9882493, 24.782772, -5.520864, 5.50994, 24.639458, -5.3500004, 4.9882493, 24.782772, -5.4553275, 5.043026, 24.83633, -5.0510015, 5.370203, 24.32837, -5.044219, 5.1299534, 23.679413, -4.6092978, 5.019003, 23.91192, -5.09594, 5.2896347, 24.011734, -5.044219, 5.1299534, 23.679413, -5.0510015, 5.370203, 24.32837, -5.044219, 5.1299534, 23.679413, -5.09594, 5.2896347, 24.011734, -5.2869577, 5.1833105, 23.681408, -5.044219, 5.1299534, 23.679413, -5.2869577, 5.1833105, 23.681408, -5.197224, 5.1352034, 23.604895, -5.0510015, 5.370203, 24.32837, -5.321176, 5.2789783, 23.735258, -5.09594, 5.2896347, 24.011734, -5.0510015, 5.370203, 24.32837, -5.416506, 5.348407, 23.822437, -5.321176, 5.2789783, 23.735258, -4.9126344, 4.897375, 24.407942, -5.007563, 4.7688026, 24.597364, -5.0510015, 5.370203, 24.32837, -4.9126344, 4.897375, 24.407942, -4.924829, 4.7089424, 24.521822, -5.007563, 4.7688026, 24.597364, -5.520864, 5.50994, 24.639458, -5.4675636, 5.4387703, 24.16735, -5.0510015, 5.370203, 24.32837, -5.4675636, 5.4387703, 24.16735, -5.520864, 5.50994, 24.639458, -5.780359, 5.49264, 24.220936, -5.4675636, 5.4387703, 24.16735, -5.780359, 5.49264, 24.220936, -5.6462617, 5.4495068, 24.077614, -5.261839, 5.2418065, 24.581022, -5.007563, 4.7688026, 24.597364, -5.284591, 4.8992395, 24.734537, -5.261839, 5.2418065, 24.581022, -5.0510015, 5.370203, 24.32837, -5.007563, 4.7688026, 24.597364, -6.027886, 5.4678555, 24.868423, -5.780785, 5.505308, 24.545547, -5.520864, 5.50994, 24.639458, -5.780785, 5.505308, 24.545547, -6.027886, 5.4678555, 24.868423, -6.0964007, 5.467935, 24.783894, -5.780785, 5.505308, 24.545547, -6.0964007, 5.467935, 24.783894, -5.9559407, 5.492031, 24.540054, -4.504075, 4.5656476, 23.906963, -4.644364, 4.490939, 24.260977, -4.6092978, 5.019003, 23.91192, -4.504075, 4.5656476, 23.906963, -4.5401545, 4.407131, 24.07597, -4.644364, 4.490939, 24.260977, -4.4175224, 4.424284, 23.794865, -4.4788713, 4.3216662, 23.951574, -4.504075, 4.5656476, 23.906963, -4.4175224, 4.424284, 23.794865, -4.4406796, 4.305605, 23.890078, -4.4788713, 4.3216662, 23.951574, -4.4175224, 4.424284, 23.794865, -4.3400836, 4.5620866, 23.516861, -4.367128, 4.250943, 23.76148, -4.253202, 4.254257, 23.418776, -4.367128, 4.250943, 23.76148, -4.3400836, 4.5620866, 23.516861, -4.253202, 4.254257, 23.418776, -4.274289, 4.1678133, 23.544254, -4.367128, 4.250943, 23.76148, -6.100014, 5.3618755, 25.36038, -6.053468, 5.4692774, 24.699776, -5.951238, 5.4974275, 24.761002, -6.100014, 5.3618755, 25.36038, -6.5840898, 5.185566, 25.139408, -6.053468, 5.4692774, 24.699776, -5.702927, 4.9555445, 25.310673, -5.8909087, 5.2601395, 25.158152, -5.754765, 5.1142435, 25.118452, -5.702927, 4.9555445, 25.310673, -6.100014, 5.3618755, 25.36038, -5.8909087, 5.2601395, 25.158152, -6.868545, 2.974114, 26.87804, -6.7395024, 3.6925054, 26.67168, -6.369455, 3.4866214, 26.543037, -6.868545, 2.974114, 26.87804, -6.9426017, 2.9990473, 26.889389, -6.7395024, 3.6925054, 26.67168, -6.7395024, 3.6925054, 26.67168, -6.2608256, 3.6231003, 26.448338, -6.369455, 3.4866214, 26.543037, -6.7395024, 3.6925054, 26.67168, -6.429816, 3.8601167, 26.47786, -6.2608256, 3.6231003, 26.448338, -6.429816, 3.8601167, 26.47786, -6.7395024, 3.6925054, 26.67168, -6.5268407, 4.3764234, 26.390133, -6.429816, 3.8601167, 26.47786, -6.5268407, 4.3764234, 26.390133, -5.9696584, 3.9842505, 26.192686, -6.429816, 3.8601167, 26.47786, -5.9696584, 3.9842505, 26.192686, -6.1631923, 3.745168, 26.363018, -5.9696584, 3.9842505, 26.192686, -6.049317, 4.232349, 26.135853, -5.944036, 4.043989, 26.133595, -5.9696584, 3.9842505, 26.192686, -6.5268407, 4.3764234, 26.390133, -6.049317, 4.232349, 26.135853, -5.773127, 4.5062346, 25.794031, -5.898379, 4.8849564, 25.608694, -5.7558064, 4.6165876, 25.667988, -5.773127, 4.5062346, 25.794031, -6.2885137, 4.9992027, 25.899937, -5.898379, 4.8849564, 25.608694, -7.0106807, 3.0087154, 26.849985, -6.7395024, 3.6925054, 26.67168, -6.9426017, 2.9990473, 26.889389, -6.7395024, 3.6925054, 26.67168, -7.0106807, 3.0087154, 26.849985, -6.9334645, 3.3768253, 26.686901, -6.9334645, 3.3768253, 26.686901, -7.0106807, 3.0087154, 26.849985, -7.0455823, 3.2654228, 26.659414, -6.7395024, 3.6925054, 26.67168, -6.892614, 4.0271807, 26.316252, -6.5268407, 4.3764234, 26.390133, -6.7395024, 3.6925054, 26.67168, -6.9665236, 3.8246672, 26.401512, -6.892614, 4.0271807, 26.316252, -7.0833793, 4.2717676, 25.97569, -6.2885137, 4.9992027, 25.899937, -6.5268407, 4.3764234, 26.390133, -6.2885137, 4.9992027, 25.899937, -7.0833793, 4.2717676, 25.97569, -6.9840283, 4.5172915, 25.75856, -6.2885137, 4.9992027, 25.899937, -6.9840283, 4.5172915, 25.75856, -6.7441907, 4.708605, 25.804724, -6.4090276, 5.1228185, 25.524706, -6.100014, 5.3618755, 25.36038, -6.2885137, 4.9992027, 25.899937, -6.100014, 5.3618755, 25.36038, -6.4090276, 5.1228185, 25.524706, -6.6533313, 5.102206, 25.24277, -6.100014, 5.3618755, 25.36038, -6.6533313, 5.102206, 25.24277, -6.5840898, 5.185566, 25.139408, -6.2885137, 4.9992027, 25.899937, -6.049317, 4.232349, 26.135853, -6.5268407, 4.3764234, 26.390133, -6.123463, 4.6506386, 25.980106, -6.049317, 4.232349, 26.135853, -6.2885137, 4.9992027, 25.899937, -6.049317, 4.232349, 26.135853, -6.123463, 4.6506386, 25.980106, -5.8564105, 4.2503223, 25.933277, -6.049317, 4.232349, 26.135853, -5.8564105, 4.2503223, 25.933277, -5.898976, 4.1555033, 26.03533, -6.2885137, 4.9992027, 25.899937, -5.822971, 4.3798666, 25.900269, -6.123463, 4.6506386, 25.980106, -6.2885137, 4.9992027, 25.899937, -5.773127, 4.5062346, 25.794031, -5.822971, 4.3798666, 25.900269, -6.7441907, 4.708605, 25.804724, -6.8905606, 4.769124, 25.571657, -6.2885137, 4.9992027, 25.899937, -6.7441907, 4.708605, 25.804724, -6.933764, 4.659773, 25.663965, -6.8905606, 4.769124, 25.571657, -6.100014, 5.3618755, 25.36038, -5.898379, 4.8849564, 25.608694, -6.2885137, 4.9992027, 25.899937, -5.898379, 4.8849564, 25.608694, -6.100014, 5.3618755, 25.36038, -5.702927, 4.9555445, 25.310673, -5.898379, 4.8849564, 25.608694, -5.702927, 4.9555445, 25.310673, -5.727136, 4.8010197, 25.48896, -6.4090276, 5.1228185, 25.524706, -6.8905606, 4.769124, 25.571657, -6.712791, 4.966251, 25.297207, -6.4090276, 5.1228185, 25.524706, -6.2885137, 4.9992027, 25.899937, -6.8905606, 4.769124, 25.571657, -5.951238, 5.4974275, 24.761002, -5.8909087, 5.2601395, 25.158152, -6.100014, 5.3618755, 25.36038, -5.8909087, 5.2601395, 25.158152, -5.951238, 5.4974275, 24.761002, -5.857908, 5.4214015, 24.738348, -5.8909087, 5.2601395, 25.158152, -5.857908, 5.4214015, 24.738348, -5.786898, 5.237897, 24.993658, -6.892614, 4.0271807, 26.316252, -7.0833793, 4.2717676, 25.97569, -6.5268407, 4.3764234, 26.390133, -6.892614, 4.0271807, 26.316252, -7.084641, 4.049944, 26.135506, -7.0833793, 4.2717676, 25.97569, -6.9665236, 3.8246672, 26.401512, -7.0913386, 3.8691072, 26.228043, -6.892614, 4.0271807, 26.316252, -6.9665236, 3.8246672, 26.401512, -7.0892367, 3.8087032, 26.28731, -7.0913386, 3.8691072, 26.228043, -6.9665236, 3.8246672, 26.401512, -6.7395024, 3.6925054, 26.67168, -7.0888734, 3.6620417, 26.401033, -6.9334645, 3.3768253, 26.686901, -7.0888734, 3.6620417, 26.401033, -6.7395024, 3.6925054, 26.67168, -6.9334645, 3.3768253, 26.686901, -7.0596833, 3.4167135, 26.568975, -7.0888734, 3.6620417, 26.401033, -6.3319364, 5.7359695, 25.081383, -5.9698243, 5.384118, 24.680239, -5.904365, 5.4435186, 24.765022, -6.3319364, 5.7359695, 25.081383, -6.686463, 5.4666615, 24.73655, -5.9698243, 5.384118, 24.680239, -6.155786, 5.38664, 25.487875, -6.125067, 5.5240107, 25.150925, -6.053208, 5.3827543, 25.27274, -6.155786, 5.38664, 25.487875, -6.3319364, 5.7359695, 25.081383, -6.125067, 5.5240107, 25.150925, -8.413198, 4.890188, 26.407503, -7.970281, 5.3087463, 26.08081, -7.7057996, 5.0614824, 26.335453, -8.413198, 4.890188, 26.407503, -8.465763, 4.9176445, 26.35488, -7.970281, 5.3087463, 26.08081, -7.970281, 5.3087463, 26.08081, -7.5340934, 5.103933, 26.298006, -7.7057996, 5.0614824, 26.335453, -7.970281, 5.3087463, 26.08081, -7.592959, 5.3087974, 26.109215, -7.5340934, 5.103933, 26.298006, -7.592959, 5.3087974, 26.109215, -7.970281, 5.3087463, 26.08081, -7.4463277, 5.651639, 25.785309, -7.592959, 5.3087974, 26.109215, -7.4463277, 5.651639, 25.785309, -7.0733275, 5.2124443, 26.196064, -7.592959, 5.3087974, 26.109215, -7.0733275, 5.2124443, 26.196064, -7.379638, 5.141446, 26.26409, -7.0733275, 5.2124443, 26.196064, -7.0267406, 5.3698435, 26.018854, -7.005255, 5.218994, 26.151365, -7.0733275, 5.2124443, 26.196064, -7.4463277, 5.651639, 25.785309, -7.0267406, 5.3698435, 26.018854, -6.5752587, 5.356027, 25.89963, -6.46051, 5.5302234, 25.563005, -6.4677835, 5.358314, 25.792141, -6.5752587, 5.356027, 25.89963, -6.836881, 5.81112, 25.415369, -6.46051, 5.5302234, 25.563005, -8.498926, 4.900632, 26.286041, -7.970281, 5.3087463, 26.08081, -8.465763, 4.9176445, 26.35488, -7.970281, 5.3087463, 26.08081, -8.498926, 4.900632, 26.286041, -8.239595, 5.077715, 26.094341, -8.239595, 5.077715, 26.094341, -8.498926, 4.900632, 26.286041, -8.351461, 4.9742537, 26.046995, -7.970281, 5.3087463, 26.08081, -7.8256736, 5.34012, 25.656843, -7.4463277, 5.651639, 25.785309, -7.970281, 5.3087463, 26.08081, -7.9896913, 5.2398424, 25.73777, -7.8256736, 5.34012, 25.656843, -7.744165, 5.310528, 25.246101, -6.836881, 5.81112, 25.415369, -7.4463277, 5.651639, 25.785309, -6.836881, 5.81112, 25.415369, -7.744165, 5.310528, 25.246101, -7.485513, 5.35617, 25.082954, -6.836881, 5.81112, 25.415369, -7.485513, 5.35617, 25.082954, -7.2575336, 5.5329638, 25.196087, -6.7352633, 5.6665816, 25.089825, -6.3319364, 5.7359695, 25.081383, -6.836881, 5.81112, 25.415369, -6.3319364, 5.7359695, 25.081383, -6.7352633, 5.6665816, 25.089825, -6.815335, 5.4714, 24.787678, -6.3319364, 5.7359695, 25.081383, -6.815335, 5.4714, 24.787678, -6.686463, 5.4666615, 24.73655, -6.836881, 5.81112, 25.415369, -7.0267406, 5.3698435, 26.018854, -7.4463277, 5.651639, 25.785309, -6.8723903, 5.5936527, 25.718147, -7.0267406, 5.3698435, 26.018854, -6.836881, 5.81112, 25.415369, -7.0267406, 5.3698435, 26.018854, -6.8723903, 5.5936527, 25.718147, -6.7732606, 5.244132, 25.999937, -7.0267406, 5.3698435, 26.018854, -6.7732606, 5.244132, 25.999937, -6.888104, 5.2399597, 26.077604, -6.836881, 5.81112, 25.415369, -6.703796, 5.3285036, 25.979181, -6.8723903, 5.5936527, 25.718147, -6.836881, 5.81112, 25.415369, -6.5752587, 5.356027, 25.89963, -6.703796, 5.3285036, 25.979181, -7.2575336, 5.5329638, 25.196087, -7.249745, 5.4294252, 24.944311, -6.836881, 5.81112, 25.415369, -7.2575336, 5.5329638, 25.196087, -7.3613477, 5.405882, 25.013523, -7.249745, 5.4294252, 24.944311, -6.3319364, 5.7359695, 25.081383, -6.46051, 5.5302234, 25.563005, -6.836881, 5.81112, 25.415369, -6.46051, 5.5302234, 25.563005, -6.3319364, 5.7359695, 25.081383, -6.155786, 5.38664, 25.487875, -6.46051, 5.5302234, 25.563005, -6.155786, 5.38664, 25.487875, -6.3074155, 5.384823, 25.640076, -6.7352633, 5.6665816, 25.089825, -7.249745, 5.4294252, 24.944311, -6.916063, 5.3964624, 24.806072, -6.7352633, 5.6665816, 25.089825, -6.836881, 5.81112, 25.415369, -7.249745, 5.4294252, 24.944311, -5.904365, 5.4435186, 24.765022, -6.125067, 5.5240107, 25.150925, -6.3319364, 5.7359695, 25.081383, -6.125067, 5.5240107, 25.150925, -5.904365, 5.4435186, 24.765022, -5.8518662, 5.369458, 24.847279, -6.125067, 5.5240107, 25.150925, -5.8518662, 5.369458, 24.847279, -5.982039, 5.3976545, 25.132534, -7.8256736, 5.34012, 25.656843, -7.744165, 5.310528, 25.246101, -7.4463277, 5.651639, 25.785309, -7.8256736, 5.34012, 25.656843, -7.8908563, 5.243067, 25.42438, -7.744165, 5.310528, 25.246101, -7.9896913, 5.2398424, 25.73777, -7.989692, 5.1600666, 25.528631, -7.8256736, 5.34012, 25.656843, -7.9896913, 5.2398424, 25.73777, -8.038292, 5.1534004, 25.594398, -7.989692, 5.1600666, 25.528631, -7.9896913, 5.2398424, 25.73777, -7.970281, 5.3087463, 26.08081, -8.13969, 5.114773, 25.721031, -8.239595, 5.077715, 26.094341, -8.13969, 5.114773, 25.721031, -7.970281, 5.3087463, 26.08081, -8.239595, 5.077715, 26.094341, -8.274294, 5.0338635, 25.932472, -8.13969, 5.114773, 25.721031, -6.331285, 5.6798854, 24.653553, -5.876188, 5.388583, 24.768026, -5.910171, 5.4440794, 24.847443, -6.331285, 5.6798854, 24.653553, -6.2332807, 5.427716, 24.268461, -5.876188, 5.388583, 24.768026, -6.555012, 5.4120646, 24.973154, -6.2882495, 5.5129185, 24.845657, -6.350158, 5.405257, 24.957108, -6.555012, 5.4120646, 24.973154, -6.331285, 5.6798854, 24.653553, -6.2882495, 5.5129185, 24.845657, -8.316685, 4.926779, 23.737947, -7.8548923, 5.297988, 23.903517, -7.925697, 5.1127415, 24.21713, -8.316685, 4.926779, 23.737947, -8.301708, 4.9451656, 23.675941, -7.8548923, 5.297988, 23.903517, -7.8548923, 5.297988, 23.903517, -7.815824, 5.15586, 24.324652, -7.925697, 5.1127415, 24.21713, -7.8548923, 5.297988, 23.903517, -7.6972337, 5.3176384, 24.191387, -7.815824, 5.15586, 24.324652, -7.6972337, 5.3176384, 24.191387, -7.8548923, 5.297988, 23.903517, -7.37958, 5.598157, 24.143217, -7.6972337, 5.3176384, 24.191387, -7.37958, 5.598157, 24.143217, -7.52163, 5.2640796, 24.611933, -7.6972337, 5.3176384, 24.191387, -7.52163, 5.2640796, 24.611933, -7.717137, 5.1935024, 24.421171, -7.52163, 5.2640796, 24.611933, -7.3604617, 5.389035, 24.563318, -7.457949, 5.2713842, 24.642237, -7.52163, 5.2640796, 24.611933, -7.37958, 5.598157, 24.143217, -7.3604617, 5.389035, 24.563318, -7.058254, 5.3891068, 24.842972, -6.74797, 5.522382, 24.775219, -6.928924, 5.3911495, 24.875774, -7.058254, 5.3891068, 24.842972, -6.8127713, 5.7382603, 24.422806, -6.74797, 5.522382, 24.775219, -8.2651825, 4.9275074, 23.624132, -7.8548923, 5.297988, 23.903517, -8.301708, 4.9451656, 23.675941, -7.8548923, 5.297988, 23.903517, -8.2651825, 4.9275074, 23.624132, -7.9951816, 5.089792, 23.725204, -7.9951816, 5.089792, 23.725204, -8.2651825, 4.9275074, 23.624132, -8.016663, 4.994527, 23.62873, -7.8548923, 5.297988, 23.903517, -7.4628644, 5.3177576, 23.831608, -7.37958, 5.598157, 24.143217, -7.8548923, 5.297988, 23.903517, -7.6030936, 5.2291493, 23.751238, -7.4628644, 5.3177576, 23.831608, -7.1153607, 5.282588, 23.72141, -6.8127713, 5.7382603, 24.422806, -7.37958, 5.598157, 24.143217, -6.8127713, 5.7382603, 24.422806, -7.1153607, 5.282588, 23.72141, -6.87394, 5.326008, 23.83848, -6.8127713, 5.7382603, 24.422806, -6.87394, 5.326008, 23.83848, -6.8445373, 5.4831157, 24.042107, -6.518, 5.605947, 24.369406, -6.331285, 5.6798854, 24.653553, -6.8127713, 5.7382603, 24.422806, -6.331285, 5.6798854, 24.653553, -6.518, 5.605947, 24.369406, -6.3297257, 5.428781, 24.196327, -6.331285, 5.6798854, 24.653553, -6.3297257, 5.428781, 24.196327, -6.2332807, 5.427716, 24.268461, -6.8127713, 5.7382603, 24.422806, -7.3604617, 5.389035, 24.563318, -7.37958, 5.598157, 24.143217, -7.056313, 5.567637, 24.537035, -7.3604617, 5.389035, 24.563318, -6.8127713, 5.7382603, 24.422806, -7.3604617, 5.389035, 24.563318, -7.056313, 5.567637, 24.537035, -7.24099, 5.2982535, 24.745642, -7.3604617, 5.389035, 24.563318, -7.24099, 5.2982535, 24.745642, -7.3485713, 5.2914233, 24.69535, -6.8127713, 5.7382603, 24.422806, -7.1779733, 5.3646665, 24.78445, -7.056313, 5.567637, 24.537035, -6.8127713, 5.7382603, 24.422806, -7.058254, 5.3891068, 24.842972, -7.1779733, 5.3646665, 24.78445, -6.8445373, 5.4831157, 24.042107, -6.6547856, 5.388571, 23.94828, -6.8127713, 5.7382603, 24.422806, -6.8445373, 5.4831157, 24.042107, -6.7587676, 5.367548, 23.897482, -6.6547856, 5.388571, 23.94828, -6.331285, 5.6798854, 24.653553, -6.74797, 5.522382, 24.775219, -6.8127713, 5.7382603, 24.422806, -6.74797, 5.522382, 24.775219, -6.331285, 5.6798854, 24.653553, -6.555012, 5.4120646, 24.973154, -6.74797, 5.522382, 24.775219, -6.555012, 5.4120646, 24.973154, -6.737456, 5.410923, 24.927305, -6.518, 5.605947, 24.369406, -6.6547856, 5.388571, 23.94828, -6.404985, 5.3717523, 24.133698, -6.518, 5.605947, 24.369406, -6.8127713, 5.7382603, 24.422806, -6.6547856, 5.388571, 23.94828, -5.910171, 5.4440794, 24.847443, -6.2882495, 5.5129185, 24.845657, -6.331285, 5.6798854, 24.653553, -6.2882495, 5.5129185, 24.845657, -5.910171, 5.4440794, 24.847443, -5.947083, 5.38731, 24.924572, -6.2882495, 5.5129185, 24.845657, -5.947083, 5.38731, 24.924572, -6.211325, 5.4130735, 24.94913, -7.4628644, 5.3177576, 23.831608, -7.1153607, 5.282588, 23.72141, -7.37958, 5.598157, 24.143217, -7.4628644, 5.3177576, 23.831608, -7.319452, 5.226307, 23.692755, -7.1153607, 5.282588, 23.72141, -7.6030936, 5.2291493, 23.751238, -7.4537644, 5.1571455, 23.66732, -7.4628644, 5.3177576, 23.831608, -7.6030936, 5.2291493, 23.751238, -7.522962, 5.150751, 23.66025, -7.4537644, 5.1571455, 23.66732, -7.6030936, 5.2291493, 23.751238, -7.8548923, 5.297988, 23.903517, -7.6649528, 5.116803, 23.641397, -7.9951816, 5.089792, 23.725204, -7.6649528, 5.116803, 23.641397, -7.8548923, 5.297988, 23.903517, -7.9951816, 5.089792, 23.725204, -7.8895354, 5.0471697, 23.63519, -7.6649528, 5.116803, 23.641397, -5.857347, 5.7116084, 24.363003, -5.903773, 5.378351, 24.927797, -6.00098, 5.4485216, 24.902779, -5.857347, 5.7116084, 24.363003, -5.4018383, 5.389697, 24.404541, -5.903773, 5.378351, 24.927797, -6.2981954, 5.4044847, 24.163559, -6.076615, 5.5257673, 24.45499, -6.2318177, 5.399699, 24.403456, -6.2981954, 5.4044847, 24.163559, -5.857347, 5.7116084, 24.363003, -6.076615, 5.5257673, 24.45499, -5.4074144, 4.736121, 21.818316, -5.439855, 5.177844, 22.396505, -5.831164, 4.97289, 22.37704, -5.4074144, 4.736121, 21.818316, -5.3315473, 4.755287, 21.823586, -5.439855, 5.177844, 22.396505, -5.439855, 5.177844, 22.396505, -5.9201818, 5.030147, 22.5292, -5.831164, 4.97289, 22.37704, -5.439855, 5.177844, 22.396505, -5.7215886, 5.217056, 22.642551, -5.9201818, 5.030147, 22.5292, -5.7215886, 5.217056, 22.642551, -5.439855, 5.177844, 22.396505, -5.5580716, 5.554124, 23.009472, -5.7215886, 5.217056, 22.642551, -5.5580716, 5.554124, 23.009472, -6.160071, 5.178384, 22.936909, -5.7215886, 5.217056, 22.642551, -6.160071, 5.178384, 22.936909, -6.000282, 5.0809345, 22.665983, -6.160071, 5.178384, 22.936909, -6.046327, 5.3291693, 23.119432, -6.1793313, 5.1894274, 23.018368, -6.160071, 5.178384, 22.936909, -5.5580716, 5.554124, 23.009472, -6.046327, 5.3291693, 23.119432, -6.2826037, 5.3541317, 23.53723, -6.1099467, 5.52, 23.893438, -6.287435, 5.3614902, 23.69768, -6.2826037, 5.3541317, 23.53723, -5.7123218, 5.753208, 23.741987, -6.1099467, 5.52, 23.893438, -5.2635365, 4.7313147, 21.856642, -5.439855, 5.177844, 22.396505, -5.3315473, 4.755287, 21.823586, -5.439855, 5.177844, 22.396505, -5.2635365, 4.7313147, 21.856642, -5.291579, 4.922532, 22.195475, -5.291579, 4.922532, 22.195475, -5.2635365, 4.7313147, 21.856642, -5.19895, 4.8064184, 22.150917, -5.439855, 5.177844, 22.396505, -5.24503, 5.200137, 22.848965, -5.5580716, 5.554124, 23.009472, -5.439855, 5.177844, 22.396505, -5.1995373, 5.0887403, 22.666178, -5.24503, 5.200137, 22.848965, -5.0281267, 5.1566877, 23.240654, -5.7123218, 5.753208, 23.741987, -5.5580716, 5.554124, 23.009472, -5.7123218, 5.753208, 23.741987, -5.0281267, 5.1566877, 23.240654, -5.097282, 5.219322, 23.551094, -5.7123218, 5.753208, 23.741987, -5.097282, 5.219322, 23.551094, -5.3022, 5.4230766, 23.628416, -5.577439, 5.6020293, 24.084873, -5.857347, 5.7116084, 24.363003, -5.7123218, 5.753208, 23.741987, -5.857347, 5.7116084, 24.363003, -5.577439, 5.6020293, 24.084873, -5.342383, 5.384054, 24.275276, -5.857347, 5.7116084, 24.363003, -5.342383, 5.384054, 24.275276, -5.4018383, 5.389697, 24.404541, -5.7123218, 5.753208, 23.741987, -6.046327, 5.3291693, 23.119432, -5.5580716, 5.554124, 23.009472, -5.917007, 5.5500546, 23.476519, -6.046327, 5.3291693, 23.119432, -5.7123218, 5.753208, 23.741987, -6.046327, 5.3291693, 23.119432, -5.917007, 5.5500546, 23.476519, -6.244391, 5.2299757, 23.296265, -6.046327, 5.3291693, 23.119432, -6.244391, 5.2299757, 23.296265, -6.210569, 5.21813, 23.159012, -5.7123218, 5.753208, 23.741987, -6.2491727, 5.318202, 23.382605, -5.917007, 5.5500546, 23.476519, -5.7123218, 5.753208, 23.741987, -6.2826037, 5.3541317, 23.53723, -6.2491727, 5.318202, 23.382605, -5.3022, 5.4230766, 23.628416, -5.154279, 5.307758, 23.83554, -5.7123218, 5.753208, 23.741987, -5.3022, 5.4230766, 23.628416, -5.1251884, 5.2768984, 23.701454, -5.154279, 5.307758, 23.83554, -5.857347, 5.7116084, 24.363003, -6.1099467, 5.52, 23.893438, -5.7123218, 5.753208, 23.741987, -6.1099467, 5.52, 23.893438, -5.857347, 5.7116084, 24.363003, -6.2981954, 5.4044847, 24.163559, -6.1099467, 5.52, 23.893438, -6.2981954, 5.4044847, 24.163559, -6.291105, 5.3954716, 23.937151, -5.577439, 5.6020293, 24.084873, -5.154279, 5.307758, 23.83554, -5.3127255, 5.301915, 24.168657, -5.577439, 5.6020293, 24.084873, -5.7123218, 5.753208, 23.741987, -5.154279, 5.307758, 23.83554, -6.00098, 5.4485216, 24.902779, -6.076615, 5.5257673, 24.45499, -5.857347, 5.7116084, 24.363003, -6.076615, 5.5257673, 24.45499, -6.00098, 5.4485216, 24.902779, -6.1017814, 5.3846993, 24.875137, -6.076615, 5.5257673, 24.45499, -6.1017814, 5.3846993, 24.875137, -6.184852, 5.4140406, 24.567734, -5.24503, 5.200137, 22.848965, -5.0281267, 5.1566877, 23.240654, -5.5580716, 5.554124, 23.009472, -5.24503, 5.200137, 22.848965, -5.0552382, 5.0847697, 22.992046, -5.0281267, 5.1566877, 23.240654, -5.1995373, 5.0887403, 22.666178, -5.0783086, 4.9989347, 22.825645, -5.24503, 5.200137, 22.848965, -5.1995373, 5.0887403, 22.666178, -5.0852733, 4.9907217, 22.742779, -5.0783086, 4.9989347, 22.825645, -5.1995373, 5.0887403, 22.666178, -5.439855, 5.177844, 22.396505, -5.102995, 4.9489727, 22.570704, -5.291579, 4.922532, 22.195475, -5.102995, 4.9489727, 22.570704, -5.439855, 5.177844, 22.396505, -5.291579, 4.922532, 22.195475, -5.163247, 4.866782, 22.303133, -5.102995, 4.9489727, 22.570704, -5.488422, 5.4559135, 25.067163, -6.115338, 5.4586115, 24.851, -6.0311418, 5.4854546, 24.766218, -5.488422, 5.4559135, 25.067163, -5.8165927, 5.2966948, 25.486755, -6.115338, 5.4586115, 24.851, -5.37565, 5.0100093, 24.717901, -5.60895, 5.3052464, 24.837267, -5.5913396, 5.1425714, 24.705996, -5.37565, 5.0100093, 24.717901, -5.488422, 5.4559135, 25.067163, -5.60895, 5.3052464, 24.837267, -3.997026, 3.4084823, 26.23418, -4.2373033, 4.0717287, 26.033955, -4.2301164, 3.8139224, 25.675167, -3.997026, 3.4084823, 26.23418, -4.010387, 3.441938, 26.303183, -4.2373033, 4.0717287, 26.033955, -4.2373033, 4.0717287, 26.033955, -4.3043237, 3.923424, 25.547739, -4.2301164, 3.8139224, 25.675167, -4.2373033, 4.0717287, 26.033955, -4.351472, 4.177483, 25.700325, -4.3043237, 3.923424, 25.547739, -4.351472, 4.177483, 25.700325, -4.2373033, 4.0717287, 26.033955, -4.5209985, 4.6817417, 25.74504, -4.351472, 4.177483, 25.700325, -4.5209985, 4.6817417, 25.74504, -4.504621, 4.2121515, 25.204073, -4.351472, 4.177483, 25.700325, -4.504621, 4.2121515, 25.204073, -4.3712316, 4.021261, 25.432993, -4.504621, 4.2121515, 25.204073, -4.6092005, 4.455448, 25.259403, -4.5602574, 4.259628, 25.160809, -4.504621, 4.2121515, 25.204073, -4.5209985, 4.6817417, 25.74504, -4.6092005, 4.455448, 25.259403, -4.8856854, 4.646908, 24.917786, -5.1403203, 5.003999, 24.975368, -5.0130434, 4.735046, 24.86499, -4.8856854, 4.646908, 24.917786, -4.988566, 5.1979246, 25.387373, -5.1403203, 5.003999, 24.975368, -4.0683527, 3.4522138, 26.357347, -4.2373033, 4.0717287, 26.033955, -4.010387, 3.441938, 26.303183, -4.2373033, 4.0717287, 26.033955, -4.0683527, 3.4522138, 26.357347, -4.242745, 3.782452, 26.231682, -4.242745, 3.782452, 26.231682, -4.0683527, 3.4522138, 26.357347, -4.2885528, 3.6797438, 26.329477, -4.2373033, 4.0717287, 26.033955, -4.656346, 4.3628387, 26.08645, -4.5209985, 4.6817417, 25.74504, -4.2373033, 4.0717287, 26.033955, -4.57377, 4.1835814, 26.181997, -4.656346, 4.3628387, 26.08645, -5.062138, 4.5712886, 26.17474, -4.988566, 5.1979246, 25.387373, -4.5209985, 4.6817417, 25.74504, -4.988566, 5.1979246, 25.387373, -5.062138, 4.5712886, 26.17474, -5.2679048, 4.7707634, 26.016972, -4.988566, 5.1979246, 25.387373, -5.2679048, 4.7707634, 26.016972, -5.1766024, 4.9423556, 25.806808, -5.3932915, 5.275302, 25.41678, -5.488422, 5.4559135, 25.067163, -4.988566, 5.1979246, 25.387373, -5.488422, 5.4559135, 25.067163, -5.3932915, 5.275302, 25.41678, -5.7287784, 5.236825, 25.581738, -5.488422, 5.4559135, 25.067163, -5.7287784, 5.236825, 25.581738, -5.8165927, 5.2966948, 25.486755, -4.988566, 5.1979246, 25.387373, -4.6092005, 4.455448, 25.259403, -4.5209985, 4.6817417, 25.74504, -4.825552, 4.8508716, 25.277138, -4.6092005, 4.455448, 25.259403, -4.988566, 5.1979246, 25.387373, -4.6092005, 4.455448, 25.259403, -4.825552, 4.8508716, 25.277138, -4.749141, 4.4242043, 25.014292, -4.6092005, 4.455448, 25.259403, -4.749141, 4.4242043, 25.014292, -4.653323, 4.3502817, 25.089447, -4.988566, 5.1979246, 25.387373, -4.784744, 4.543359, 24.994701, -4.825552, 4.8508716, 25.277138, -4.988566, 5.1979246, 25.387373, -4.8856854, 4.646908, 24.917786, -4.784744, 4.543359, 24.994701, -5.1766024, 4.9423556, 25.806808, -5.4469156, 4.981415, 25.883078, -4.988566, 5.1979246, 25.387373, -5.1766024, 4.9423556, 25.806808, -5.3591576, 4.891834, 25.949957, -5.4469156, 4.981415, 25.883078, -5.488422, 5.4559135, 25.067163, -5.1403203, 5.003999, 24.975368, -4.988566, 5.1979246, 25.387373, -5.1403203, 5.003999, 24.975368, -5.488422, 5.4559135, 25.067163, -5.37565, 5.0100093, 24.717901, -5.1403203, 5.003999, 24.975368, -5.37565, 5.0100093, 24.717901, -5.1955976, 4.8869286, 24.792862, -5.3932915, 5.275302, 25.41678, -5.4469156, 4.981415, 25.883078, -5.6794295, 5.1171756, 25.630447, -5.3932915, 5.275302, 25.41678, -4.988566, 5.1979246, 25.387373, -5.4469156, 4.981415, 25.883078, -6.0311418, 5.4854546, 24.766218, -5.60895, 5.3052464, 24.837267, -5.488422, 5.4559135, 25.067163, -5.60895, 5.3052464, 24.837267, -6.0311418, 5.4854546, 24.766218, -6.01732, 5.3982096, 24.681461, -5.60895, 5.3052464, 24.837267, -6.01732, 5.3982096, 24.681461, -5.733069, 5.248727, 24.701923, -4.656346, 4.3628387, 26.08645, -5.062138, 4.5712886, 26.17474, -4.5209985, 4.6817417, 25.74504, -4.656346, 4.3628387, 26.08645, -4.8856683, 4.377059, 26.22222, -5.062138, 4.5712886, 26.17474, -4.57377, 4.1835814, 26.181997, -4.779701, 4.213683, 26.243425, -4.656346, 4.3628387, 26.08645, -4.57377, 4.1835814, 26.181997, -4.715779, 4.162856, 26.263649, -4.779701, 4.213683, 26.243425, -4.57377, 4.1835814, 26.181997, -4.2373033, 4.0717287, 26.033955, -4.590958, 4.0354905, 26.29871, -4.242745, 3.782452, 26.231682, -4.590958, 4.0354905, 26.29871, -4.2373033, 4.0717287, 26.033955, -4.242745, 3.782452, 26.231682, -4.395331, 3.8163853, 26.320553, -4.590958, 4.0354905, 26.29871, -6.1042504, 1.2728562, 23.197392, -5.918094, 2.7018955, 23.690025, -5.9148564, 2.7549148, 23.524578, -6.1042504, 1.2728562, 23.197392, -6.1098676, 1.2173094, 23.399563, -5.918094, 2.7018955, 23.690025, -6.4102006, -0.38891935, 23.115858, -6.1098676, 1.2173094, 23.399563, -6.1042504, 1.2728562, 23.197392, -6.4102006, -0.38891935, 23.115858, -6.407615, -0.42018914, 23.34877, -6.1098676, 1.2173094, 23.399563, -6.445969, 1.214475, 23.560837, -6.334398, 2.7400944, 23.713541, -6.206943, 2.6917243, 23.821266, -6.445969, 1.214475, 23.560837, -6.593639, 1.2685848, 23.43337, -6.334398, 2.7400944, 23.713541, -6.45285, 1.3504215, 23.077362, -6.04308, 2.8031602, 23.417374, -6.2159896, 2.8219187, 23.420893, -6.45285, 1.3504215, 23.077362, -6.2506223, 1.3273255, 23.06642, -6.04308, 2.8031602, 23.417374, -6.206943, 2.6917243, 23.821266, -6.1571717, 4.087394, 24.238876, -6.071984, 4.053143, 24.309086, -6.206943, 2.6917243, 23.821266, -6.334398, 2.7400944, 23.713541, -6.1571717, 4.087394, 24.238876, -5.9148564, 2.7549148, 23.524578, -5.878671, 4.066166, 24.22505, -5.875483, 4.1052194, 24.1148, -5.9148564, 2.7549148, 23.524578, -5.918094, 2.7018955, 23.690025, -5.878671, 4.066166, 24.22505, -5.875483, 4.1052194, 24.1148, -5.878671, 4.066166, 24.22505, -5.8760715, 4.7822523, 24.560642, -5.875483, 4.1052194, 24.1148, -5.8760715, 4.7822523, 24.560642, -5.8734727, 4.8202696, 24.482874, -6.1098676, 1.2173094, 23.399563, -6.0342355, 2.6733067, 23.816893, -5.918094, 2.7018955, 23.690025, -6.1098676, 1.2173094, 23.399563, -6.2450395, 1.1910194, 23.5534, -6.0342355, 2.6733067, 23.816893, -5.918094, 2.7018955, 23.690025, -6.0342355, 2.6733067, 23.816893, -5.9568696, 4.0428257, 24.307844, -5.918094, 2.7018955, 23.690025, -5.9568696, 4.0428257, 24.307844, -5.878671, 4.066166, 24.22505, -6.593639, 1.2685848, 23.43337, -6.3314233, 2.7938638, 23.547709, -6.334398, 2.7400944, 23.713541, -6.593639, 1.2685848, 23.43337, -6.589192, 1.3239617, 23.234522, -6.3314233, 2.7938638, 23.547709, -6.334398, 2.7400944, 23.713541, -6.3314233, 2.7938638, 23.547709, -6.1551642, 4.1276875, 24.129848, -6.334398, 2.7400944, 23.713541, -6.1551642, 4.1276875, 24.129848, -6.1571717, 4.087394, 24.238876, -6.2159896, 2.8219187, 23.420893, -5.9612355, 4.1389728, 24.042906, -6.0775657, 4.150184, 24.04522, -6.2159896, 2.8219187, 23.420893, -6.04308, 2.8031602, 23.417374, -5.9612355, 4.1389728, 24.042906, -6.2506223, 1.3273255, 23.06642, -5.9148564, 2.7549148, 23.524578, -6.04308, 2.8031602, 23.417374, -6.2506223, 1.3273255, 23.06642, -6.1042504, 1.2728562, 23.197392, -5.9148564, 2.7549148, 23.524578, -6.04308, 2.8031602, 23.417374, -5.875483, 4.1052194, 24.1148, -5.9612355, 4.1389728, 24.042906, -6.04308, 2.8031602, 23.417374, -5.9148564, 2.7549148, 23.524578, -5.875483, 4.1052194, 24.1148, -6.589192, 1.3239617, 23.234522, -6.2159896, 2.8219187, 23.420893, -6.3314233, 2.7938638, 23.547709, -6.589192, 1.3239617, 23.234522, -6.45285, 1.3504215, 23.077362, -6.2159896, 2.8219187, 23.420893, -6.971047, -0.29482222, 23.15477, -6.45285, 1.3504215, 23.077362, -6.589192, 1.3239617, 23.234522, -6.971047, -0.29482222, 23.15477, -6.8199706, -0.29774308, 22.974768, -6.45285, 1.3504215, 23.077362, -6.3314233, 2.7938638, 23.547709, -6.0775657, 4.150184, 24.04522, -6.1551642, 4.1276875, 24.129848, -6.3314233, 2.7938638, 23.547709, -6.2159896, 2.8219187, 23.420893, -6.0775657, 4.150184, 24.04522, -6.1551642, 4.1276875, 24.129848, -6.0775657, 4.150184, 24.04522, -6.023566, 4.8536663, 24.431192, -6.1551642, 4.1276875, 24.129848, -6.023566, 4.8536663, 24.431192, -6.082048, 4.828084, 24.48972, -6.020826, 4.7619505, 24.618555, -5.936191, 5.2892733, 24.899128, -5.934734, 4.757289, 24.618954, -6.020826, 4.7619505, 24.618555, -5.999312, 5.2915688, 24.898323, -5.936191, 5.2892733, 24.899128, -5.8760715, 4.7822523, 24.560642, -5.8913436, 5.3461533, 24.805635, -5.8734727, 4.8202696, 24.482874, -5.8760715, 4.7822523, 24.560642, -5.8932214, 5.3139095, 24.859932, -5.8913436, 5.3461533, 24.805635, -5.934734, 4.757289, 24.618954, -5.8932214, 5.3139095, 24.859932, -5.8760715, 4.7822523, 24.560642, -5.934734, 4.757289, 24.618954, -5.936191, 5.2892733, 24.899128, -5.8932214, 5.3139095, 24.859932, -5.9370246, 4.8486996, 24.431229, -6.000772, 5.3705015, 24.767956, -6.023566, 4.8536663, 24.431192, -5.9370246, 4.8486996, 24.431229, -5.937638, 5.3697376, 24.769716, -6.000772, 5.3705015, 24.767956, -5.8734727, 4.8202696, 24.482874, -5.937638, 5.3697376, 24.769716, -5.9370246, 4.8486996, 24.431229, -5.8734727, 4.8202696, 24.482874, -5.8913436, 5.3461533, 24.805635, -5.937638, 5.3697376, 24.769716, -6.082048, 4.828084, 24.48972, -6.0456195, 5.3136215, 24.86145, -6.0841565, 4.790536, 24.567505, -6.082048, 4.828084, 24.48972, -6.0437293, 5.3473988, 24.80811, -6.0456195, 5.3136215, 24.86145, -6.023566, 4.8536663, 24.431192, -6.0437293, 5.3473988, 24.80811, -6.082048, 4.828084, 24.48972, -6.023566, 4.8536663, 24.431192, -6.000772, 5.3705015, 24.767956, -6.0437293, 5.3473988, 24.80811, -6.5591636, -0.41737175, 23.530104, -6.445969, 1.214475, 23.560837, -6.2450395, 1.1910194, 23.5534, -6.5591636, -0.41737175, 23.530104, -6.791864, -0.37708354, 23.536917, -6.445969, 1.214475, 23.560837, -6.0841565, 4.790536, 24.567505, -5.999312, 5.2915688, 24.898323, -6.020826, 4.7619505, 24.618555, -6.0841565, 4.790536, 24.567505, -6.0456195, 5.3136215, 24.86145, -5.999312, 5.2915688, 24.898323, -6.2450395, 1.1910194, 23.5534, -6.206943, 2.6917243, 23.821266, -6.0342355, 2.6733067, 23.816893, -6.2450395, 1.1910194, 23.5534, -6.445969, 1.214475, 23.560837, -6.206943, 2.6917243, 23.821266, -6.791864, -0.37708354, 23.536917, -6.593639, 1.2685848, 23.43337, -6.445969, 1.214475, 23.560837, -6.791864, -0.37708354, 23.536917, -6.9689336, -0.32619548, 23.389013, -6.593639, 1.2685848, 23.43337, -6.8199706, -0.29774308, 22.974768, -6.2506223, 1.3273255, 23.06642, -6.45285, 1.3504215, 23.077362, -6.8199706, -0.29774308, 22.974768, -6.5876184, -0.33810782, 22.968916, -6.2506223, 1.3273255, 23.06642, -6.407615, -0.42018914, 23.34877, -6.2450395, 1.1910194, 23.5534, -6.1098676, 1.2173094, 23.399563, -6.407615, -0.42018914, 23.34877, -6.5591636, -0.41737175, 23.530104, -6.2450395, 1.1910194, 23.5534, -6.9689336, -0.32619548, 23.389013, -6.589192, 1.3239617, 23.234522, -6.593639, 1.2685848, 23.43337, -6.9689336, -0.32619548, 23.389013, -6.971047, -0.29482222, 23.15477, -6.589192, 1.3239617, 23.234522, -6.5876184, -0.33810782, 22.968916, -6.1042504, 1.2728562, 23.197392, -6.2506223, 1.3273255, 23.06642, -6.5876184, -0.33810782, 22.968916, -6.4102006, -0.38891935, 23.115858, -6.1042504, 1.2728562, 23.197392, -6.0342355, 2.6733067, 23.816893, -6.071984, 4.053143, 24.309086, -5.9568696, 4.0428257, 24.307844, -6.0342355, 2.6733067, 23.816893, -6.206943, 2.6917243, 23.821266, -6.071984, 4.053143, 24.309086, -5.9568696, 4.0428257, 24.307844, -6.020826, 4.7619505, 24.618555, -5.934734, 4.757289, 24.618954, -5.9568696, 4.0428257, 24.307844, -6.071984, 4.053143, 24.309086, -6.020826, 4.7619505, 24.618555, -6.071984, 4.053143, 24.309086, -6.0841565, 4.790536, 24.567505, -6.020826, 4.7619505, 24.618555, -6.071984, 4.053143, 24.309086, -6.1571717, 4.087394, 24.238876, -6.0841565, 4.790536, 24.567505, -5.878671, 4.066166, 24.22505, -5.934734, 4.757289, 24.618954, -5.8760715, 4.7822523, 24.560642, -5.878671, 4.066166, 24.22505, -5.9568696, 4.0428257, 24.307844, -5.934734, 4.757289, 24.618954, -6.0775657, 4.150184, 24.04522, -5.9370246, 4.8486996, 24.431229, -6.023566, 4.8536663, 24.431192, -6.0775657, 4.150184, 24.04522, -5.9612355, 4.1389728, 24.042906, -5.9370246, 4.8486996, 24.431229, -5.9612355, 4.1389728, 24.042906, -5.8734727, 4.8202696, 24.482874, -5.9370246, 4.8486996, 24.431229, -5.9612355, 4.1389728, 24.042906, -5.875483, 4.1052194, 24.1148, -5.8734727, 4.8202696, 24.482874, -6.1571717, 4.087394, 24.238876, -6.082048, 4.828084, 24.48972, -6.0841565, 4.790536, 24.567505, -6.1571717, 4.087394, 24.238876, -6.1551642, 4.1276875, 24.129848, -6.082048, 4.828084, 24.48972, -7.3456454, 4.1475077, 21.98113, -7.7547154, 4.0165606, 21.935587, -7.73344, 4.0729046, 21.883982, -7.3456454, 4.1475077, 21.98113, -7.39551, 3.8681917, 22.222395, -7.7547154, 4.0165606, 21.935587, -7.2885756, 4.023938, 21.648745, -7.4439178, 4.086134, 21.816298, -7.4376407, 4.0358357, 21.702383, -7.2885756, 4.023938, 21.648745, -7.3456454, 4.1475077, 21.98113, -7.4439178, 4.086134, 21.816298, -5.835222, 3.136388, 22.094479, -6.143434, 3.5016217, 22.14021, -6.182443, 3.446207, 21.86201, -5.835222, 3.136388, 22.094479, -5.831487, 3.134635, 22.146042, -6.143434, 3.5016217, 22.14021, -6.143434, 3.5016217, 22.14021, -6.276529, 3.5192416, 21.814625, -6.182443, 3.446207, 21.86201, -6.143434, 3.5016217, 22.14021, -6.311259, 3.6133804, 21.96652, -6.276529, 3.5192416, 21.814625, -6.311259, 3.6133804, 21.96652, -6.143434, 3.5016217, 22.14021, -6.488027, 3.838162, 22.121197, -6.311259, 3.6133804, 21.96652, -6.488027, 3.838162, 22.121197, -6.5290217, 3.7089415, 21.687683, -6.311259, 3.6133804, 21.96652, -6.5290217, 3.7089415, 21.687683, -6.3611083, 3.5839481, 21.772032, -6.5290217, 3.7089415, 21.687683, -6.61853, 3.8050375, 21.778206, -6.5801196, 3.7298079, 21.680998, -6.5290217, 3.7089415, 21.687683, -6.488027, 3.838162, 22.121197, -6.61853, 3.8050375, 21.778206, -6.895559, 3.9183722, 21.635805, -7.089803, 4.029269, 21.773304, -6.996865, 3.9430032, 21.63935, -6.895559, 3.9183722, 21.635805, -6.936025, 4.07687, 22.060637, -7.089803, 4.029269, 21.773304, -5.852432, 3.11117, 22.186768, -6.143434, 3.5016217, 22.14021, -5.831487, 3.134635, 22.146042, -6.143434, 3.5016217, 22.14021, -5.852432, 3.11117, 22.186768, -6.0435195, 3.2870784, 22.199028, -6.0435195, 3.2870784, 22.199028, -5.852432, 3.11117, 22.186768, -6.0259957, 3.1908565, 22.246626, -6.143434, 3.5016217, 22.14021, -6.4199605, 3.5414572, 22.270983, -6.488027, 3.838162, 22.121197, -6.143434, 3.5016217, 22.14021, -6.316384, 3.4395418, 22.283651, -6.4199605, 3.5414572, 22.270983, -6.6648736, 3.5251694, 22.411572, -6.936025, 4.07687, 22.060637, -6.488027, 3.838162, 22.121197, -6.936025, 4.07687, 22.060637, -6.6648736, 3.5251694, 22.411572, -6.8574367, 3.616784, 22.385473, -6.936025, 4.07687, 22.060637, -6.8574367, 3.616784, 22.385473, -6.8897033, 3.7860932, 22.274248, -7.1705127, 3.995197, 22.128761, -7.3456454, 4.1475077, 21.98113, -6.936025, 4.07687, 22.060637, -7.3456454, 4.1475077, 21.98113, -7.1705127, 3.995197, 22.128761, -7.311005, 3.8388238, 22.254168, -7.3456454, 4.1475077, 21.98113, -7.311005, 3.8388238, 22.254168, -7.39551, 3.8681917, 22.222395, -6.936025, 4.07687, 22.060637, -6.61853, 3.8050375, 21.778206, -6.488027, 3.838162, 22.121197, -6.808315, 3.9616337, 21.892696, -6.61853, 3.8050375, 21.778206, -6.936025, 4.07687, 22.060637, -6.61853, 3.8050375, 21.778206, -6.808315, 3.9616337, 21.892696, -6.754091, 3.8023548, 21.658047, -6.61853, 3.8050375, 21.778206, -6.754091, 3.8023548, 21.658047, -6.667336, 3.7712486, 21.66921, -6.936025, 4.07687, 22.060637, -6.8002677, 3.871698, 21.648817, -6.808315, 3.9616337, 21.892696, -6.936025, 4.07687, 22.060637, -6.895559, 3.9183722, 21.635805, -6.8002677, 3.871698, 21.648817, -6.8897033, 3.7860932, 22.274248, -7.0301766, 3.7129135, 22.36113, -6.936025, 4.07687, 22.060637, -6.8897033, 3.7860932, 22.274248, -6.94754, 3.6726983, 22.372395, -7.0301766, 3.7129135, 22.36113, -7.3456454, 4.1475077, 21.98113, -7.089803, 4.029269, 21.773304, -6.936025, 4.07687, 22.060637, -7.089803, 4.029269, 21.773304, -7.3456454, 4.1475077, 21.98113, -7.2885756, 4.023938, 21.648745, -7.089803, 4.029269, 21.773304, -7.2885756, 4.023938, 21.648745, -7.145592, 3.9925408, 21.643513, -7.1705127, 3.995197, 22.128761, -7.0301766, 3.7129135, 22.36113, -7.250442, 3.7801287, 22.278788, -7.1705127, 3.995197, 22.128761, -6.936025, 4.07687, 22.060637, -7.0301766, 3.7129135, 22.36113, -7.73344, 4.0729046, 21.883982, -7.4439178, 4.086134, 21.816298, -7.3456454, 4.1475077, 21.98113, -7.4439178, 4.086134, 21.816298, -7.73344, 4.0729046, 21.883982, -7.730845, 4.0477705, 21.8088, -7.4439178, 4.086134, 21.816298, -7.730845, 4.0477705, 21.8088, -7.537543, 4.047288, 21.737965, -6.4199605, 3.5414572, 22.270983, -6.6648736, 3.5251694, 22.411572, -6.488027, 3.838162, 22.121197, -6.4199605, 3.5414572, 22.270983, -6.5177665, 3.4538927, 22.380463, -6.6648736, 3.5251694, 22.411572, -6.316384, 3.4395418, 22.283651, -6.4231963, 3.3844132, 22.362364, -6.4199605, 3.5414572, 22.270983, -6.316384, 3.4395418, 22.283651, -6.372542, 3.369453, 22.350878, -6.4231963, 3.3844132, 22.362364, -6.316384, 3.4395418, 22.283651, -6.143434, 3.5016217, 22.14021, -6.269735, 3.3225741, 22.32892, -6.0435195, 3.2870784, 22.199028, -6.269735, 3.3225741, 22.32892, -6.143434, 3.5016217, 22.14021, -6.0435195, 3.2870784, 22.199028, -6.1135287, 3.244587, 22.275616, -6.269735, 3.3225741, 22.32892, -8.006098, 4.0689516, 22.13492, -7.6670833, 4.0418825, 21.783504, -7.640471, 4.0882125, 21.854998, -8.006098, 4.0689516, 22.13492, -8.1642685, 3.8312206, 21.842627, -7.6670833, 4.0418825, 21.783504, -7.756726, 3.8452916, 22.38111, -7.7926917, 3.9996805, 22.145943, -7.6962695, 3.9147148, 22.219965, -7.756726, 3.8452916, 22.38111, -8.006098, 4.0689516, 22.13492, -7.7926917, 3.9996805, 22.145943, -9.112757, 2.5434918, 23.142544, -8.955492, 3.0269203, 22.925161, -8.686771, 2.9242716, 23.071201, -9.112757, 2.5434918, 23.142544, -9.159386, 2.5504227, 23.109524, -8.955492, 3.0269203, 22.925161, -8.955492, 3.0269203, 22.925161, -8.584085, 3.0207791, 23.038433, -8.686771, 2.9242716, 23.071201, -8.955492, 3.0269203, 22.925161, -8.695301, 3.1616163, 22.923185, -8.584085, 3.0207791, 23.038433, -8.695301, 3.1616163, 22.923185, -8.955492, 3.0269203, 22.925161, -8.715704, 3.4878736, 22.713732, -8.695301, 3.1616163, 22.923185, -8.715704, 3.4878736, 22.713732, -8.307899, 3.2759342, 22.950727, -8.695301, 3.1616163, 22.923185, -8.307899, 3.2759342, 22.950727, -8.491686, 3.1070724, 23.009012, -8.307899, 3.2759342, 22.950727, -8.328896, 3.4248629, 22.82755, -8.266613, 3.3116674, 22.91728, -8.307899, 3.2759342, 22.950727, -8.715704, 3.4878736, 22.713732, -8.328896, 3.4248629, 22.82755, -8.018351, 3.603071, 22.708073, -8.005375, 3.8135586, 22.46657, -7.9505553, 3.661367, 22.623951, -8.018351, 3.603071, 22.708073, -8.361177, 3.876412, 22.41829, -8.005375, 3.8135586, 22.46657, -9.178247, 2.5394235, 23.05604, -8.955492, 3.0269203, 22.925161, -9.159386, 2.5504227, 23.109524, -8.955492, 3.0269203, 22.925161, -9.178247, 2.5394235, 23.05604, -9.064192, 2.7795045, 22.919472, -9.064192, 2.7795045, 22.919472, -9.178247, 2.5394235, 23.05604, -9.110924, 2.6796026, 22.880608, -8.955492, 3.0269203, 22.925161, -8.875946, 3.1743643, 22.591955, -8.715704, 3.4878736, 22.713732, -8.955492, 3.0269203, 22.925161, -8.9538765, 3.0363877, 22.650652, -8.875946, 3.1743643, 22.591955, -8.822695, 3.2558434, 22.270594, -8.361177, 3.876412, 22.41829, -8.715704, 3.4878736, 22.713732, -8.361177, 3.876412, 22.41829, -8.822695, 3.2558434, 22.270594, -8.668564, 3.40597, 22.14398, -8.361177, 3.876412, 22.41829, -8.668564, 3.40597, 22.14398, -8.56176, 3.58513, 22.231379, -8.250622, 3.8801494, 22.13827, -8.006098, 4.0689516, 22.13492, -8.361177, 3.876412, 22.41829, -8.006098, 4.0689516, 22.13492, -8.250622, 3.8801494, 22.13827, -8.250301, 3.7793045, 21.885279, -8.006098, 4.0689516, 22.13492, -8.250301, 3.7793045, 21.885279, -8.1642685, 3.8312206, 21.842627, -8.361177, 3.876412, 22.41829, -8.328896, 3.4248629, 22.82755, -8.715704, 3.4878736, 22.713732, -8.302567, 3.6759555, 22.615335, -8.328896, 3.4248629, 22.82755, -8.361177, 3.876412, 22.41829, -8.328896, 3.4248629, 22.82755, -8.302567, 3.6759555, 22.615335, -8.126291, 3.4355674, 22.803347, -8.328896, 3.4248629, 22.82755, -8.126291, 3.4355674, 22.803347, -8.196812, 3.379901, 22.85993, -8.361177, 3.876412, 22.41829, -8.094337, 3.5251052, 22.770916, -8.302567, 3.6759555, 22.615335, -8.361177, 3.876412, 22.41829, -8.018351, 3.603071, 22.708073, -8.094337, 3.5251052, 22.770916, -8.56176, 3.58513, 22.231379, -8.532204, 3.5644715, 22.029161, -8.361177, 3.876412, 22.41829, -8.56176, 3.58513, 22.231379, -8.598177, 3.497055, 22.083708, -8.532204, 3.5644715, 22.029161, -8.006098, 4.0689516, 22.13492, -8.005375, 3.8135586, 22.46657, -8.361177, 3.876412, 22.41829, -8.005375, 3.8135586, 22.46657, -8.006098, 4.0689516, 22.13492, -7.756726, 3.8452916, 22.38111, -8.005375, 3.8135586, 22.46657, -7.756726, 3.8452916, 22.38111, -7.852491, 3.7639642, 22.499866, -8.250622, 3.8801494, 22.13827, -8.532204, 3.5644715, 22.029161, -8.306704, 3.6845965, 21.918798, -8.250622, 3.8801494, 22.13827, -8.361177, 3.876412, 22.41829, -8.532204, 3.5644715, 22.029161, -7.640471, 4.0882125, 21.854998, -7.7926917, 3.9996805, 22.145943, -8.006098, 4.0689516, 22.13492, -7.7926917, 3.9996805, 22.145943, -7.640471, 4.0882125, 21.854998, -7.576958, 4.04729, 21.902573, -7.7926917, 3.9996805, 22.145943, -7.576958, 4.04729, 21.902573, -7.6565347, 3.9742813, 22.111242, -8.875946, 3.1743643, 22.591955, -8.822695, 3.2558434, 22.270594, -8.715704, 3.4878736, 22.713732, -8.875946, 3.1743643, 22.591955, -8.895857, 3.1287508, 22.405542, -8.822695, 3.2558434, 22.270594, -8.9538765, 3.0363877, 22.650652, -8.940609, 3.017987, 22.493166, -8.875946, 3.1743643, 22.591955, -8.9538765, 3.0363877, 22.650652, -8.9667015, 2.9863772, 22.539291, -8.940609, 3.017987, 22.493166, -8.9538765, 3.0363877, 22.650652, -8.955492, 3.0269203, 22.925161, -9.018259, 2.9038734, 22.63329, -9.064192, 2.7795045, 22.919472, -9.018259, 2.9038734, 22.63329, -8.955492, 3.0269203, 22.925161, -9.064192, 2.7795045, 22.919472, -9.078123, 2.7668862, 22.79162, -9.018259, 2.9038734, 22.63329, -8.007105, 4.2432814, 21.79742, -7.6102357, 3.9935734, 21.830496, -7.6156516, 4.0593348, 21.890636, -8.007105, 4.2432814, 21.79742, -8.03323, 3.9421675, 21.519663, -7.6102357, 3.9935734, 21.830496, -8.098074, 4.0979156, 22.171206, -7.9227233, 4.142306, 21.975924, -7.9388556, 4.0766134, 22.104975, -8.098074, 4.0979156, 22.171206, -8.007105, 4.2432814, 21.79742, -7.9227233, 4.142306, 21.975924, -9.771751, 3.535802, 21.75793, -9.379068, 3.8438268, 21.68758, -9.350674, 3.7584422, 21.997433, -9.771751, 3.535802, 21.75793, -9.776064, 3.537459, 21.700321, -9.379068, 3.8438268, 21.68758, -9.379068, 3.8438268, 21.68758, -9.238958, 3.8097954, 22.04487, -9.350674, 3.7584422, 21.997433, -9.379068, 3.8438268, 21.68758, -9.184797, 3.91052, 21.871664, -9.238958, 3.8097954, 22.04487, -9.184797, 3.91052, 21.871664, -9.379068, 3.8438268, 21.68758, -8.955339, 4.1180353, 21.68752, -9.184797, 3.91052, 21.871664, -8.955339, 4.1180353, 21.68752, -8.938385, 3.9436283, 22.172657, -9.184797, 3.91052, 21.871664, -8.938385, 3.9436283, 22.172657, -9.138344, 3.8554869, 22.087576, -8.938385, 3.9436283, 22.172657, -8.830858, 4.0307946, 22.063833, -8.879677, 3.9519649, 22.17815, -8.938385, 3.9436283, 22.172657, -8.955339, 4.1180353, 21.68752, -8.830858, 4.0307946, 22.063833, -8.525064, 4.0757513, 22.206758, -8.3042145, 4.1594357, 22.040773, -8.414618, 4.077264, 22.197893, -8.525064, 4.0757513, 22.206758, -8.445869, 4.2673163, 21.729216, -8.3042145, 4.1594357, 22.040773, -9.761192, 3.5089111, 21.653206, -9.379068, 3.8438268, 21.68758, -9.776064, 3.537459, 21.700321, -9.379068, 3.8438268, 21.68758, -9.761192, 3.5089111, 21.653206, -9.529911, 3.6422298, 21.627573, -9.529911, 3.6422298, 21.627573, -9.761192, 3.5089111, 21.653206, -9.567165, 3.544581, 21.576538, -9.379068, 3.8438268, 21.68758, -9.097656, 3.8193562, 21.5222, -8.955339, 4.1180353, 21.68752, -9.379068, 3.8438268, 21.68758, -9.2248955, 3.7364876, 21.514492, -9.097656, 3.8193562, 21.5222, -8.856268, 3.7445884, 21.349636, -8.445869, 4.2673163, 21.729216, -8.955339, 4.1180353, 21.68752, -8.445869, 4.2673163, 21.729216, -8.856268, 3.7445884, 21.349636, -8.63674, 3.7917051, 21.369219, -8.445869, 4.2673163, 21.729216, -8.63674, 3.7917051, 21.369219, -8.568174, 3.9699275, 21.489882, -8.232094, 4.130494, 21.637142, -8.007105, 4.2432814, 21.79742, -8.445869, 4.2673163, 21.729216, -8.007105, 4.2432814, 21.79742, -8.232094, 4.130494, 21.637142, -8.128689, 3.9327025, 21.48767, -8.007105, 4.2432814, 21.79742, -8.128689, 3.9327025, 21.48767, -8.03323, 3.9421675, 21.519663, -8.445869, 4.2673163, 21.729216, -8.830858, 4.0307946, 22.063833, -8.955339, 4.1180353, 21.68752, -8.606184, 4.161176, 21.922972, -8.830858, 4.0307946, 22.063833, -8.445869, 4.2673163, 21.729216, -8.830858, 4.0307946, 22.063833, -8.606184, 4.161176, 21.922972, -8.680071, 3.982471, 22.196703, -8.830858, 4.0307946, 22.063833, -8.680071, 3.982471, 22.196703, -8.7803135, 3.9735296, 22.187021, -8.445869, 4.2673163, 21.729216, -8.633348, 4.0487413, 22.19735, -8.606184, 4.161176, 21.922972, -8.445869, 4.2673163, 21.729216, -8.525064, 4.0757513, 22.206758, -8.633348, 4.0487413, 22.19735, -8.568174, 3.9699275, 21.489882, -8.441978, 3.8581774, 21.385015, -8.445869, 4.2673163, 21.729216, -8.568174, 3.9699275, 21.489882, -8.53607, 3.8349931, 21.376839, -8.441978, 3.8581774, 21.385015, -8.007105, 4.2432814, 21.79742, -8.3042145, 4.1594357, 22.040773, -8.445869, 4.2673163, 21.729216, -8.3042145, 4.1594357, 22.040773, -8.007105, 4.2432814, 21.79742, -8.098074, 4.0979156, 22.171206, -8.3042145, 4.1594357, 22.040773, -8.098074, 4.0979156, 22.171206, -8.254183, 4.096847, 22.18368, -8.232094, 4.130494, 21.637142, -8.441978, 3.8581774, 21.385015, -8.191664, 3.867425, 21.469576, -8.232094, 4.130494, 21.637142, -8.445869, 4.2673163, 21.729216, -8.441978, 3.8581774, 21.385015, -7.6156516, 4.0593348, 21.890636, -7.9227233, 4.142306, 21.975924, -8.007105, 4.2432814, 21.79742, -7.9227233, 4.142306, 21.975924, -7.6156516, 4.0593348, 21.890636, -7.62472, 4.0303593, 21.974613, -7.9227233, 4.142306, 21.975924, -7.62472, 4.0303593, 21.974613, -7.833011, 4.0754824, 22.059702, -9.097656, 3.8193562, 21.5222, -8.856268, 3.7445884, 21.349636, -8.955339, 4.1180353, 21.68752, -9.097656, 3.8193562, 21.5222, -9.020979, 3.7032843, 21.39327, -8.856268, 3.7445884, 21.349636, -9.2248955, 3.7364876, 21.514492, -9.123741, 3.6469016, 21.423038, -9.097656, 3.8193562, 21.5222, -9.2248955, 3.7364876, 21.514492, -9.181778, 3.6451564, 21.437378, -9.123741, 3.6469016, 21.423038, -9.2248955, 3.7364876, 21.514492, -9.379068, 3.8438268, 21.68758, -9.29739, 3.622695, 21.467472, -9.529911, 3.6422298, 21.627573, -9.29739, 3.622695, 21.467472, -9.379068, 3.8438268, 21.68758, -9.529911, 3.6422298, 21.627573, -9.471037, 3.5795534, 21.537083, -9.29739, 3.622695, 21.467472, -7.801645, 4.134115, 21.58577, -7.6006274, 4.02651, 21.917599, -7.6581864, 4.072356, 21.931242, -7.801645, 4.134115, 21.58577, -7.559606, 3.912532, 21.485746, -7.6006274, 4.02651, 21.917599, -8.072794, 3.9650502, 21.715675, -7.866752, 4.057515, 21.744825, -7.952191, 3.99135, 21.80066, -8.072794, 3.9650502, 21.715675, -7.801645, 4.134115, 21.58577, -7.866752, 4.057515, 21.744825, -8.445419, 3.1368337, 20.389019, -8.276415, 3.4964035, 20.596956, -8.461891, 3.4085104, 20.773108, -8.445419, 3.1368337, 20.389019, -8.407898, 3.1417727, 20.358862, -8.276415, 3.4964035, 20.596956, -8.276415, 3.4964035, 20.596956, -8.453417, 3.4740689, 20.868896, -8.461891, 3.4085104, 20.773108, -8.276415, 3.4964035, 20.596956, -8.327274, 3.583331, 20.814444, -8.453417, 3.4740689, 20.868896, -8.327274, 3.583331, 20.814444, -8.276415, 3.4964035, 20.596956, -8.134125, 3.821342, 20.866423, -8.327274, 3.583331, 20.814444, -8.134125, 3.821342, 20.866423, -8.4303055, 3.644507, 21.12673, -8.327274, 3.583331, 20.814444, -8.4303055, 3.644507, 21.12673, -8.445759, 3.5321927, 20.955154, -8.4303055, 3.644507, 21.12673, -8.320477, 3.7492816, 21.144423, -8.409815, 3.665008, 21.169518, -8.4303055, 3.644507, 21.12673, -8.134125, 3.821342, 20.866423, -8.320477, 3.7492816, 21.144423, -8.287462, 3.8450718, 21.426939, -8.086512, 3.9769306, 21.499489, -8.231916, 3.8731332, 21.501694, -8.287462, 3.8450718, 21.426939, -7.9577355, 4.0573144, 21.235031, -8.086512, 3.9769306, 21.499489, -8.362757, 3.127033, 20.352093, -8.276415, 3.4964035, 20.596956, -8.407898, 3.1417727, 20.358862, -8.276415, 3.4964035, 20.596956, -8.362757, 3.127033, 20.352093, -8.265045, 3.3001463, 20.48862, -8.265045, 3.3001463, 20.48862, -8.362757, 3.127033, 20.352093, -8.229216, 3.2149067, 20.450375, -8.276415, 3.4964035, 20.596956, -8.026518, 3.5625966, 20.730312, -8.134125, 3.821342, 20.866423, -8.276415, 3.4964035, 20.596956, -8.06389, 3.4651048, 20.64575, -8.026518, 3.5625966, 20.730312, -7.781936, 3.5772128, 20.836325, -7.9577355, 4.0573144, 21.235031, -8.134125, 3.821342, 20.866423, -7.9577355, 4.0573144, 21.235031, -7.781936, 3.5772128, 20.836325, -7.705567, 3.6661596, 20.99604, -7.9577355, 4.0573144, 21.235031, -7.705567, 3.6661596, 20.99604, -7.7892184, 3.8130858, 21.079828, -7.7676377, 4.0055275, 21.369812, -7.801645, 4.134115, 21.58577, -7.9577355, 4.0573144, 21.235031, -7.801645, 4.134115, 21.58577, -7.7676377, 4.0055275, 21.369812, -7.5783267, 3.8855352, 21.404331, -7.801645, 4.134115, 21.58577, -7.5783267, 3.8855352, 21.404331, -7.559606, 3.912532, 21.485746, -7.9577355, 4.0573144, 21.235031, -8.320477, 3.7492816, 21.144423, -8.134125, 3.821342, 20.866423, -8.143465, 3.9182801, 21.225508, -8.320477, 3.7492816, 21.144423, -7.9577355, 4.0573144, 21.235031, -8.320477, 3.7492816, 21.144423, -8.143465, 3.9182801, 21.225508, -8.340118, 3.7362518, 21.314922, -8.320477, 3.7492816, 21.144423, -8.340118, 3.7362518, 21.314922, -8.375259, 3.7055726, 21.241833, -7.9577355, 4.0573144, 21.235031, -8.325062, 3.7988772, 21.348167, -8.143465, 3.9182801, 21.225508, -7.9577355, 4.0573144, 21.235031, -8.287462, 3.8450718, 21.426939, -8.325062, 3.7988772, 21.348167, -7.7892184, 3.8130858, 21.079828, -7.6383195, 3.7634745, 21.137505, -7.9577355, 4.0573144, 21.235031, -7.7892184, 3.8130858, 21.079828, -7.6709933, 3.7234988, 21.069057, -7.6383195, 3.7634745, 21.137505, -7.801645, 4.134115, 21.58577, -8.086512, 3.9769306, 21.499489, -7.9577355, 4.0573144, 21.235031, -8.086512, 3.9769306, 21.499489, -7.801645, 4.134115, 21.58577, -8.072794, 3.9650502, 21.715675, -8.086512, 3.9769306, 21.499489, -8.072794, 3.9650502, 21.715675, -8.151386, 3.9267366, 21.61004, -7.7676377, 4.0055275, 21.369812, -7.6383195, 3.7634745, 21.137505, -7.588919, 3.823454, 21.350895, -7.7676377, 4.0055275, 21.369812, -7.9577355, 4.0573144, 21.235031, -7.6383195, 3.7634745, 21.137505, -7.6581864, 4.072356, 21.931242, -7.866752, 4.057515, 21.744825, -7.801645, 4.134115, 21.58577, -7.866752, 4.057515, 21.744825, -7.6581864, 4.072356, 21.931242, -7.71435, 4.039794, 21.968426, -7.866752, 4.057515, 21.744825, -7.71435, 4.039794, 21.968426, -7.87205, 4.018237, 21.856892, -8.026518, 3.5625966, 20.730312, -7.781936, 3.5772128, 20.836325, -8.134125, 3.821342, 20.866423, -8.026518, 3.5625966, 20.730312, -7.8797107, 3.5000882, 20.74282, -7.781936, 3.5772128, 20.836325, -8.06389, 3.4651048, 20.64575, -7.94037, 3.4260588, 20.685284, -8.026518, 3.5625966, 20.730312, -8.06389, 3.4651048, 20.64575, -7.975001, 3.40911, 20.652027, -7.94037, 3.4260588, 20.685284, -8.06389, 3.4651048, 20.64575, -8.276415, 3.4964035, 20.596956, -8.0436325, 3.3592296, 20.586334, -8.265045, 3.3001463, 20.48862, -8.0436325, 3.3592296, 20.586334, -8.276415, 3.4964035, 20.596956, -8.265045, 3.3001463, 20.48862, -8.163176, 3.2732167, 20.498547, -8.0436325, 3.3592296, 20.586334, -7.402509, 4.1859736, 21.63811, -7.6918817, 4.041251, 21.991617, -7.7346835, 4.0696325, 21.918596, -7.402509, 4.1859736, 21.63811, -7.1659408, 4.0452685, 21.938877, -7.6918817, 4.041251, 21.991617, -7.544909, 3.8685284, 21.406876, -7.563997, 4.036748, 21.631306, -7.6227355, 3.9156742, 21.563887, -7.544909, 3.8685284, 21.406876, -7.402509, 4.1859736, 21.63811, -7.563997, 4.036748, 21.631306, -5.9238067, 3.1665137, 20.728191, -6.2251096, 3.5616388, 20.915148, -6.4278474, 3.3525786, 20.777048, -5.9238067, 3.1665137, 20.728191, -5.8848615, 3.193083, 20.760527, -6.2251096, 3.5616388, 20.915148, -6.2251096, 3.5616388, 20.915148, -6.5504594, 3.402594, 20.803858, -6.4278474, 3.3525786, 20.777048, -6.2251096, 3.5616388, 20.915148, -6.4983582, 3.5830135, 20.909115, -6.5504594, 3.402594, 20.803858, -6.4983582, 3.5830135, 20.909115, -6.2251096, 3.5616388, 20.915148, -6.5891337, 3.903202, 21.097126, -6.4983582, 3.5830135, 20.909115, -6.5891337, 3.903202, 21.097126, -6.879663, 3.532806, 20.876286, -6.4983582, 3.5830135, 20.909115, -6.879663, 3.532806, 20.876286, -6.6607127, 3.447071, 20.828083, -6.879663, 3.532806, 20.876286, -6.9066834, 3.6857386, 20.988258, -6.9293284, 3.5507002, 20.90792, -6.879663, 3.532806, 20.876286, -6.5891337, 3.903202, 21.097126, -6.9066834, 3.6857386, 20.988258, -7.2366023, 3.7330508, 21.09622, -7.3143077, 3.9347124, 21.32243, -7.315949, 3.763792, 21.176712, -7.2366023, 3.7330508, 21.09622, -7.02702, 4.1361513, 21.366934, -7.3143077, 3.9347124, 21.32243, -5.862766, 3.1931438, 20.813963, -6.2251096, 3.5616388, 20.915148, -5.8848615, 3.193083, 20.760527, -6.2251096, 3.5616388, 20.915148, -5.862766, 3.1931438, 20.813963, -6.043155, 3.376487, 20.935175, -6.043155, 3.376487, 20.935175, -5.862766, 3.1931438, 20.813963, -5.9688907, 3.3041463, 20.98022, -6.2251096, 3.5616388, 20.915148, -6.3340783, 3.6836305, 21.23523, -6.5891337, 3.903202, 21.097126, -6.2251096, 3.5616388, 20.915148, -6.2198997, 3.5840178, 21.185366, -6.3340783, 3.6836305, 21.23523, -6.400766, 3.7538157, 21.548512, -7.02702, 4.1361513, 21.366934, -6.5891337, 3.903202, 21.097126, -7.02702, 4.1361513, 21.366934, -6.400766, 3.7538157, 21.548512, -6.588054, 3.8369021, 21.666204, -7.02702, 4.1361513, 21.366934, -6.588054, 3.8369021, 21.666204, -6.7412972, 3.9567418, 21.567703, -7.1137557, 4.110402, 21.642786, -7.402509, 4.1859736, 21.63811, -7.02702, 4.1361513, 21.366934, -7.402509, 4.1859736, 21.63811, -7.1137557, 4.110402, 21.642786, -7.0714173, 4.029825, 21.898872, -7.402509, 4.1859736, 21.63811, -7.0714173, 4.029825, 21.898872, -7.1659408, 4.0452685, 21.938877, -7.02702, 4.1361513, 21.366934, -6.9066834, 3.6857386, 20.988258, -6.5891337, 3.903202, 21.097126, -7.0096154, 3.9184318, 21.182732, -6.9066834, 3.6857386, 20.988258, -7.02702, 4.1361513, 21.366934, -6.9066834, 3.6857386, 20.988258, -7.0096154, 3.9184318, 21.182732, -7.098456, 3.6138377, 21.015465, -6.9066834, 3.6857386, 20.988258, -7.098456, 3.6138377, 21.015465, -7.014134, 3.5885832, 20.961206, -7.02702, 4.1361513, 21.366934, -7.1437945, 3.6882315, 21.038242, -7.0096154, 3.9184318, 21.182732, -7.02702, 4.1361513, 21.366934, -7.2366023, 3.7330508, 21.09622, -7.1437945, 3.6882315, 21.038242, -6.7412972, 3.9567418, 21.567703, -6.756685, 3.9355235, 21.769691, -7.02702, 4.1361513, 21.366934, -6.7412972, 3.9567418, 21.567703, -6.6761546, 3.8968792, 21.71939, -6.756685, 3.9355235, 21.769691, -7.402509, 4.1859736, 21.63811, -7.3143077, 3.9347124, 21.32243, -7.02702, 4.1361513, 21.366934, -7.3143077, 3.9347124, 21.32243, -7.402509, 4.1859736, 21.63811, -7.544909, 3.8685284, 21.406876, -7.3143077, 3.9347124, 21.32243, -7.544909, 3.8685284, 21.406876, -7.432817, 3.8261418, 21.293098, -7.1137557, 4.110402, 21.642786, -6.756685, 3.9355235, 21.769691, -7.002805, 3.9603796, 21.87517, -7.1137557, 4.110402, 21.642786, -7.02702, 4.1361513, 21.366934, -6.756685, 3.9355235, 21.769691, -7.7346835, 4.0696325, 21.918596, -7.563997, 4.036748, 21.631306, -7.402509, 4.1859736, 21.63811, -7.563997, 4.036748, 21.631306, -7.7346835, 4.0696325, 21.918596, -7.775924, 4.0044317, 21.873682, -7.563997, 4.036748, 21.631306, -7.775924, 4.0044317, 21.873682, -7.6754994, 3.960377, 21.667994, -6.3340783, 3.6836305, 21.23523, -6.400766, 3.7538157, 21.548512, -6.5891337, 3.903202, 21.097126, -6.3340783, 3.6836305, 21.23523, -6.2958436, 3.6586976, 21.422325, -6.400766, 3.7538157, 21.548512, -6.2198997, 3.5840178, 21.185366, -6.2276845, 3.568183, 21.344408, -6.3340783, 3.6836305, 21.23523, -6.2198997, 3.5840178, 21.185366, -6.1918397, 3.5477042, 21.29962, -6.2276845, 3.568183, 21.344408, -6.2198997, 3.5840178, 21.185366, -6.2251096, 3.5616388, 20.915148, -6.1187816, 3.4875863, 21.210886, -6.043155, 3.376487, 20.935175, -6.1187816, 3.4875863, 21.210886, -6.2251096, 3.5616388, 20.915148, -6.043155, 3.376487, 20.935175, -6.0228896, 3.3768027, 21.062256, -6.1187816, 3.4875863, 21.210886, -7.5309367, 4.1860266, 22.233109, -7.7731357, 4.049283, 21.837759, -7.686767, 4.0676775, 21.824697, -7.5309367, 4.1860266, 22.233109, -7.90918, 4.0913115, 22.343561, -7.7731357, 4.049283, 21.837759, -7.2945504, 3.8389473, 22.203506, -7.4850507, 4.0242515, 22.093157, -7.4099345, 3.8931296, 22.069782, -7.2945504, 3.8389473, 22.203506, -7.5309367, 4.1860266, 22.233109, -7.4850507, 4.0242515, 22.093157, -7.231361, 3.2090497, 23.990551, -7.2794967, 3.594735, 23.623615, -7.099934, 3.3586783, 23.498016, -7.231361, 3.2090497, 23.990551, -7.2722163, 3.2413416, 24.013079, -7.2794967, 3.594735, 23.623615, -7.2794967, 3.594735, 23.623615, -7.0818753, 3.401124, 23.37233, -7.099934, 3.3586783, 23.498016, -7.2794967, 3.594735, 23.623615, -7.1849923, 3.5940344, 23.37246, -7.0818753, 3.401124, 23.37233, -7.1849923, 3.5940344, 23.37246, -7.2794967, 3.594735, 23.623615, -7.3083177, 3.9218946, 23.202938, -7.1849923, 3.5940344, 23.37246, -7.3083177, 3.9218946, 23.202938, -7.0331464, 3.5110173, 23.034817, -7.1849923, 3.5940344, 23.37246, -7.0331464, 3.5110173, 23.034817, -7.0656457, 3.4388018, 23.25929, -7.0331464, 3.5110173, 23.034817, -7.121306, 3.6710267, 22.96056, -7.043958, 3.527542, 22.976107, -7.0331464, 3.5110173, 23.034817, -7.3083177, 3.9218946, 23.202938, -7.121306, 3.6710267, 22.96056, -7.113857, 3.7015529, 22.612543, -7.287684, 3.915894, 22.445786, -7.160318, 3.7328143, 22.507229, -7.113857, 3.7015529, 22.612543, -7.4040046, 4.1427026, 22.68545, -7.287684, 3.915894, 22.445786, -7.3301635, 3.2478483, 24.013186, -7.2794967, 3.594735, 23.623615, -7.2722163, 3.2413416, 24.013079, -7.2794967, 3.594735, 23.623615, -7.3301635, 3.2478483, 24.013186, -7.371898, 3.4266107, 23.792282, -7.371898, 3.4266107, 23.792282, -7.3301635, 3.2478483, 24.013186, -7.442037, 3.3642597, 23.847004, -7.2794967, 3.594735, 23.623615, -7.540323, 3.735655, 23.39579, -7.3083177, 3.9218946, 23.202938, -7.2794967, 3.594735, 23.623615, -7.5373693, 3.641142, 23.524654, -7.540323, 3.735655, 23.39579, -7.8095174, 3.8275523, 23.211908, -7.4040046, 4.1427026, 22.68545, -7.3083177, 3.9218946, 23.202938, -7.4040046, 4.1427026, 22.68545, -7.8095174, 3.8275523, 23.211908, -7.8509803, 3.9055398, 22.990408, -7.4040046, 4.1427026, 22.68545, -7.8509803, 3.9055398, 22.990408, -7.703343, 4.004422, 22.88101, -7.6422763, 4.1345253, 22.501507, -7.5309367, 4.1860266, 22.233109, -7.4040046, 4.1427026, 22.68545, -7.5309367, 4.1860266, 22.233109, -7.6422763, 4.1345253, 22.501507, -7.905112, 4.080037, 22.446768, -7.5309367, 4.1860266, 22.233109, -7.905112, 4.080037, 22.446768, -7.90918, 4.0913115, 22.343561, -7.4040046, 4.1427026, 22.68545, -7.121306, 3.6710267, 22.96056, -7.3083177, 3.9218946, 23.202938, -7.255689, 3.9115663, 22.781116, -7.121306, 3.6710267, 22.96056, -7.4040046, 4.1427026, 22.68545, -7.121306, 3.6710267, 22.96056, -7.255689, 3.9115663, 22.781116, -7.080893, 3.5860498, 22.77619, -7.121306, 3.6710267, 22.96056, -7.080893, 3.5860498, 22.77619, -7.062715, 3.563114, 22.87582, -7.4040046, 4.1427026, 22.68545, -7.0931907, 3.6592827, 22.722273, -7.255689, 3.9115663, 22.781116, -7.4040046, 4.1427026, 22.68545, -7.113857, 3.7015529, 22.612543, -7.0931907, 3.6592827, 22.722273, -7.703343, 4.004422, 22.88101, -7.8892875, 3.9995782, 22.79086, -7.4040046, 4.1427026, 22.68545, -7.703343, 4.004422, 22.88101, -7.8713326, 3.9631367, 22.88616, -7.8892875, 3.9995782, 22.79086, -7.5309367, 4.1860266, 22.233109, -7.287684, 3.915894, 22.445786, -7.4040046, 4.1427026, 22.68545, -7.287684, 3.915894, 22.445786, -7.5309367, 4.1860266, 22.233109, -7.2945504, 3.8389473, 22.203506, -7.287684, 3.915894, 22.445786, -7.2945504, 3.8389473, 22.203506, -7.2290454, 3.7958574, 22.35221, -7.6422763, 4.1345253, 22.501507, -7.8892875, 3.9995782, 22.79086, -7.8991284, 4.0135202, 22.522274, -7.6422763, 4.1345253, 22.501507, -7.4040046, 4.1427026, 22.68545, -7.8892875, 3.9995782, 22.79086, -7.686767, 4.0676775, 21.824697, -7.4850507, 4.0242515, 22.093157, -7.5309367, 4.1860266, 22.233109, -7.4850507, 4.0242515, 22.093157, -7.686767, 4.0676775, 21.824697, -7.63722, 3.9957073, 21.806574, -7.4850507, 4.0242515, 22.093157, -7.63722, 3.9957073, 21.806574, -7.487868, 3.9425092, 21.979464, -7.540323, 3.735655, 23.39579, -7.8095174, 3.8275523, 23.211908, -7.3083177, 3.9218946, 23.202938, -7.540323, 3.735655, 23.39579, -7.7316575, 3.7301946, 23.361069, -7.8095174, 3.8275523, 23.211908, -7.5373693, 3.641142, 23.524654, -7.680619, 3.6382384, 23.457644, -7.540323, 3.735655, 23.39579, -7.5373693, 3.641142, 23.524654, -7.6541924, 3.6169612, 23.508732, -7.680619, 3.6382384, 23.457644, -7.5373693, 3.641142, 23.524654, -7.2794967, 3.594735, 23.623615, -7.599995, 3.5552611, 23.612686, -7.371898, 3.4266107, 23.792282, -7.599995, 3.5552611, 23.612686, -7.2794967, 3.594735, 23.623615, -7.371898, 3.4266107, 23.792282, -7.4989257, 3.4396398, 23.762728, -7.599995, 3.5552611, 23.612686, -6.9064727, 0.8870442, 21.944698, -7.0634227, 1.9859911, 21.955528, -6.983601, 1.9958726, 21.857681, -6.9064727, 0.8870442, 21.944698, -7.0050845, 0.88108003, 22.061377, -7.0634227, 1.9859911, 21.955528, -7.007685, -0.32457638, 22.15034, -7.0050845, 0.88108003, 22.061377, -6.9064727, 0.8870442, 21.944698, -7.007685, -0.32457638, 22.15034, -7.116863, -0.30735517, 22.281223, -7.0050845, 0.88108003, 22.061377, -7.2671094, 0.85050464, 21.996153, -7.3045907, 1.9478768, 21.77239, -7.284966, 1.9531578, 21.8974, -7.2671094, 0.85050464, 21.996153, -7.288546, 0.84261394, 21.850367, -7.3045907, 1.9478768, 21.77239, -7.0425286, 0.86357975, 21.707298, -7.003896, 1.9904655, 21.73263, -7.101132, 1.9756031, 21.652607, -7.0425286, 0.86357975, 21.707298, -6.925528, 0.87903726, 21.79757, -7.003896, 1.9904655, 21.73263, -7.284966, 1.9531578, 21.8974, -7.4806666, 2.9942842, 21.815382, -7.466662, 2.9961905, 21.89844, -7.284966, 1.9531578, 21.8974, -7.3045907, 1.9478768, 21.77239, -7.4806666, 2.9942842, 21.815382, -6.983601, 1.9958726, 21.857681, -7.320312, 3.0230846, 21.937897, -7.2666173, 3.0324101, 21.872292, -6.983601, 1.9958726, 21.857681, -7.0634227, 1.9859911, 21.955528, -7.320312, 3.0230846, 21.937897, -7.2666173, 3.0324101, 21.872292, -7.320312, 3.0230846, 21.937897, -7.4912467, 3.573497, 21.949327, -7.2666173, 3.0324101, 21.872292, -7.4912467, 3.573497, 21.949327, -7.453357, 3.5873647, 21.900778, -7.0050845, 0.88108003, 22.061377, -7.1874385, 1.968081, 21.976803, -7.0634227, 1.9859911, 21.955528, -7.0050845, 0.88108003, 22.061377, -7.15249, 0.8660785, 22.087767, -7.1874385, 1.968081, 21.976803, -7.0634227, 1.9859911, 21.955528, -7.1874385, 1.968081, 21.976803, -7.40249, 3.0077667, 21.95179, -7.0634227, 1.9859911, 21.955528, -7.40249, 3.0077667, 21.95179, -7.320312, 3.0230846, 21.937897, -7.288546, 0.84261394, 21.850367, -7.2247424, 1.9581697, 21.674047, -7.3045907, 1.9478768, 21.77239, -7.288546, 0.84261394, 21.850367, -7.192162, 0.8488163, 21.734951, -7.2247424, 1.9581697, 21.674047, -7.3045907, 1.9478768, 21.77239, -7.2247424, 1.9581697, 21.674047, -7.428226, 3.0044737, 21.749603, -7.3045907, 1.9478768, 21.77239, -7.428226, 3.0044737, 21.749603, -7.4806666, 2.9942842, 21.815382, -7.101132, 1.9756031, 21.652607, -7.2801204, 3.029789, 21.78821, -7.345489, 3.018808, 21.73467, -7.101132, 1.9756031, 21.652607, -7.003896, 1.9904655, 21.73263, -7.2801204, 3.029789, 21.78821, -6.925528, 0.87903726, 21.79757, -6.983601, 1.9958726, 21.857681, -7.003896, 1.9904655, 21.73263, -6.925528, 0.87903726, 21.79757, -6.9064727, 0.8870442, 21.944698, -6.983601, 1.9958726, 21.857681, -7.003896, 1.9904655, 21.73263, -7.2666173, 3.0324101, 21.872292, -7.2801204, 3.029789, 21.78821, -7.003896, 1.9904655, 21.73263, -6.983601, 1.9958726, 21.857681, -7.2666173, 3.0324101, 21.872292, -7.192162, 0.8488163, 21.734951, -7.101132, 1.9756031, 21.652607, -7.2247424, 1.9581697, 21.674047, -7.192162, 0.8488163, 21.734951, -7.0425286, 0.86357975, 21.707298, -7.101132, 1.9756031, 21.652607, -7.3367667, -0.3452804, 21.89752, -7.0425286, 0.86357975, 21.707298, -7.192162, 0.8488163, 21.734951, -7.3367667, -0.3452804, 21.89752, -7.1676164, -0.3521583, 21.871283, -7.0425286, 0.86357975, 21.707298, -7.2247424, 1.9581697, 21.674047, -7.345489, 3.018808, 21.73467, -7.428226, 3.0044737, 21.749603, -7.2247424, 1.9581697, 21.674047, -7.101132, 1.9756031, 21.652607, -7.345489, 3.018808, 21.73467, -7.428226, 3.0044737, 21.749603, -7.345489, 3.018808, 21.73467, -7.5119376, 3.577262, 21.798561, -7.428226, 3.0044737, 21.749603, -7.5119376, 3.577262, 21.798561, -7.5716386, 3.5596325, 21.809055, -7.5982623, 3.5452437, 21.919594, -7.6945357, 3.969666, 21.982683, -7.5509553, 3.5562305, 21.959482, -7.5982623, 3.5452437, 21.919594, -7.7289567, 3.960751, 21.953426, -7.6945357, 3.969666, 21.982683, -7.4912467, 3.573497, 21.949327, -7.6261945, 4.0009108, 21.940428, -7.453357, 3.5873647, 21.900778, -7.4912467, 3.573497, 21.949327, -7.6525865, 3.9872801, 21.975618, -7.6261945, 4.0009108, 21.940428, -7.5509553, 3.5562305, 21.959482, -7.6525865, 3.9872801, 21.975618, -7.4912467, 3.573497, 21.949327, -7.5509553, 3.5562305, 21.959482, -7.6945357, 3.969666, 21.982683, -7.6525865, 3.9872801, 21.975618, -7.4642043, 3.588051, 21.838531, -7.668904, 3.9935448, 21.865906, -7.5119376, 3.577262, 21.798561, -7.4642043, 3.588051, 21.838531, -7.634957, 4.0036774, 21.895311, -7.668904, 3.9935448, 21.865906, -7.453357, 3.5873647, 21.900778, -7.634957, 4.0036774, 21.895311, -7.4642043, 3.588051, 21.838531, -7.453357, 3.5873647, 21.900778, -7.6261945, 4.0009108, 21.940428, -7.634957, 4.0036774, 21.895311, -7.5716386, 3.5596325, 21.809055, -7.737245, 3.9623003, 21.908161, -7.6092744, 3.5461726, 21.857725, -7.5716386, 3.5596325, 21.809055, -7.711328, 3.9771495, 21.873121, -7.737245, 3.9623003, 21.908161, -7.5119376, 3.577262, 21.798561, -7.711328, 3.9771495, 21.873121, -7.5716386, 3.5596325, 21.809055, -7.5119376, 3.577262, 21.798561, -7.668904, 3.9935448, 21.865906, -7.711328, 3.9771495, 21.873121, -7.2869067, -0.3004067, 22.307976, -7.2671094, 0.85050464, 21.996153, -7.15249, 0.8660785, 22.087767, -7.2869067, -0.3004067, 22.307976, -7.4190135, -0.30967426, 22.197882, -7.2671094, 0.85050464, 21.996153, -7.6092744, 3.5461726, 21.857725, -7.7289567, 3.960751, 21.953426, -7.5982623, 3.5452437, 21.919594, -7.6092744, 3.5461726, 21.857725, -7.737245, 3.9623003, 21.908161, -7.7289567, 3.960751, 21.953426, -7.15249, 0.8660785, 22.087767, -7.284966, 1.9531578, 21.8974, -7.1874385, 1.968081, 21.976803, -7.15249, 0.8660785, 22.087767, -7.2671094, 0.85050464, 21.996153, -7.284966, 1.9531578, 21.8974, -7.4190135, -0.30967426, 22.197882, -7.288546, 0.84261394, 21.850367, -7.2671094, 0.85050464, 21.996153, -7.4190135, -0.30967426, 22.197882, -7.446838, -0.32798862, 22.028917, -7.288546, 0.84261394, 21.850367, -7.1676164, -0.3521583, 21.871283, -6.925528, 0.87903726, 21.79757, -7.0425286, 0.86357975, 21.707298, -7.1676164, -0.3521583, 21.871283, -7.036158, -0.3428421, 21.981743, -6.925528, 0.87903726, 21.79757, -7.116863, -0.30735517, 22.281223, -7.15249, 0.8660785, 22.087767, -7.0050845, 0.88108003, 22.061377, -7.116863, -0.30735517, 22.281223, -7.2869067, -0.3004067, 22.307976, -7.15249, 0.8660785, 22.087767, -7.446838, -0.32798862, 22.028917, -7.192162, 0.8488163, 21.734951, -7.288546, 0.84261394, 21.850367, -7.446838, -0.32798862, 22.028917, -7.3367667, -0.3452804, 21.89752, -7.192162, 0.8488163, 21.734951, -7.036158, -0.3428421, 21.981743, -6.9064727, 0.8870442, 21.944698, -6.925528, 0.87903726, 21.79757, -7.036158, -0.3428421, 21.981743, -7.007685, -0.32457638, 22.15034, -6.9064727, 0.8870442, 21.944698, -7.1874385, 1.968081, 21.976803, -7.466662, 2.9961905, 21.89844, -7.40249, 3.0077667, 21.95179, -7.1874385, 1.968081, 21.976803, -7.284966, 1.9531578, 21.8974, -7.466662, 2.9961905, 21.89844, -7.40249, 3.0077667, 21.95179, -7.5982623, 3.5452437, 21.919594, -7.5509553, 3.5562305, 21.959482, -7.40249, 3.0077667, 21.95179, -7.466662, 2.9961905, 21.89844, -7.5982623, 3.5452437, 21.919594, -7.466662, 2.9961905, 21.89844, -7.6092744, 3.5461726, 21.857725, -7.5982623, 3.5452437, 21.919594, -7.466662, 2.9961905, 21.89844, -7.4806666, 2.9942842, 21.815382, -7.6092744, 3.5461726, 21.857725, -7.320312, 3.0230846, 21.937897, -7.5509553, 3.5562305, 21.959482, -7.4912467, 3.573497, 21.949327, -7.320312, 3.0230846, 21.937897, -7.40249, 3.0077667, 21.95179, -7.5509553, 3.5562305, 21.959482, -7.345489, 3.018808, 21.73467, -7.4642043, 3.588051, 21.838531, -7.5119376, 3.577262, 21.798561, -7.345489, 3.018808, 21.73467, -7.2801204, 3.029789, 21.78821, -7.4642043, 3.588051, 21.838531, -7.2801204, 3.029789, 21.78821, -7.453357, 3.5873647, 21.900778, -7.4642043, 3.588051, 21.838531, -7.2801204, 3.029789, 21.78821, -7.2666173, 3.0324101, 21.872292, -7.453357, 3.5873647, 21.900778, -7.4806666, 2.9942842, 21.815382, -7.5716386, 3.5596325, 21.809055, -7.6092744, 3.5461726, 21.857725, -7.4806666, 2.9942842, 21.815382, -7.428226, 3.0044737, 21.749603, -7.5716386, 3.5596325, 21.809055, -25.120148, 13.783545, 12.764626, -26.397978, 13.369994, 12.574373, -26.33375, 13.5643215, 12.432411, -25.120148, 13.783545, 12.764626, -25.263885, 12.827821, 13.423475, -26.397978, 13.369994, 12.574373, -24.933325, 13.512459, 11.685724, -25.424961, 13.642609, 12.2283325, -25.402243, 13.524235, 11.855768, -24.933325, 13.512459, 11.685724, -25.120148, 13.783545, 12.764626, -25.424961, 13.642609, 12.2283325, -20.327318, 10.662638, 12.792898, -21.313543, 11.772419, 13.055622, -21.432056, 11.691162, 12.167927, -20.327318, 10.662638, 12.792898, -20.315731, 10.639986, 12.953346, -21.313543, 11.772419, 13.055622, -21.313543, 11.772419, 13.055622, -21.73093, 11.930523, 12.043638, -21.432056, 11.691162, 12.167927, -21.313543, 11.772419, 13.055622, -21.845343, 12.171588, 12.549276, -21.73093, 11.930523, 12.043638, -21.845343, 12.171588, 12.549276, -21.313543, 11.772419, 13.055622, -22.412296, 12.812631, 13.106188, -21.845343, 12.171588, 12.549276, -22.412296, 12.812631, 13.106188, -22.532686, 12.553142, 11.708666, -21.845343, 12.171588, 12.549276, -22.532686, 12.553142, 11.708666, -21.999557, 12.142736, 11.931597, -22.532686, 12.553142, 11.708666, -22.81894, 12.8182955, 12.022795, -22.69415, 12.618034, 11.694317, -22.532686, 12.553142, 11.708666, -22.412296, 12.812631, 13.106188, -22.81894, 12.8182955, 12.022795, -23.693872, 13.206497, 11.613611, -24.30996, 13.49691, 12.078384, -24.013233, 13.277237, 11.631985, -23.693872, 13.206497, 11.613611, -23.830704, 13.556321, 12.993084, -24.30996, 13.49691, 12.078384, -20.380512, 10.551989, 13.072411, -21.313543, 11.772419, 13.055622, -20.315731, 10.639986, 12.953346, -21.313543, 11.772419, 13.055622, -20.380512, 10.551989, 13.072411, -20.98937, 11.087692, 13.168047, -20.98937, 11.087692, 13.168047, -20.380512, 10.551989, 13.072411, -20.92976, 10.772113, 13.284454, -21.313543, 11.772419, 13.055622, -22.184439, 11.839201, 13.474684, -22.412296, 12.812631, 13.106188, -21.313543, 11.772419, 13.055622, -21.854174, 11.521826, 13.480944, -22.184439, 11.839201, 13.474684, -22.953316, 11.729006, 13.905809, -23.830704, 13.556321, 12.993084, -22.412296, 12.812631, 13.106188, -23.830704, 13.556321, 12.993084, -22.953316, 11.729006, 13.905809, -23.56244, 12.014381, 13.853283, -23.830704, 13.556321, 12.993084, -23.56244, 12.014381, 13.853283, -23.671707, 12.578844, 13.562656, -24.563269, 13.266899, 13.176035, -25.120148, 13.783545, 12.764626, -23.830704, 13.556321, 12.993084, -25.120148, 13.783545, 12.764626, -24.563269, 13.266899, 13.176035, -24.997204, 12.729578, 13.513615, -25.120148, 13.783545, 12.764626, -24.997204, 12.729578, 13.513615, -25.263885, 12.827821, 13.423475, -23.830704, 13.556321, 12.993084, -22.81894, 12.8182955, 12.022795, -22.412296, 12.812631, 13.106188, -23.423197, 13.259348, 12.431152, -22.81894, 12.8182955, 12.022795, -23.830704, 13.556321, 12.993084, -22.81894, 12.8182955, 12.022795, -23.423197, 13.259348, 12.431152, -23.243961, 12.84373, 11.64538, -22.81894, 12.8182955, 12.022795, -23.243961, 12.84373, 11.64538, -22.970037, 12.747108, 11.67061, -23.830704, 13.556321, 12.993084, -23.392378, 13.061067, 11.639456, -23.423197, 13.259348, 12.431152, -23.830704, 13.556321, 12.993084, -23.693872, 13.206497, 11.613611, -23.392378, 13.061067, 11.639456, -23.671707, 12.578844, 13.562656, -24.109556, 12.314231, 13.807953, -23.830704, 13.556321, 12.993084, -23.671707, 12.578844, 13.562656, -23.848108, 12.188936, 13.830387, -24.109556, 12.314231, 13.807953, -25.120148, 13.783545, 12.764626, -24.30996, 13.49691, 12.078384, -23.830704, 13.556321, 12.993084, -24.30996, 13.49691, 12.078384, -25.120148, 13.783545, 12.764626, -24.933325, 13.512459, 11.685724, -24.30996, 13.49691, 12.078384, -24.933325, 13.512459, 11.685724, -24.482748, 13.423203, 11.660212, -24.563269, 13.266899, 13.176035, -24.109556, 12.314231, 13.807953, -24.80421, 12.541008, 13.571323, -24.563269, 13.266899, 13.176035, -23.830704, 13.556321, 12.993084, -24.109556, 12.314231, 13.807953, -26.33375, 13.5643215, 12.432411, -25.424961, 13.642609, 12.2283325, -25.120148, 13.783545, 12.764626, -25.424961, 13.642609, 12.2283325, -26.33375, 13.5643215, 12.432411, -26.324017, 13.5112915, 12.189222, -25.424961, 13.642609, 12.2283325, -26.324017, 13.5112915, 12.189222, -25.71668, 13.54311, 11.969755, -22.184439, 11.839201, 13.474684, -22.953316, 11.729006, 13.905809, -22.412296, 12.812631, 13.106188, -22.184439, 11.839201, 13.474684, -22.487688, 11.524162, 13.78611, -22.953316, 11.729006, 13.905809, -21.854174, 11.521826, 13.480944, -22.187183, 11.317971, 13.70713, -22.184439, 11.839201, 13.474684, -21.854174, 11.521826, 13.480944, -22.027325, 11.277615, 13.666721, -22.187183, 11.317971, 13.70713, -21.854174, 11.521826, 13.480944, -21.313543, 11.772419, 13.055622, -21.702065, 11.143689, 13.583382, -20.98937, 11.087692, 13.168047, -21.702065, 11.143689, 13.583382, -21.313543, 11.772419, 13.055622, -20.98937, 11.087692, 13.168047, -21.207434, 10.925812, 13.392204, -21.702065, 11.143689, 13.583382, -27.190845, 13.454124, 13.211977, -26.123392, 13.504549, 12.108877, -26.042444, 13.6264305, 12.347961, -27.190845, 13.454124, 13.211977, -27.674408, 12.802565, 12.217752, -26.123392, 13.504549, 12.108877, -26.397722, 12.685178, 13.907841, -26.517313, 13.244606, 13.225155, -26.210627, 12.959142, 13.428625, -26.397722, 12.685178, 13.907841, -27.190845, 13.454124, 13.211977, -26.517313, 13.244606, 13.225155, -30.59442, 8.298002, 15.834078, -30.123693, 9.8882065, 15.319544, -29.275402, 9.5317335, 15.74361, -30.59442, 8.298002, 15.834078, -30.741045, 8.328472, 15.732853, -30.123693, 9.8882065, 15.319544, -30.123693, 9.8882065, 15.319544, -28.95761, 9.849106, 15.67477, -29.275402, 9.5317335, 15.74361, -30.123693, 9.8882065, 15.319544, -29.313347, 10.32216, 15.361237, -28.95761, 9.849106, 15.67477, -29.313347, 10.32216, 15.361237, -30.123693, 9.8882065, 15.319544, -29.39274, 11.410282, 14.816835, -29.313347, 10.32216, 15.361237, -29.39274, 11.410282, 14.816835, -28.10266, 10.688791, 15.489471, -29.313347, 10.32216, 15.361237, -28.10266, 10.688791, 15.489471, -28.671627, 10.132956, 15.612846, -28.10266, 10.688791, 15.489471, -28.17546, 11.1942005, 15.154776, -27.974642, 10.813639, 15.397457, -28.10266, 10.688791, 15.489471, -29.39274, 11.410282, 14.816835, -28.17546, 11.1942005, 15.154776, -27.208647, 11.806034, 14.844706, -27.177315, 12.54514, 14.161594, -26.99829, 12.019685, 14.60232, -27.208647, 11.806034, 14.844706, -28.297514, 12.740184, 14.028536, -27.177315, 12.54514, 14.161594, -30.79949, 8.3112135, 15.561966, -30.123693, 9.8882065, 15.319544, -30.741045, 8.328472, 15.732853, -30.123693, 9.8882065, 15.319544, -30.79949, 8.3112135, 15.561966, -30.452688, 9.11234, 15.217451, -30.452688, 9.11234, 15.217451, -30.79949, 8.3112135, 15.561966, -30.594294, 8.811231, 15.062023, -30.123693, 9.8882065, 15.319544, -29.879786, 10.464624, 14.329517, -29.39274, 11.410282, 14.816835, -30.123693, 9.8882065, 15.319544, -30.117891, 10.010266, 14.465597, -29.879786, 10.464624, 14.329517, -29.715208, 10.829817, 13.354015, -28.297514, 12.740184, 14.028536, -29.39274, 11.410282, 14.816835, -28.297514, 12.740184, 14.028536, -29.715208, 10.829817, 13.354015, -29.23813, 11.34871, 13.010698, -28.297514, 12.740184, 14.028536, -29.23813, 11.34871, 13.010698, -28.912056, 11.883873, 13.344931, -27.949308, 12.851552, 13.156515, -27.190845, 13.454124, 13.211977, -28.297514, 12.740184, 14.028536, -27.190845, 13.454124, 13.211977, -27.949308, 12.851552, 13.156515, -27.942167, 12.621899, 12.332624, -27.190845, 13.454124, 13.211977, -27.942167, 12.621899, 12.332624, -27.674408, 12.802565, 12.217752, -28.297514, 12.740184, 14.028536, -28.17546, 11.1942005, 15.154776, -29.39274, 11.410282, 14.816835, -28.104351, 12.050874, 14.57691, -28.17546, 11.1942005, 15.154776, -28.297514, 12.740184, 14.028536, -28.17546, 11.1942005, 15.154776, -28.104351, 12.050874, 14.57691, -27.539679, 11.245704, 15.084768, -28.17546, 11.1942005, 15.154776, -27.539679, 11.245704, 15.084768, -27.758598, 11.0494, 15.242031, -28.297514, 12.740184, 14.028536, -27.44365, 11.537734, 15.013952, -28.104351, 12.050874, 14.57691, -28.297514, 12.740184, 14.028536, -27.208647, 11.806034, 14.844706, -27.44365, 11.537734, 15.013952, -28.912056, 11.883873, 13.344931, -28.817327, 11.888908, 12.70685, -28.297514, 12.740184, 14.028536, -28.912056, 11.883873, 13.344931, -29.021376, 11.656829, 12.85384, -28.817327, 11.888908, 12.70685, -27.190845, 13.454124, 13.211977, -27.177315, 12.54514, 14.161594, -28.297514, 12.740184, 14.028536, -27.177315, 12.54514, 14.161594, -27.190845, 13.454124, 13.211977, -26.397722, 12.685178, 13.907841, -27.177315, 12.54514, 14.161594, -26.397722, 12.685178, 13.907841, -26.694912, 12.386589, 14.250351, -27.949308, 12.851552, 13.156515, -28.817327, 11.888908, 12.70685, -28.11471, 12.312162, 12.404867, -27.949308, 12.851552, 13.156515, -28.297514, 12.740184, 14.028536, -28.817327, 11.888908, 12.70685, -26.042444, 13.6264305, 12.347961, -26.517313, 13.244606, 13.225155, -27.190845, 13.454124, 13.211977, -26.517313, 13.244606, 13.225155, -26.042444, 13.6264305, 12.347961, -25.841175, 13.485764, 12.483356, -26.517313, 13.244606, 13.225155, -25.841175, 13.485764, 12.483356, -26.088339, 13.183667, 13.109575, -29.879786, 10.464624, 14.329517, -29.715208, 10.829817, 13.354015, -29.39274, 11.410282, 14.816835, -29.879786, 10.464624, 14.329517, -29.939215, 10.384001, 13.731905, -29.715208, 10.829817, 13.354015, -30.117891, 10.010266, 14.465597, -30.074621, 10.006492, 13.9677925, -29.879786, 10.464624, 14.329517, -30.117891, 10.010266, 14.465597, -30.15518, 9.890993, 14.100925, -30.074621, 10.006492, 13.9677925, -30.117891, 10.010266, 14.465597, -30.123693, 9.8882065, 15.319544, -30.313383, 9.599236, 14.366161, -30.452688, 9.11234, 15.217451, -30.313383, 9.599236, 14.366161, -30.123693, 9.8882065, 15.319544, -30.452688, 9.11234, 15.217451, -30.495245, 9.115306, 14.813856, -30.313383, 9.599236, 14.366161, -27.201202, 14.111977, 12.216812, -25.942682, 13.340691, 12.239897, -25.963226, 13.52549, 12.449766, -27.201202, 14.111977, 12.216812, -27.26703, 13.264112, 11.247925, -25.942682, 13.340691, 12.239897, -27.481268, 13.527826, 13.334137, -26.931988, 13.740763, 12.7410145, -26.979942, 13.491425, 13.121708, -27.481268, 13.527826, 13.334137, -27.201202, 14.111977, 12.216812, -26.931988, 13.740763, 12.7410145, -32.707096, 11.829945, 11.838261, -31.489027, 12.834562, 11.726076, -31.396978, 12.465054, 12.665121, -32.707096, 11.829945, 11.838261, -32.720463, 11.854302, 11.658894, -31.489027, 12.834562, 11.726076, -31.489027, 12.834562, 11.726076, -31.048937, 12.614889, 12.831612, -31.396978, 12.465054, 12.665121, -31.489027, 12.834562, 11.726076, -30.883116, 12.990331, 12.325209, -31.048937, 12.614889, 12.831612, -30.883116, 12.990331, 12.325209, -31.489027, 12.834562, 11.726076, -30.172102, 13.71146, 11.822292, -30.883116, 12.990331, 12.325209, -30.172102, 13.71146, 11.822292, -30.112314, 13.00444, 13.27858, -30.883116, 12.990331, 12.325209, -30.112314, 13.00444, 13.27858, -30.735453, 12.748096, 12.981314, -30.112314, 13.00444, 13.27858, -29.77852, 13.318491, 12.969162, -29.928392, 13.031496, 13.299112, -30.112314, 13.00444, 13.27858, -30.172102, 13.71146, 11.822292, -29.77852, 13.318491, 12.969162, -28.82114, 13.425723, 13.433544, -28.131073, 13.753705, 12.945568, -28.474356, 13.438845, 13.407442, -28.82114, 13.425723, 13.433544, -28.579897, 14.188468, 12.007677, -28.131073, 13.753705, 12.945568, -32.672134, 11.781767, 11.502314, -31.489027, 12.834562, 11.726076, -32.720463, 11.854302, 11.658894, -31.489027, 12.834562, 11.726076, -32.672134, 11.781767, 11.502314, -31.952402, 12.217968, 11.469395, -31.952402, 12.217968, 11.469395, -32.672134, 11.781767, 11.502314, -32.0643, 11.928456, 11.276808, -31.489027, 12.834562, 11.726076, -30.603403, 12.827642, 11.204182, -30.172102, 13.71146, 11.822292, -31.489027, 12.834562, 11.726076, -30.9988, 12.565276, 11.150988, -30.603403, 12.827642, 11.204182, -29.840925, 12.6641445, 10.642536, -28.579897, 14.188468, 12.007677, -30.172102, 13.71146, 11.822292, -28.579897, 14.188468, 12.007677, -29.840925, 12.6641445, 10.642536, -29.154, 12.815418, 10.721668, -28.579897, 14.188468, 12.007677, -29.154, 12.815418, 10.721668, -28.948093, 13.334579, 11.159084, -27.901386, 13.802778, 11.676207, -27.201202, 14.111977, 12.216812, -28.579897, 14.188468, 12.007677, -27.201202, 14.111977, 12.216812, -27.901386, 13.802778, 11.676207, -27.566175, 13.240652, 11.143919, -27.201202, 14.111977, 12.216812, -27.566175, 13.240652, 11.143919, -27.26703, 13.264112, 11.247925, -28.579897, 14.188468, 12.007677, -29.77852, 13.318491, 12.969162, -30.172102, 13.71146, 11.822292, -29.078878, 13.783996, 12.575394, -29.77852, 13.318491, 12.969162, -28.579897, 14.188468, 12.007677, -29.77852, 13.318491, 12.969162, -29.078878, 13.783996, 12.575394, -29.3032, 13.130279, 13.369244, -29.77852, 13.318491, 12.969162, -29.3032, 13.130279, 13.369244, -29.61749, 13.100707, 13.335033, -28.579897, 14.188468, 12.007677, -29.159786, 13.339252, 13.394022, -29.078878, 13.783996, 12.575394, -28.579897, 14.188468, 12.007677, -28.82114, 13.425723, 13.433544, -29.159786, 13.339252, 13.394022, -28.948093, 13.334579, 11.159084, -28.545794, 13.027181, 10.795259, -28.579897, 14.188468, 12.007677, -28.948093, 13.334579, 11.159084, -28.840069, 12.952937, 10.761011, -28.545794, 13.027181, 10.795259, -27.201202, 14.111977, 12.216812, -28.131073, 13.753705, 12.945568, -28.579897, 14.188468, 12.007677, -28.131073, 13.753705, 12.945568, -27.201202, 14.111977, 12.216812, -27.481268, 13.527826, 13.334137, -28.131073, 13.753705, 12.945568, -27.481268, 13.527826, 13.334137, -27.971476, 13.512636, 13.371208, -27.901386, 13.802778, 11.676207, -28.545794, 13.027181, 10.795259, -27.7606, 13.039855, 11.0648365, -27.901386, 13.802778, 11.676207, -28.579897, 14.188468, 12.007677, -28.545794, 13.027181, 10.795259, -25.963226, 13.52549, 12.449766, -26.931988, 13.740763, 12.7410145, -27.201202, 14.111977, 12.216812, -26.931988, 13.740763, 12.7410145, -25.963226, 13.52549, 12.449766, -25.99063, 13.406307, 12.702139, -26.931988, 13.740763, 12.7410145, -25.99063, 13.406307, 12.702139, -26.647318, 13.508326, 12.980991, -30.603403, 12.827642, 11.204182, -29.840925, 12.6641445, 10.642536, -30.172102, 13.71146, 11.822292, -30.603403, 12.827642, 11.204182, -30.356276, 12.512424, 10.763277, -29.840925, 12.6641445, 10.642536, -30.9988, 12.565276, 11.150988, -30.676294, 12.321331, 10.836258, -30.603403, 12.827642, 11.204182, -30.9988, 12.565276, 11.150988, -30.858511, 12.308209, 10.879881, -30.676294, 12.321331, 10.836258, -30.9988, 12.565276, 11.150988, -31.489027, 12.834562, 11.726076, -31.220568, 12.222282, 10.96516, -31.952402, 12.217968, 11.469395, -31.220568, 12.222282, 10.96516, -31.489027, 12.834562, 11.726076, -31.952402, 12.217968, 11.469395, -31.764006, 12.055641, 11.166313, -31.220568, 12.222282, 10.96516, -26.549658, 13.852484, 11.521194, -25.914532, 13.414665, 12.523054, -26.09762, 13.550388, 12.580524, -26.549658, 13.852484, 11.521194, -25.778147, 13.206228, 11.136642, -25.914532, 13.414665, 12.523054, -27.393269, 13.267618, 11.867287, -26.750992, 13.556589, 11.991425, -27.016235, 13.327029, 12.142666, -27.393269, 13.267618, 11.867287, -26.549658, 13.852484, 11.521194, -26.750992, 13.556589, 11.991425, -28.51631, 11.110254, 7.4424706, -28.004425, 12.171139, 8.214411, -28.58325, 11.828327, 8.733058, -28.51631, 11.110254, 7.4424706, -28.3986, 11.137669, 7.350334, -28.004425, 12.171139, 8.214411, -28.004425, 12.171139, 8.214411, -28.560322, 12.001171, 9.0543, -28.58325, 11.828327, 8.733058, -28.004425, 12.171139, 8.214411, -28.169415, 12.366816, 8.922272, -28.560322, 12.001171, 9.0543, -28.169415, 12.366816, 8.922272, -28.004425, 12.171139, 8.214411, -27.574968, 13.101874, 9.166556, -28.169415, 12.366816, 8.922272, -27.574968, 13.101874, 9.166556, -28.497368, 12.447617, 9.9169655, -28.169415, 12.366816, 8.922272, -28.497368, 12.447617, 9.9169655, -28.539549, 12.153973, 9.343277, -28.497368, 12.447617, 9.9169655, -28.157778, 12.77416, 10.008541, -28.434235, 12.498222, 10.057671, -28.497368, 12.447617, 9.9169655, -27.574968, 13.101874, 9.166556, -28.157778, 12.77416, 10.008541, -28.060122, 12.979725, 10.923248, -27.435986, 13.376825, 11.196117, -27.887424, 13.044889, 11.166651, -28.060122, 12.979725, 10.923248, -27.034447, 13.723143, 10.398659, -27.435986, 13.376825, 11.196117, -28.256086, 11.096142, 7.324676, -28.004425, 12.171139, 8.214411, -28.3986, 11.137669, 7.350334, -28.004425, 12.171139, 8.214411, -28.256086, 11.096142, 7.324676, -27.958471, 11.59544, 7.810196, -27.958471, 11.59544, 7.810196, -28.256086, 11.096142, 7.324676, -27.841549, 11.343948, 7.662429, -28.004425, 12.171139, 8.214411, -27.223587, 12.345152, 8.655518, -27.574968, 13.101874, 9.166556, -28.004425, 12.171139, 8.214411, -27.335718, 12.067413, 8.358295, -27.223587, 12.345152, 8.655518, -26.456749, 12.367084, 8.993832, -27.034447, 13.723143, 10.398659, -27.574968, 13.101874, 9.166556, -27.034447, 13.723143, 10.398659, -26.456749, 12.367084, 8.993832, -26.222069, 12.594754, 9.523212, -27.034447, 13.723143, 10.398659, -26.222069, 12.594754, 9.523212, -26.492432, 13.021158, 9.833479, -26.435522, 13.525401, 10.803925, -26.549658, 13.852484, 11.521194, -27.034447, 13.723143, 10.398659, -26.549658, 13.852484, 11.521194, -26.435522, 13.525401, 10.803925, -25.835228, 13.148436, 10.873161, -26.549658, 13.852484, 11.521194, -25.835228, 13.148436, 10.873161, -25.778147, 13.206228, 11.136642, -27.034447, 13.723143, 10.398659, -28.157778, 12.77416, 10.008541, -27.574968, 13.101874, 9.166556, -27.610704, 13.283167, 10.320328, -28.157778, 12.77416, 10.008541, -27.034447, 13.723143, 10.398659, -28.157778, 12.77416, 10.008541, -27.610704, 13.283167, 10.320328, -28.219557, 12.675113, 10.536343, -28.157778, 12.77416, 10.008541, -28.219557, 12.675113, 10.536343, -28.328056, 12.602215, 10.297457, -27.034447, 13.723143, 10.398659, -28.175543, 12.86018, 10.661373, -27.610704, 13.283167, 10.320328, -27.034447, 13.723143, 10.398659, -28.060122, 12.979725, 10.923248, -28.175543, 12.86018, 10.661373, -26.492432, 13.021158, 9.833479, -26.016369, 12.854247, 9.998339, -27.034447, 13.723143, 10.398659, -26.492432, 13.021158, 9.833479, -26.116678, 12.750883, 9.77084, -26.016369, 12.854247, 9.998339, -26.549658, 13.852484, 11.521194, -27.435986, 13.376825, 11.196117, -27.034447, 13.723143, 10.398659, -27.435986, 13.376825, 11.196117, -26.549658, 13.852484, 11.521194, -27.393269, 13.267618, 11.867287, -27.435986, 13.376825, 11.196117, -27.393269, 13.267618, 11.867287, -27.637686, 13.179714, 11.523783, -26.435522, 13.525401, 10.803925, -26.016369, 12.854247, 9.998339, -25.865164, 12.972085, 10.685308, -26.435522, 13.525401, 10.803925, -27.034447, 13.723143, 10.398659, -26.016369, 12.854247, 9.998339, -26.09762, 13.550388, 12.580524, -26.750992, 13.556589, 11.991425, -26.549658, 13.852484, 11.521194, -26.750992, 13.556589, 11.991425, -26.09762, 13.550388, 12.580524, -26.272526, 13.433453, 12.685123, -26.750992, 13.556589, 11.991425, -26.272526, 13.433453, 12.685123, -26.766165, 13.395969, 12.328079, -27.223587, 12.345152, 8.655518, -26.456749, 12.367084, 8.993832, -27.574968, 13.101874, 9.166556, -27.223587, 12.345152, 8.655518, -26.759527, 12.152982, 8.67496, -26.456749, 12.367084, 8.993832, -27.335718, 12.067413, 8.358295, -26.946072, 11.938253, 8.469797, -27.223587, 12.345152, 8.655518, -27.335718, 12.067413, 8.358295, -27.053827, 11.894838, 8.359913, -26.946072, 11.938253, 8.469797, -27.335718, 12.067413, 8.358295, -28.004425, 12.171139, 8.214411, -27.266567, 11.75786, 8.137337, -27.958471, 11.59544, 7.810196, -27.266567, 11.75786, 8.137337, -28.004425, 12.171139, 8.214411, -27.958471, 11.59544, 7.810196, -27.63729, 11.513017, 7.833111, -27.266567, 11.75786, 8.137337, -25.299118, 14.016339, 11.705952, -26.202145, 13.431294, 12.758247, -26.33764, 13.542391, 12.539379, -25.299118, 14.016339, 11.705952, -24.55057, 13.487381, 12.600025, -26.202145, 13.431294, 12.758247, -25.729456, 13.096121, 10.875708, -25.798758, 13.544817, 11.6329155, -25.976883, 13.186634, 11.381079, -25.729456, 13.096121, 10.875708, -25.299118, 14.016339, 11.705952, -25.798758, 13.544817, 11.6329155, -20.600985, 11.212358, 8.53599, -21.567623, 12.368253, 9.249824, -22.19324, 11.752106, 8.746279, -20.600985, 11.212358, 8.53599, -20.480156, 11.286333, 8.646276, -21.567623, 12.368253, 9.249824, -21.567623, 12.368253, 9.249824, -22.58087, 11.89322, 8.845634, -22.19324, 11.752106, 8.746279, -21.567623, 12.368253, 9.249824, -22.426706, 12.42362, 9.235527, -22.58087, 11.89322, 8.845634, -22.426706, 12.42362, 9.235527, -21.567623, 12.368253, 9.249824, -22.728525, 13.355524, 9.92947, -22.426706, 12.42362, 9.235527, -22.728525, 13.355524, 9.92947, -23.621424, 12.259226, 9.112419, -22.426706, 12.42362, 9.235527, -23.621424, 12.259226, 9.112419, -22.929403, 12.018518, 8.935175, -23.621424, 12.259226, 9.112419, -23.71438, 12.69767, 9.51326, -23.778412, 12.302008, 9.216736, -23.621424, 12.259226, 9.112419, -22.728525, 13.355524, 9.92947, -23.71438, 12.69767, 9.51326, -24.753212, 12.792858, 9.863087, -25.008253, 13.342491, 10.636538, -25.004257, 12.857843, 10.124001, -24.753212, 12.792858, 9.863087, -24.116346, 13.970501, 10.84609, -25.008253, 13.342491, 10.636538, -20.411009, 11.269615, 8.813364, -21.567623, 12.368253, 9.249824, -20.480156, 11.286333, 8.646276, -21.567623, 12.368253, 9.249824, -20.411009, 11.269615, 8.813364, -20.987125, 11.792372, 9.251802, -20.987125, 11.792372, 9.251802, -20.411009, 11.269615, 8.813364, -20.750517, 11.554993, 9.368832, -21.567623, 12.368253, 9.249824, -21.917282, 12.636011, 10.289265, -22.728525, 13.355524, 9.92947, -21.567623, 12.368253, 9.249824, -21.553564, 12.347399, 10.101149, -21.917282, 12.636011, 10.289265, -22.131565, 12.746388, 11.290453, -24.116346, 13.970501, 10.84609, -22.728525, 13.355524, 9.92947, -24.116346, 13.970501, 10.84609, -22.131565, 12.746388, 11.290453, -22.724337, 12.956984, 11.684092, -24.116346, 13.970501, 10.84609, -22.724337, 12.956984, 11.684092, -23.211079, 13.356783, 11.415373, -24.388641, 13.79298, 11.697922, -25.299118, 14.016339, 11.705952, -24.116346, 13.970501, 10.84609, -25.299118, 14.016339, 11.705952, -24.388641, 13.79298, 11.697922, -24.252804, 13.45727, 12.470831, -25.299118, 14.016339, 11.705952, -24.252804, 13.45727, 12.470831, -24.55057, 13.487381, 12.600025, -24.116346, 13.970501, 10.84609, -23.71438, 12.69767, 9.51326, -22.728525, 13.355524, 9.92947, -24.050045, 13.353634, 10.197813, -23.71438, 12.69767, 9.51326, -24.116346, 13.970501, 10.84609, -23.71438, 12.69767, 9.51326, -24.050045, 13.353634, 10.197813, -24.313118, 12.454627, 9.572146, -23.71438, 12.69767, 9.51326, -24.313118, 12.454627, 9.572146, -24.046837, 12.398183, 9.39505, -24.116346, 13.970501, 10.84609, -24.459291, 12.676998, 9.6678705, -24.050045, 13.353634, 10.197813, -24.116346, 13.970501, 10.84609, -24.753212, 12.792858, 9.863087, -24.459291, 12.676998, 9.6678705, -23.211079, 13.356783, 11.415373, -23.25923, 13.221777, 12.038786, -24.116346, 13.970501, 10.84609, -23.211079, 13.356783, 11.415373, -23.004206, 13.122009, 11.869499, -23.25923, 13.221777, 12.038786, -25.299118, 14.016339, 11.705952, -25.008253, 13.342491, 10.636538, -24.116346, 13.970501, 10.84609, -25.008253, 13.342491, 10.636538, -25.299118, 14.016339, 11.705952, -25.729456, 13.096121, 10.875708, -25.008253, 13.342491, 10.636538, -25.729456, 13.096121, 10.875708, -25.374859, 13.007589, 10.507263, -24.388641, 13.79298, 11.697922, -23.25923, 13.221777, 12.038786, -24.033789, 13.251799, 12.374115, -24.388641, 13.79298, 11.697922, -24.116346, 13.970501, 10.84609, -23.25923, 13.221777, 12.038786, -26.33764, 13.542391, 12.539379, -25.798758, 13.544817, 11.6329155, -25.299118, 14.016339, 11.705952, -25.798758, 13.544817, 11.6329155, -26.33764, 13.542391, 12.539379, -26.4637, 13.351927, 12.376793, -25.798758, 13.544817, 11.6329155, -26.4637, 13.351927, 12.376793, -26.145252, 13.288563, 11.72068, -21.917282, 12.636011, 10.289265, -22.131565, 12.746388, 11.290453, -22.728525, 13.355524, 9.92947, -21.917282, 12.636011, 10.289265, -21.7968, 12.497051, 10.865448, -22.131565, 12.746388, 11.290453, -21.553564, 12.347399, 10.101149, -21.57792, 12.244032, 10.592348, -21.917282, 12.636011, 10.289265, -21.553564, 12.347399, 10.101149, -21.464146, 12.196934, 10.445953, -21.57792, 12.244032, 10.592348, -21.553564, 12.347399, 10.101149, -21.567623, 12.368253, 9.249824, -21.231346, 12.042698, 10.149359, -20.987125, 11.792372, 9.251802, -21.231346, 12.042698, 10.149359, -21.567623, 12.368253, 9.249824, -20.987125, 11.792372, 9.251802, -20.92406, 11.751565, 9.648912, -21.231346, 12.042698, 10.149359, -25.705019, 13.809881, 13.562584, -26.45702, 13.504189, 12.279745, -26.186665, 13.570251, 12.245989, -25.705019, 13.809881, 13.562584, -26.888538, 13.45844, 13.87192, -26.45702, 13.504189, 12.279745, -24.945316, 12.747828, 13.355633, -25.55226, 13.354175, 13.0715885, -25.309752, 12.956354, 12.9552, -24.945316, 12.747828, 13.355633, -25.705019, 13.809881, 13.562584, -25.55226, 13.354175, 13.0715885, -24.723381, 10.182662, 18.724218, -24.892126, 11.507984, 17.707823, -24.315966, 10.822086, 17.237934, -24.723381, 10.182662, 18.724218, -24.853382, 10.273891, 18.805033, -24.892126, 11.507984, 17.707823, -24.892126, 11.507984, 17.707823, -24.260817, 10.9978075, 16.859945, -24.315966, 10.822086, 17.237934, -24.892126, 11.507984, 17.707823, -24.594227, 11.594991, 16.92428, -24.260817, 10.9978075, 16.859945, -24.594227, 11.594991, 16.92428, -24.892126, 11.507984, 17.707823, -24.99707, 12.669626, 16.504105, -24.594227, 11.594991, 16.92428, -24.99707, 12.669626, 16.504105, -24.11179, 11.456928, 15.843534, -24.594227, 11.594991, 16.92428, -24.11179, 11.456928, 15.843534, -24.211235, 11.154295, 16.519825, -24.11179, 11.456928, 15.843534, -24.396267, 11.977171, 15.664667, -24.146303, 11.527758, 15.66567, -24.11179, 11.456928, 15.843534, -24.99707, 12.669626, 16.504105, -24.396267, 11.977171, 15.664667, -24.372864, 12.190012, 14.588343, -24.92865, 12.906826, 14.1381035, -24.519852, 12.320793, 14.269573, -24.372864, 12.190012, 14.588343, -25.306261, 13.528545, 14.961657, -24.92865, 12.906826, 14.1381035, -25.035671, 10.291328, 18.806995, -24.892126, 11.507984, 17.707823, -24.853382, 10.273891, 18.805033, -24.892126, 11.507984, 17.707823, -25.035671, 10.291328, 18.806995, -25.174652, 10.921768, 18.176992, -25.174652, 10.921768, 18.176992, -25.035671, 10.291328, 18.806995, -25.392036, 10.70524, 18.326195, -24.892126, 11.507984, 17.707823, -25.717178, 12.011866, 17.041342, -24.99707, 12.669626, 16.504105, -24.892126, 11.507984, 17.707823, -25.703766, 11.673546, 17.411934, -25.717178, 12.011866, 17.041342, -26.566265, 12.347487, 16.495499, -25.306261, 13.528545, 14.961657, -24.99707, 12.669626, 16.504105, -25.306261, 13.528545, 14.961657, -26.566265, 12.347487, 16.495499, -26.699371, 12.663515, 15.829714, -25.306261, 13.528545, 14.961657, -26.699371, 12.663515, 15.829714, -26.24021, 13.016304, 15.522837, -26.053259, 13.553265, 14.382238, -25.705019, 13.809881, 13.562584, -25.306261, 13.528545, 14.961657, -25.705019, 13.809881, 13.562584, -26.053259, 13.553265, 14.382238, -26.875654, 13.388691, 14.190426, -25.705019, 13.809881, 13.562584, -26.875654, 13.388691, 14.190426, -26.888538, 13.45844, 13.87192, -25.306261, 13.528545, 14.961657, -24.396267, 11.977171, 15.664667, -24.99707, 12.669626, 16.504105, -24.82944, 12.781982, 15.184017, -24.396267, 11.977171, 15.664667, -25.306261, 13.528545, 14.961657, -24.396267, 11.977171, 15.664667, -24.82944, 12.781982, 15.184017, -24.26432, 11.775922, 15.060766, -24.396267, 11.977171, 15.664667, -24.26432, 11.775922, 15.060766, -24.206535, 11.67166, 15.364321, -25.306261, 13.528545, 14.961657, -24.306345, 12.02211, 14.9169445, -24.82944, 12.781982, 15.184017, -25.306261, 13.528545, 14.961657, -24.372864, 12.190012, 14.588343, -24.306345, 12.02211, 14.9169445, -26.24021, 13.016304, 15.522837, -26.82347, 13.022417, 15.237913, -25.306261, 13.528545, 14.961657, -26.24021, 13.016304, 15.522837, -26.765692, 12.877438, 15.523392, -26.82347, 13.022417, 15.237913, -25.705019, 13.809881, 13.562584, -24.92865, 12.906826, 14.1381035, -25.306261, 13.528545, 14.961657, -24.92865, 12.906826, 14.1381035, -25.705019, 13.809881, 13.562584, -24.945316, 12.747828, 13.355633, -24.92865, 12.906826, 14.1381035, -24.945316, 12.747828, 13.355633, -24.738127, 12.5664425, 13.806084, -26.053259, 13.553265, 14.382238, -26.82347, 13.022417, 15.237913, -26.853886, 13.155892, 14.403858, -26.053259, 13.553265, 14.382238, -25.306261, 13.528545, 14.961657, -26.82347, 13.022417, 15.237913, -26.186665, 13.570251, 12.245989, -25.55226, 13.354175, 13.0715885, -25.705019, 13.809881, 13.562584, -25.55226, 13.354175, 13.0715885, -26.186665, 13.570251, 12.245989, -26.027418, 13.354084, 12.165661, -25.55226, 13.354175, 13.0715885, -26.027418, 13.354084, 12.165661, -25.556536, 13.13711, 12.689047, -25.717178, 12.011866, 17.041342, -26.566265, 12.347487, 16.495499, -24.99707, 12.669626, 16.504105, -25.717178, 12.011866, 17.041342, -26.317577, 11.997127, 16.929232, -26.566265, 12.347487, 16.495499, -25.703766, 11.673546, 17.411934, -26.153154, 11.680018, 17.200329, -25.717178, 12.011866, 17.041342, -25.703766, 11.673546, 17.411934, -26.069336, 11.597685, 17.352945, -26.153154, 11.680018, 17.200329, -25.703766, 11.673546, 17.411934, -24.892126, 11.507984, 17.707823, -25.896534, 11.372709, 17.657299, -25.174652, 10.921768, 18.176992, -25.896534, 11.372709, 17.657299, -24.892126, 11.507984, 17.707823, -25.174652, 10.921768, 18.176992, -25.57406, 10.966168, 18.087864, -25.896534, 11.372709, 17.657299, -23.578579, 3.6379063, 11.557745, -24.12619, 7.057568, 11.959915, -23.8756, 7.125276, 11.658489, -23.578579, 3.6379063, 11.557745, -23.888453, 3.5751674, 11.919102, -24.12619, 7.057568, 11.959915, -23.836985, -0.21907139, 11.791069, -23.888453, 3.5751674, 11.919102, -23.578579, 3.6379063, 11.557745, -23.836985, -0.21907139, 11.791069, -24.181253, -0.21472502, 12.204487, -23.888453, 3.5751674, 11.919102, -24.709448, 3.4888213, 11.70261, -24.880796, 6.9884076, 11.3729, -24.819984, 6.963768, 11.765211, -24.709448, 3.4888213, 11.70261, -24.77573, 3.5122192, 11.244535, -24.880796, 6.9884076, 11.3729, -24.003609, 3.6379066, 10.807255, -23.93851, 7.1495028, 11.266003, -24.242756, 7.125276, 11.010187, -24.003609, 3.6379066, 10.807255, -23.637373, 3.6615102, 11.095457, -23.93851, 7.1495028, 11.266003, -24.819984, 6.963768, 11.765211, -25.485994, 10.232269, 11.85763, -25.442476, 10.210943, 12.117752, -24.819984, 6.963768, 11.765211, -24.880796, 6.9884076, 11.3729, -25.485994, 10.232269, 11.85763, -23.8756, 7.125276, 11.658489, -24.984426, 10.2888, 12.251429, -24.815989, 10.342636, 12.050236, -23.8756, 7.125276, 11.658489, -24.12619, 7.057568, 11.959915, -24.984426, 10.2888, 12.251429, -24.815989, 10.342636, 12.050236, -24.984426, 10.2888, 12.251429, -25.548643, 11.995004, 12.470713, -24.815989, 10.342636, 12.050236, -25.548643, 11.995004, 12.470713, -25.430141, 12.056503, 12.32415, -23.888453, 3.5751674, 11.919102, -24.514824, 6.9884076, 12.019112, -24.12619, 7.057568, 11.959915, -23.888453, 3.5751674, 11.919102, -24.350702, 3.512219, 11.995026, -24.514824, 6.9884076, 12.019112, -24.12619, 7.057568, 11.959915, -24.514824, 6.9884076, 12.019112, -25.241776, 10.232269, 12.288855, -24.12619, 7.057568, 11.959915, -25.241776, 10.232269, 12.288855, -24.984426, 10.2888, 12.251429, -24.77573, 3.5122192, 11.244535, -24.63014, 7.057568, 11.070068, -24.880796, 6.9884076, 11.3729, -24.77573, 3.5122192, 11.244535, -24.472868, 3.5751677, 10.8871765, -24.63014, 7.057568, 11.070068, -24.880796, 6.9884076, 11.3729, -24.63014, 7.057568, 11.070068, -25.321539, 10.2888, 11.656172, -24.880796, 6.9884076, 11.3729, -25.321539, 10.2888, 11.656172, -25.485994, 10.232269, 11.85763, -24.242756, 7.125276, 11.010187, -24.857891, 10.362101, 11.78668, -25.062378, 10.342636, 11.615177, -24.242756, 7.125276, 11.010187, -23.93851, 7.1495028, 11.266003, -24.857891, 10.362101, 11.78668, -23.637373, 3.6615102, 11.095457, -23.8756, 7.125276, 11.658489, -23.93851, 7.1495028, 11.266003, -23.637373, 3.6615102, 11.095457, -23.578579, 3.6379063, 11.557745, -23.8756, 7.125276, 11.658489, -23.93851, 7.1495028, 11.266003, -24.815989, 10.342636, 12.050236, -24.857891, 10.362101, 11.78668, -23.93851, 7.1495028, 11.266003, -23.8756, 7.125276, 11.658489, -24.815989, 10.342636, 12.050236, -24.472868, 3.5751677, 10.8871765, -24.242756, 7.125276, 11.010187, -24.63014, 7.057568, 11.070068, -24.472868, 3.5751677, 10.8871765, -24.003609, 3.6379066, 10.807255, -24.242756, 7.125276, 11.010187, -24.868216, -0.21472502, 10.9914875, -24.003609, 3.6379066, 10.807255, -24.472868, 3.5751677, 10.8871765, -24.868216, -0.21472502, 10.9914875, -24.336596, -0.21907139, 10.908884, -24.003609, 3.6379066, 10.807255, -24.63014, 7.057568, 11.070068, -25.062378, 10.342636, 11.615177, -25.321539, 10.2888, 11.656172, -24.63014, 7.057568, 11.070068, -24.242756, 7.125276, 11.010187, -25.062378, 10.342636, 11.615177, -25.321539, 10.2888, 11.656172, -25.062378, 10.342636, 11.615177, -25.613142, 12.056503, 12.001019, -25.321539, 10.2888, 11.656172, -25.613142, 12.056503, 12.001019, -25.79978, 11.995004, 12.027269, -25.883152, 11.911559, 12.367321, -26.206875, 13.210678, 12.70623, -25.735323, 11.93475, 12.496033, -25.883152, 11.911559, 12.367321, -26.31439, 13.191008, 12.611546, -26.206875, 13.210678, 12.70623, -25.548643, 11.995004, 12.470713, -25.993643, 13.325804, 12.585468, -25.430141, 12.056503, 12.32415, -25.548643, 11.995004, 12.470713, -26.075993, 13.270106, 12.690508, -25.993643, 13.325804, 12.585468, -25.735323, 11.93475, 12.496033, -26.075993, 13.270106, 12.690508, -25.548643, 11.995004, 12.470713, -25.735323, 11.93475, 12.496033, -26.206875, 13.210678, 12.70623, -26.075993, 13.270106, 12.690508, -25.463963, 12.079072, 12.129919, -26.127062, 13.325804, 12.349884, -25.613142, 12.056503, 12.001019, -25.463963, 12.079072, 12.129919, -26.021097, 13.349201, 12.445444, -26.127062, 13.325804, 12.349884, -25.430141, 12.056503, 12.32415, -26.021097, 13.349201, 12.445444, -25.463963, 12.079072, 12.129919, -25.430141, 12.056503, 12.32415, -25.993643, 13.325804, 12.585468, -26.021097, 13.349201, 12.445444, -25.79978, 11.995004, 12.027269, -26.340294, 13.210678, 12.470644, -25.917505, 11.93475, 12.174345, -25.79978, 11.995004, 12.027269, -26.259499, 13.270106, 12.366484, -26.340294, 13.210678, 12.470644, -25.613142, 12.056503, 12.001019, -26.259499, 13.270106, 12.366484, -25.79978, 11.995004, 12.027269, -25.613142, 12.056503, 12.001019, -26.127062, 13.325804, 12.349884, -26.259499, 13.270106, 12.366484, -24.715683, -0.21037531, 12.288706, -24.709448, 3.4888213, 11.70261, -24.350702, 3.512219, 11.995026, -24.715683, -0.21037531, 12.288706, -25.129576, -0.2087202, 11.940542, -24.709448, 3.4888213, 11.70261, -25.917505, 11.93475, 12.174345, -26.31439, 13.191008, 12.611546, -25.883152, 11.911559, 12.367321, -25.917505, 11.93475, 12.174345, -26.340294, 13.210678, 12.470644, -26.31439, 13.191008, 12.611546, -24.350702, 3.512219, 11.995026, -24.819984, 6.963768, 11.765211, -24.514824, 6.9884076, 12.019112, -24.350702, 3.512219, 11.995026, -24.709448, 3.4888213, 11.70261, -24.819984, 6.963768, 11.765211, -25.129576, -0.2087202, 11.940542, -24.77573, 3.5122192, 11.244535, -24.709448, 3.4888213, 11.70261, -25.129576, -0.2087202, 11.940542, -25.215294, -0.21037531, 11.406523, -24.77573, 3.5122192, 11.244535, -24.336596, -0.21907139, 10.908884, -23.637373, 3.6615102, 11.095457, -24.003609, 3.6379066, 10.807255, -24.336596, -0.21907139, 10.908884, -23.924746, -0.2207303, 11.258205, -23.637373, 3.6615102, 11.095457, -24.181253, -0.21472502, 12.204487, -24.350702, 3.512219, 11.995026, -23.888453, 3.5751674, 11.919102, -24.181253, -0.21472502, 12.204487, -24.715683, -0.21037531, 12.288706, -24.350702, 3.512219, 11.995026, -25.215294, -0.21037531, 11.406523, -24.472868, 3.5751677, 10.8871765, -24.77573, 3.5122192, 11.244535, -25.215294, -0.21037531, 11.406523, -24.868216, -0.21472502, 10.9914875, -24.472868, 3.5751677, 10.8871765, -23.924746, -0.2207303, 11.258205, -23.578579, 3.6379063, 11.557745, -23.637373, 3.6615102, 11.095457, -23.924746, -0.2207303, 11.258205, -23.836985, -0.21907139, 11.791069, -23.578579, 3.6379063, 11.557745, -24.514824, 6.9884076, 12.019112, -25.442476, 10.210943, 12.117752, -25.241776, 10.232269, 12.288855, -24.514824, 6.9884076, 12.019112, -24.819984, 6.963768, 11.765211, -25.442476, 10.210943, 12.117752, -25.241776, 10.232269, 12.288855, -25.883152, 11.911559, 12.367321, -25.735323, 11.93475, 12.496033, -25.241776, 10.232269, 12.288855, -25.442476, 10.210943, 12.117752, -25.883152, 11.911559, 12.367321, -25.442476, 10.210943, 12.117752, -25.917505, 11.93475, 12.174345, -25.883152, 11.911559, 12.367321, -25.442476, 10.210943, 12.117752, -25.485994, 10.232269, 11.85763, -25.917505, 11.93475, 12.174345, -24.984426, 10.2888, 12.251429, -25.735323, 11.93475, 12.496033, -25.548643, 11.995004, 12.470713, -24.984426, 10.2888, 12.251429, -25.241776, 10.232269, 12.288855, -25.735323, 11.93475, 12.496033, -25.062378, 10.342636, 11.615177, -25.463963, 12.079072, 12.129919, -25.613142, 12.056503, 12.001019, -25.062378, 10.342636, 11.615177, -24.857891, 10.362101, 11.78668, -25.463963, 12.079072, 12.129919, -24.857891, 10.362101, 11.78668, -25.430141, 12.056503, 12.32415, -25.463963, 12.079072, 12.129919, -24.857891, 10.362101, 11.78668, -24.815989, 10.342636, 12.050236, -25.430141, 12.056503, 12.32415, -25.485994, 10.232269, 11.85763, -25.79978, 11.995004, 12.027269, -25.917505, 11.93475, 12.174345, -25.485994, 10.232269, 11.85763, -25.321539, 10.2888, 11.656172, -25.79978, 11.995004, 12.027269, 14.960796, 7.3177958, 10.284792, 14.197514, 7.0888658, 10.165964, 14.230383, 7.219601, 10.105017, 14.960796, 7.3177958, 10.284792, 14.899889, 6.6811314, 10.556076, 14.197514, 7.0888658, 10.165964, 15.035656, 7.2923393, 9.619492, 14.762205, 7.297971, 9.966335, 14.763325, 7.2744055, 9.734959, 15.035656, 7.2923393, 9.619492, 14.960796, 7.3177958, 10.284792, 14.762205, 7.297971, 9.966335, 17.810453, 5.5428567, 9.76613, 17.232166, 6.147483, 10.085447, 17.132032, 6.2065306, 9.564142, 17.810453, 5.5428567, 9.76613, 17.822802, 5.5103865, 9.856148, 17.232166, 6.147483, 10.085447, 17.232166, 6.147483, 10.085447, 16.95009, 6.3580484, 9.530783, 17.132032, 6.2065306, 9.564142, 17.232166, 6.147483, 10.085447, 16.89884, 6.4360313, 9.857285, 16.95009, 6.3580484, 9.530783, 16.89884, 6.4360313, 9.857285, 17.232166, 6.147483, 10.085447, 16.58012, 6.736306, 10.276844, 16.89884, 6.4360313, 9.857285, 16.58012, 6.736306, 10.276844, 16.462008, 6.7533646, 9.4380245, 16.89884, 6.4360313, 9.857285, 16.462008, 6.7533646, 9.4380245, 16.786558, 6.492572, 9.500252, 16.462008, 6.7533646, 9.4380245, 16.302326, 6.86697, 9.66189, 16.365639, 6.7913795, 9.442841, 16.462008, 6.7533646, 9.4380245, 16.58012, 6.736306, 10.276844, 16.302326, 6.86697, 9.66189, 15.768839, 7.134306, 9.500018, 15.418615, 7.241414, 9.825178, 15.579935, 7.1703434, 9.529699, 15.768839, 7.134306, 9.500018, 15.7336235, 7.169642, 10.347719, 15.418615, 7.241414, 9.825178, 17.788603, 5.4442396, 9.916714, 17.232166, 6.147483, 10.085447, 17.822802, 5.5103865, 9.856148, 17.232166, 6.147483, 10.085447, 17.788603, 5.4442396, 9.916714, 17.429657, 5.7386765, 10.057089, 17.429657, 5.7386765, 10.057089, 17.788603, 5.4442396, 9.916714, 17.469614, 5.541645, 10.084452, 17.232166, 6.147483, 10.085447, 16.729776, 6.127803, 10.365282, 16.58012, 6.736306, 10.276844, 17.232166, 6.147483, 10.085447, 16.926506, 5.9454803, 10.319639, 16.729776, 6.127803, 10.365282, 16.288668, 6.0046787, 10.627327, 15.7336235, 7.169642, 10.347719, 16.58012, 6.736306, 10.276844, 15.7336235, 7.169642, 10.347719, 16.288668, 6.0046787, 10.627327, 15.925083, 6.171483, 10.651344, 15.7336235, 7.169642, 10.347719, 15.925083, 6.171483, 10.651344, 15.8492565, 6.533848, 10.554647, 15.306034, 6.9726505, 10.442774, 14.960796, 7.3177958, 10.284792, 15.7336235, 7.169642, 10.347719, 14.960796, 7.3177958, 10.284792, 15.306034, 6.9726505, 10.442774, 15.061283, 6.615505, 10.587776, 14.960796, 7.3177958, 10.284792, 15.061283, 6.615505, 10.587776, 14.899889, 6.6811314, 10.556076, 15.7336235, 7.169642, 10.347719, 16.302326, 6.86697, 9.66189, 16.58012, 6.736306, 10.276844, 15.95689, 7.068595, 9.972179, 16.302326, 6.86697, 9.66189, 15.7336235, 7.169642, 10.347719, 16.302326, 6.86697, 9.66189, 15.95689, 7.068595, 9.972179, 16.037476, 6.9235826, 9.459768, 16.302326, 6.86697, 9.66189, 16.037476, 6.9235826, 9.459768, 16.200966, 6.8668146, 9.453747, 15.7336235, 7.169642, 10.347719, 15.948803, 7.0493326, 9.487534, 15.95689, 7.068595, 9.972179, 15.7336235, 7.169642, 10.347719, 15.768839, 7.134306, 9.500018, 15.948803, 7.0493326, 9.487534, 15.8492565, 6.533848, 10.554647, 15.598484, 6.34639, 10.67927, 15.7336235, 7.169642, 10.347719, 15.8492565, 6.533848, 10.554647, 15.754543, 6.2731757, 10.668556, 15.598484, 6.34639, 10.67927, 14.960796, 7.3177958, 10.284792, 15.418615, 7.241414, 9.825178, 15.7336235, 7.169642, 10.347719, 15.418615, 7.241414, 9.825178, 14.960796, 7.3177958, 10.284792, 15.035656, 7.2923393, 9.619492, 15.418615, 7.241414, 9.825178, 15.035656, 7.2923393, 9.619492, 15.302166, 7.2475796, 9.57913, 15.306034, 6.9726505, 10.442774, 15.598484, 6.34639, 10.67927, 15.17808, 6.500619, 10.592104, 15.306034, 6.9726505, 10.442774, 15.7336235, 7.169642, 10.347719, 15.598484, 6.34639, 10.67927, 14.230383, 7.219601, 10.105017, 14.762205, 7.297971, 9.966335, 14.960796, 7.3177958, 10.284792, 14.762205, 7.297971, 9.966335, 14.230383, 7.219601, 10.105017, 14.228041, 7.218266, 9.957092, 14.762205, 7.297971, 9.966335, 14.228041, 7.218266, 9.957092, 14.580687, 7.2687683, 9.813696, 16.729776, 6.127803, 10.365282, 16.288668, 6.0046787, 10.627327, 16.58012, 6.736306, 10.276844, 16.729776, 6.127803, 10.365282, 16.561163, 5.9042377, 10.517782, 16.288668, 6.0046787, 10.627327, 16.926506, 5.9454803, 10.319639, 16.737123, 5.796604, 10.437109, 16.729776, 6.127803, 10.365282, 16.926506, 5.9454803, 10.319639, 16.830635, 5.779464, 10.403534, 16.737123, 5.796604, 10.437109, 16.926506, 5.9454803, 10.319639, 17.232166, 6.147483, 10.085447, 17.020973, 5.7145977, 10.328281, 17.429657, 5.7386765, 10.057089, 17.020973, 5.7145977, 10.328281, 17.232166, 6.147483, 10.085447, 17.429657, 5.7386765, 10.057089, 17.308277, 5.615484, 10.174715, 17.020973, 5.7145977, 10.328281, 13.748751, 7.053542, 10.572222, 14.344303, 7.2258663, 9.903076, 14.400131, 7.268545, 10.054003, 13.748751, 7.053542, 10.572222, 13.42976, 6.790598, 9.931854, 14.344303, 7.2258663, 9.903076, 14.244236, 6.5294213, 10.857023, 14.149033, 6.936182, 10.532454, 14.338385, 6.748352, 10.605932, 14.244236, 6.5294213, 10.857023, 13.748751, 7.053542, 10.572222, 14.149033, 6.936182, 10.532454, 11.830196, 3.7078786, 11.580435, 12.08853, 4.698938, 11.459175, 12.606657, 4.4480114, 11.634403, 11.830196, 3.7078786, 11.580435, 11.739759, 3.7365186, 11.530159, 12.08853, 4.698938, 11.459175, 12.08853, 4.698938, 11.459175, 12.792095, 4.643725, 11.622538, 12.606657, 4.4480114, 11.634403, 12.08853, 4.698938, 11.459175, 12.569534, 4.9534965, 11.509512, 12.792095, 4.643725, 11.622538, 12.569534, 4.9534965, 11.509512, 12.08853, 4.698938, 11.459175, 12.50172, 5.6512947, 11.328012, 12.569534, 4.9534965, 11.509512, 12.50172, 5.6512947, 11.328012, 13.291006, 5.162043, 11.5888405, 12.569534, 4.9534965, 11.509512, 13.291006, 5.162043, 11.5888405, 12.958977, 4.8188252, 11.61166, 13.291006, 5.162043, 11.5888405, 13.2354355, 5.49573, 11.458206, 13.363539, 5.246913, 11.546379, 13.291006, 5.162043, 11.5888405, 12.50172, 5.6512947, 11.328012, 13.2354355, 5.49573, 11.458206, 13.796944, 5.897662, 11.320793, 13.790836, 6.4103017, 11.012831, 13.913025, 6.053091, 11.199114, 13.796944, 5.897662, 11.320793, 13.121664, 6.529729, 10.9958515, 13.790836, 6.4103017, 11.012831, 11.699358, 3.746626, 11.430775, 12.08853, 4.698938, 11.459175, 11.739759, 3.7365186, 11.530159, 12.08853, 4.698938, 11.459175, 11.699358, 3.746626, 11.430775, 11.891642, 4.2572117, 11.316582, 11.891642, 4.2572117, 11.316582, 11.699358, 3.746626, 11.430775, 11.803057, 4.0996614, 11.194469, 12.08853, 4.698938, 11.459175, 12.198441, 5.1560144, 10.946365, 12.50172, 5.6512947, 11.328012, 12.08853, 4.698938, 11.459175, 12.062822, 4.8732367, 10.978052, 12.198441, 5.1560144, 10.946365, 12.262251, 5.4878073, 10.418972, 13.121664, 6.529729, 10.9958515, 12.50172, 5.6512947, 11.328012, 13.121664, 6.529729, 10.9958515, 12.262251, 5.4878073, 10.418972, 12.532429, 5.8353195, 10.266994, 13.121664, 6.529729, 10.9958515, 12.532429, 5.8353195, 10.266994, 12.735952, 6.1089916, 10.515257, 13.298405, 6.7030754, 10.491749, 13.748751, 7.053542, 10.572222, 13.121664, 6.529729, 10.9958515, 13.748751, 7.053542, 10.572222, 13.298405, 6.7030754, 10.491749, 13.275257, 6.6692533, 9.985384, 13.748751, 7.053542, 10.572222, 13.275257, 6.6692533, 9.985384, 13.42976, 6.790598, 9.931854, 13.121664, 6.529729, 10.9958515, 13.2354355, 5.49573, 11.458206, 12.50172, 5.6512947, 11.328012, 13.256226, 6.064356, 11.224297, 13.2354355, 5.49573, 11.458206, 13.121664, 6.529729, 10.9958515, 13.2354355, 5.49573, 11.458206, 13.256226, 6.064356, 11.224297, 13.609962, 5.5398574, 11.403032, 13.2354355, 5.49573, 11.458206, 13.609962, 5.5398574, 11.403032, 13.485888, 5.4047327, 11.477678, 13.121664, 6.529729, 10.9958515, 13.663889, 5.719082, 11.394192, 13.256226, 6.064356, 11.224297, 13.121664, 6.529729, 10.9958515, 13.796944, 5.897662, 11.320793, 13.663889, 5.719082, 11.394192, 12.735952, 6.1089916, 10.515257, 12.770527, 6.18994, 10.142353, 13.121664, 6.529729, 10.9958515, 12.735952, 6.1089916, 10.515257, 12.654997, 6.03539, 10.206213, 12.770527, 6.18994, 10.142353, 13.748751, 7.053542, 10.572222, 13.790836, 6.4103017, 11.012831, 13.121664, 6.529729, 10.9958515, 13.790836, 6.4103017, 11.012831, 13.748751, 7.053542, 10.572222, 14.244236, 6.5294213, 10.857023, 13.790836, 6.4103017, 11.012831, 14.244236, 6.5294213, 10.857023, 14.080233, 6.311716, 11.029364, 13.298405, 6.7030754, 10.491749, 12.770527, 6.18994, 10.142353, 13.176046, 6.4788733, 9.995405, 13.298405, 6.7030754, 10.491749, 13.121664, 6.529729, 10.9958515, 12.770527, 6.18994, 10.142353, 14.400131, 7.268545, 10.054003, 14.149033, 6.936182, 10.532454, 13.748751, 7.053542, 10.572222, 14.149033, 6.936182, 10.532454, 14.400131, 7.268545, 10.054003, 14.524361, 7.1721907, 10.108967, 14.149033, 6.936182, 10.532454, 14.524361, 7.1721907, 10.108967, 14.399626, 6.918585, 10.443947, 12.198441, 5.1560144, 10.946365, 12.262251, 5.4878073, 10.418972, 12.50172, 5.6512947, 11.328012, 12.198441, 5.1560144, 10.946365, 12.143154, 5.1808786, 10.591608, 12.262251, 5.4878073, 10.418972, 12.062822, 4.8732367, 10.978052, 12.071648, 4.931636, 10.687195, 12.198441, 5.1560144, 10.946365, 12.062822, 4.8732367, 10.978052, 12.028627, 4.8476563, 10.753109, 12.071648, 4.931636, 10.687195, 12.062822, 4.8732367, 10.978052, 12.08853, 4.698938, 11.459175, 11.94441, 4.6445174, 10.876879, 11.891642, 4.2572117, 11.316582, 11.94441, 4.6445174, 10.876879, 12.08853, 4.698938, 11.459175, 11.891642, 4.2572117, 11.316582, 11.852747, 4.307365, 11.084038, 11.94441, 4.6445174, 10.876879, 13.7075405, 7.556304, 10.0746155, 14.456242, 7.1163726, 9.9533615, 14.450766, 7.1982546, 10.098274, 13.7075405, 7.556304, 10.0746155, 13.637535, 7.179911, 9.4115, 14.456242, 7.1163726, 9.9533615, 13.580511, 7.079011, 10.661566, 13.885704, 7.279489, 10.325115, 13.870678, 7.088043, 10.517448, 13.580511, 7.079011, 10.661566, 13.7075405, 7.556304, 10.0746155, 13.885704, 7.279489, 10.325115, 10.434713, 6.2257376, 9.758102, 11.151068, 6.834375, 9.774982, 11.238207, 6.5067883, 10.272303, 10.434713, 6.2257376, 9.758102, 10.420666, 6.2614727, 9.657367, 11.151068, 6.834375, 9.774982, 11.151068, 6.834375, 9.774982, 11.449902, 6.5769014, 10.375746, 11.238207, 6.5067883, 10.272303, 11.151068, 6.834375, 9.774982, 11.530296, 6.857931, 10.121874, 11.449902, 6.5769014, 10.375746, 11.530296, 6.857931, 10.121874, 11.151068, 6.834375, 9.774982, 11.933365, 7.3444357, 9.894124, 11.530296, 6.857931, 10.121874, 11.933365, 7.3444357, 9.894124, 12.01959, 6.757763, 10.651842, 11.530296, 6.857931, 10.121874, 12.01959, 6.757763, 10.651842, 11.640572, 6.6390653, 10.468557, 12.01959, 6.757763, 10.651842, 12.206385, 6.9807854, 10.499392, 12.129288, 6.772664, 10.661032, 12.01959, 6.757763, 10.651842, 11.933365, 7.3444357, 9.894124, 12.206385, 6.9807854, 10.499392, 12.789576, 6.995577, 10.750685, 13.181564, 7.251497, 10.484621, 12.994299, 7.0094805, 10.72579, 12.789576, 6.995577, 10.750685, 12.882765, 7.613677, 10.007544, 13.181564, 7.251497, 10.484621, 10.444181, 6.238669, 9.556094, 11.151068, 6.834375, 9.774982, 10.420666, 6.2614727, 9.657367, 11.151068, 6.834375, 9.774982, 10.444181, 6.238669, 9.556094, 10.868927, 6.502743, 9.566353, 10.868927, 6.502743, 9.566353, 10.444181, 6.238669, 9.556094, 10.796675, 6.356696, 9.423105, 11.151068, 6.834375, 9.774982, 11.658583, 6.90143, 9.442226, 11.933365, 7.3444357, 9.894124, 11.151068, 6.834375, 9.774982, 11.422882, 6.7517605, 9.392457, 11.658583, 6.90143, 9.442226, 12.092066, 6.8811474, 9.071436, 12.882765, 7.613677, 10.007544, 11.933365, 7.3444357, 9.894124, 12.882765, 7.613677, 10.007544, 12.092066, 6.8811474, 9.071436, 12.501753, 6.965708, 9.113264, 12.882765, 7.613677, 10.007544, 12.501753, 6.965708, 9.113264, 12.637543, 7.2165165, 9.423414, 13.274715, 7.4356117, 9.746176, 13.7075405, 7.556304, 10.0746155, 12.882765, 7.613677, 10.007544, 13.7075405, 7.556304, 10.0746155, 13.274715, 7.4356117, 9.746176, 13.45668, 7.17616, 9.358056, 13.7075405, 7.556304, 10.0746155, 13.45668, 7.17616, 9.358056, 13.637535, 7.179911, 9.4115, 12.882765, 7.613677, 10.007544, 12.206385, 6.9807854, 10.499392, 11.933365, 7.3444357, 9.894124, 12.606947, 7.305357, 10.3043785, 12.206385, 6.9807854, 10.499392, 12.882765, 7.613677, 10.007544, 12.206385, 6.9807854, 10.499392, 12.606947, 7.305357, 10.3043785, 12.502178, 6.827238, 10.693289, 12.206385, 6.9807854, 10.499392, 12.502178, 6.827238, 10.693289, 12.314716, 6.8113565, 10.680117, 12.882765, 7.613677, 10.007544, 12.587612, 6.947036, 10.728325, 12.606947, 7.305357, 10.3043785, 12.882765, 7.613677, 10.007544, 12.789576, 6.995577, 10.750685, 12.587612, 6.947036, 10.728325, 12.637543, 7.2165165, 9.423414, 12.864445, 7.0854006, 9.161785, 12.882765, 7.613677, 10.007544, 12.637543, 7.2165165, 9.423414, 12.688946, 7.04373, 9.142518, 12.864445, 7.0854006, 9.161785, 13.7075405, 7.556304, 10.0746155, 13.181564, 7.251497, 10.484621, 12.882765, 7.613677, 10.007544, 13.181564, 7.251497, 10.484621, 13.7075405, 7.556304, 10.0746155, 13.580511, 7.079011, 10.661566, 13.181564, 7.251497, 10.484621, 13.580511, 7.079011, 10.661566, 13.291115, 7.061289, 10.697266, 13.274715, 7.4356117, 9.746176, 12.864445, 7.0854006, 9.161785, 13.339142, 7.0672264, 9.294153, 13.274715, 7.4356117, 9.746176, 12.882765, 7.613677, 10.007544, 12.864445, 7.0854006, 9.161785, 14.450766, 7.1982546, 10.098274, 13.885704, 7.279489, 10.325115, 13.7075405, 7.556304, 10.0746155, 13.885704, 7.279489, 10.325115, 14.450766, 7.1982546, 10.098274, 14.443308, 7.0981884, 10.231224, 13.885704, 7.279489, 10.325115, 14.443308, 7.0981884, 10.231224, 14.063122, 7.117878, 10.426937, 11.658583, 6.90143, 9.442226, 12.092066, 6.8811474, 9.071436, 11.933365, 7.3444357, 9.894124, 11.658583, 6.90143, 9.442226, 11.79088, 6.773697, 9.139975, 12.092066, 6.8811474, 9.071436, 11.422882, 6.7517605, 9.392457, 11.603992, 6.6508856, 9.16963, 11.658583, 6.90143, 9.442226, 11.422882, 6.7517605, 9.392457, 11.497446, 6.636342, 9.199318, 11.603992, 6.6508856, 9.16963, 11.422882, 6.7517605, 9.392457, 11.151068, 6.834375, 9.774982, 11.28582, 6.5728126, 9.250239, 10.868927, 6.502743, 9.566353, 11.28582, 6.5728126, 9.250239, 11.151068, 6.834375, 9.774982, 10.868927, 6.502743, 9.566353, 10.970734, 6.4467087, 9.364567, 11.28582, 6.5728126, 9.250239, 14.070913, 7.495411, 9.618036, 14.482357, 7.125392, 10.12579, 14.3754425, 7.195709, 10.181591, 14.070913, 7.495411, 9.618036, 14.516771, 7.173098, 9.291218, 14.482357, 7.125392, 10.12579, 13.583624, 7.1059093, 9.775603, 13.968065, 7.2646875, 9.861715, 13.816387, 7.1105375, 9.930347, 13.583624, 7.1059093, 9.775603, 14.070913, 7.495411, 9.618036, 13.968065, 7.2646875, 9.861715, 12.772589, 6.376586, 6.9820247, 13.099979, 6.904664, 7.5420585, 12.775024, 6.637413, 7.820493, 12.772589, 6.376586, 6.9820247, 12.839212, 6.4047265, 6.9280143, 13.099979, 6.904664, 7.5420585, 13.099979, 6.904664, 7.5420585, 12.79912, 6.699265, 8.027189, 12.775024, 6.637413, 7.820493, 13.099979, 6.904664, 7.5420585, 13.025673, 6.931345, 7.982105, 12.79912, 6.699265, 8.027189, 13.025673, 6.931345, 7.982105, 13.099979, 6.904664, 7.5420585, 13.384867, 7.3345337, 8.193618, 13.025673, 6.931345, 7.982105, 13.384867, 7.3345337, 8.193618, 12.864683, 6.8550787, 8.580061, 13.025673, 6.931345, 7.982105, 12.864683, 6.8550787, 8.580061, 12.820887, 6.753347, 8.212795, 12.864683, 6.8550787, 8.580061, 13.068455, 7.0369205, 8.661718, 12.906773, 6.8680534, 8.665814, 12.864683, 6.8550787, 8.580061, 13.384867, 7.3345337, 8.193618, 13.068455, 7.0369205, 8.661718, 13.15687, 7.046695, 9.214457, 13.535355, 7.2501936, 9.400614, 13.26737, 7.0566998, 9.358015, 13.15687, 7.046695, 9.214457, 13.745743, 7.551609, 8.966543, 13.535355, 7.2501936, 9.400614, 12.922939, 6.384966, 6.9034257, 13.099979, 6.904664, 7.5420585, 12.839212, 6.4047265, 6.9280143, 13.099979, 6.904664, 7.5420585, 12.922939, 6.384966, 6.9034257, 13.11478, 6.619231, 7.236095, 13.11478, 6.619231, 7.236095, 12.922939, 6.384966, 6.9034257, 13.179651, 6.491927, 7.1159973, 13.099979, 6.904664, 7.5420585, 13.577553, 6.959513, 7.7937083, 13.384867, 7.3345337, 8.193618, 13.099979, 6.904664, 7.5420585, 13.501598, 6.832964, 7.5911536, 13.577553, 6.959513, 7.7937083, 14.043669, 6.938242, 7.9677033, 13.745743, 7.551609, 8.966543, 13.384867, 7.3345337, 8.193618, 13.745743, 7.551609, 8.966543, 14.043669, 6.938242, 7.9677033, 14.200254, 7.008704, 8.294954, 13.745743, 7.551609, 8.966543, 14.200254, 7.008704, 8.294954, 14.049522, 7.2166758, 8.535611, 14.115025, 7.3930063, 9.158257, 14.070913, 7.495411, 9.618036, 13.745743, 7.551609, 8.966543, 14.070913, 7.495411, 9.618036, 14.115025, 7.3930063, 9.158257, 14.474133, 7.170848, 9.13312, 14.070913, 7.495411, 9.618036, 14.474133, 7.170848, 9.13312, 14.516771, 7.173098, 9.291218, 13.745743, 7.551609, 8.966543, 13.068455, 7.0369205, 8.661718, 13.384867, 7.3345337, 8.193618, 13.402321, 7.300085, 8.886555, 13.068455, 7.0369205, 8.661718, 13.745743, 7.551609, 8.966543, 13.068455, 7.0369205, 8.661718, 13.402321, 7.300085, 8.886555, 13.049887, 6.9149427, 8.958126, 13.068455, 7.0369205, 8.661718, 13.049887, 6.9149427, 8.958126, 12.977623, 6.9004726, 8.814155, 13.745743, 7.551609, 8.966543, 13.079823, 7.007822, 9.05171, 13.402321, 7.300085, 8.886555, 13.745743, 7.551609, 8.966543, 13.15687, 7.046695, 9.214457, 13.079823, 7.007822, 9.05171, 14.049522, 7.2166758, 8.535611, 14.337751, 7.1039696, 8.595517, 13.745743, 7.551609, 8.966543, 14.049522, 7.2166758, 8.535611, 14.270794, 7.0704823, 8.454195, 14.337751, 7.1039696, 8.595517, 14.070913, 7.495411, 9.618036, 13.535355, 7.2501936, 9.400614, 13.745743, 7.551609, 8.966543, 13.535355, 7.2501936, 9.400614, 14.070913, 7.495411, 9.618036, 13.583624, 7.1059093, 9.775603, 13.535355, 7.2501936, 9.400614, 13.583624, 7.1059093, 9.775603, 13.427256, 7.094145, 9.573523, 14.115025, 7.3930063, 9.158257, 14.337751, 7.1039696, 8.595517, 14.450401, 7.090761, 9.003662, 14.115025, 7.3930063, 9.158257, 13.745743, 7.551609, 8.966543, 14.337751, 7.1039696, 8.595517, 14.3754425, 7.195709, 10.181591, 13.968065, 7.2646875, 9.861715, 14.070913, 7.495411, 9.618036, 13.968065, 7.2646875, 9.861715, 14.3754425, 7.195709, 10.181591, 14.275515, 7.113496, 10.233852, 13.968065, 7.2646875, 9.861715, 14.275515, 7.113496, 10.233852, 13.9707985, 7.1304455, 10.03817, 13.577553, 6.959513, 7.7937083, 14.043669, 6.938242, 7.9677033, 13.384867, 7.3345337, 8.193618, 13.577553, 6.959513, 7.7937083, 13.853798, 6.849599, 7.7665267, 14.043669, 6.938242, 7.9677033, 13.501598, 6.832964, 7.5911536, 13.736696, 6.747884, 7.6274853, 13.577553, 6.959513, 7.7937083, 13.501598, 6.832964, 7.5911536, 13.669174, 6.7349615, 7.561953, 13.736696, 6.747884, 7.6274853, 13.501598, 6.832964, 7.5911536, 13.099979, 6.904664, 7.5420585, 13.535788, 6.6803236, 7.4230866, 13.11478, 6.619231, 7.236095, 13.535788, 6.6803236, 7.4230866, 13.099979, 6.904664, 7.5420585, 13.11478, 6.619231, 7.236095, 13.306187, 6.5714235, 7.228906, 13.535788, 6.6803236, 7.4230866, 14.818359, 7.5795794, 9.704287, 14.319729, 7.1040306, 10.273739, 14.231741, 7.193877, 10.164581, 14.818359, 7.5795794, 9.704287, 15.293608, 7.170624, 10.134708, 14.319729, 7.1040306, 10.273739, 14.53705, 7.1410728, 9.124798, 14.520617, 7.3097606, 9.621042, 14.407236, 7.130357, 9.437237, 14.53705, 7.1410728, 9.124798, 14.818359, 7.5795794, 9.704287, 14.520617, 7.3097606, 9.621042, 17.50299, 6.374856, 7.3705115, 16.95148, 6.951913, 7.9566584, 16.564781, 6.648938, 7.610077, 17.50299, 6.374856, 7.3705115, 17.578213, 6.4056153, 7.4395566, 16.95148, 6.951913, 7.9566584, 16.95148, 6.951913, 7.9566584, 16.337984, 6.7154865, 7.697535, 16.564781, 6.648938, 7.610077, 16.95148, 6.951913, 7.9566584, 16.44146, 6.978112, 7.98315, 16.337984, 6.7154865, 7.697535, 16.44146, 6.978112, 7.98315, 16.95148, 6.951913, 7.9566584, 16.283978, 7.4333143, 8.50886, 16.44146, 6.978112, 7.98315, 16.283978, 7.4333143, 8.50886, 15.729202, 6.886676, 7.930798, 16.44146, 6.978112, 7.98315, 15.729202, 6.886676, 7.930798, 16.134073, 6.774377, 7.7760997, 15.729202, 6.886676, 7.930798, 15.686707, 7.0922894, 8.219696, 15.639549, 6.8975234, 8.001675, 15.729202, 6.886676, 7.930798, 16.283978, 7.4333143, 8.50886, 15.686707, 7.0922894, 8.219696, 15.082333, 7.0959883, 8.468272, 14.956093, 7.31973, 8.992256, 14.942156, 7.0999613, 8.635822, 15.082333, 7.0959883, 8.468272, 15.490726, 7.667551, 9.160905, 14.956093, 7.31973, 8.992256, 17.624905, 6.3763094, 7.5322666, 16.95148, 6.951913, 7.9566584, 17.578213, 6.4056153, 7.4395566, 16.95148, 6.951913, 7.9566584, 17.624905, 6.3763094, 7.5322666, 17.296997, 6.6220546, 7.8690057, 17.296997, 6.6220546, 7.8690057, 17.624905, 6.3763094, 7.5322666, 17.441767, 6.4720078, 7.9004264, 16.95148, 6.951913, 7.9566584, 16.778732, 6.9787436, 8.60397, 16.283978, 7.4333143, 8.50886, 16.95148, 6.951913, 7.9566584, 16.988663, 6.836954, 8.447882, 16.778732, 6.9787436, 8.60397, 16.685303, 6.91991, 9.205566, 15.490726, 7.667551, 9.160905, 16.283978, 7.4333143, 8.50886, 15.490726, 7.667551, 9.160905, 16.685303, 6.91991, 9.205566, 16.34667, 6.989422, 9.478991, 15.490726, 7.667551, 9.160905, 16.34667, 6.989422, 9.478991, 16.0481, 7.250002, 9.387381, 15.358458, 7.458866, 9.642784, 14.818359, 7.5795794, 9.704287, 15.490726, 7.667551, 9.160905, 14.818359, 7.5795794, 9.704287, 15.358458, 7.458866, 9.642784, 15.46587, 7.1714168, 10.046328, 14.818359, 7.5795794, 9.704287, 15.46587, 7.1714168, 10.046328, 15.293608, 7.170624, 10.134708, 15.490726, 7.667551, 9.160905, 15.686707, 7.0922894, 8.219696, 16.283978, 7.4333143, 8.50886, 15.509426, 7.387882, 8.707602, 15.686707, 7.0922894, 8.219696, 15.490726, 7.667551, 9.160905, 15.686707, 7.0922894, 8.219696, 15.509426, 7.387882, 8.707602, 15.334177, 6.938472, 8.243979, 15.686707, 7.0922894, 8.219696, 15.334177, 6.938472, 8.243979, 15.486206, 6.9294643, 8.125624, 15.490726, 7.667551, 9.160905, 15.250263, 7.0548754, 8.33129, 15.509426, 7.387882, 8.707602, 15.490726, 7.667551, 9.160905, 15.082333, 7.0959883, 8.468272, 15.250263, 7.0548754, 8.33129, 16.0481, 7.250002, 9.387381, 16.040924, 7.0956764, 9.734488, 15.490726, 7.667551, 9.160905, 16.0481, 7.250002, 9.387381, 16.186634, 7.0604343, 9.615782, 16.040924, 7.0956764, 9.734488, 14.818359, 7.5795794, 9.704287, 14.956093, 7.31973, 8.992256, 15.490726, 7.667551, 9.160905, 14.956093, 7.31973, 8.992256, 14.818359, 7.5795794, 9.704287, 14.53705, 7.1410728, 9.124798, 14.956093, 7.31973, 8.992256, 14.53705, 7.1410728, 9.124798, 14.7350445, 7.1373453, 8.888584, 15.358458, 7.458866, 9.642784, 16.040924, 7.0956764, 9.734488, 15.592907, 7.0656147, 9.958109, 15.358458, 7.458866, 9.642784, 15.490726, 7.667551, 9.160905, 16.040924, 7.0956764, 9.734488, 14.231741, 7.193877, 10.164581, 14.520617, 7.3097606, 9.621042, 14.818359, 7.5795794, 9.704287, 14.520617, 7.3097606, 9.621042, 14.231741, 7.193877, 10.164581, 14.1518955, 7.1016655, 10.051222, 14.520617, 7.3097606, 9.621042, 14.1518955, 7.1016655, 10.051222, 14.318668, 7.147036, 9.652245, 16.778732, 6.9787436, 8.60397, 16.685303, 6.91991, 9.205566, 16.283978, 7.4333143, 8.50886, 16.778732, 6.9787436, 8.60397, 16.869965, 6.8293467, 8.917667, 16.685303, 6.91991, 9.205566, 16.988663, 6.836954, 8.447882, 16.991056, 6.717228, 8.721294, 16.778732, 6.9787436, 8.60397, 16.988663, 6.836954, 8.447882, 17.05367, 6.7085714, 8.62688, 16.991056, 6.717228, 8.721294, 16.988663, 6.836954, 8.447882, 16.95148, 6.951913, 7.9566584, 17.18201, 6.656856, 8.4283905, 17.296997, 6.6220546, 7.8690057, 17.18201, 6.656856, 8.4283905, 16.95148, 6.951913, 7.9566584, 17.296997, 6.6220546, 7.8690057, 17.347916, 6.550868, 8.092527, 17.18201, 6.656856, 8.4283905, 14.640917, 7.231346, 10.77033, 14.15225, 7.201992, 10.013128, 14.311281, 7.246917, 9.992706, 14.640917, 7.231346, 10.77033, 13.950329, 6.9789553, 10.945974, 14.15225, 7.201992, 10.013128, 15.086668, 6.645689, 10.496582, 14.715862, 7.027137, 10.425049, 14.856575, 6.812086, 10.301319, 15.086668, 6.645689, 10.496582, 14.640917, 7.231346, 10.77033, 14.715862, 7.027137, 10.425049, 15.405359, 4.506928, 13.294855, 15.268087, 5.398923, 12.871037, 15.5953045, 5.062126, 12.496258, 15.405359, 4.506928, 13.294855, 15.330811, 4.5490294, 13.357082, 15.268087, 5.398923, 12.871037, 15.268087, 5.398923, 12.871037, 15.614843, 5.2105064, 12.296331, 15.5953045, 5.062126, 12.496258, 15.268087, 5.398923, 12.871037, 15.418043, 5.546952, 12.416988, 15.614843, 5.2105064, 12.296331, 15.418043, 5.546952, 12.416988, 15.268087, 5.398923, 12.871037, 15.162661, 6.218977, 12.31654, 15.418043, 5.546952, 12.416988, 15.162661, 6.218977, 12.31654, 15.6678505, 5.60172, 11.757086, 15.418043, 5.546952, 12.416988, 15.6678505, 5.60172, 11.757086, 15.632406, 5.343074, 12.11624, 15.6678505, 5.60172, 11.757086, 15.4920025, 5.9232817, 11.72563, 15.641216, 5.6641035, 11.663549, 15.6678505, 5.60172, 11.757086, 15.162661, 6.218977, 12.31654, 15.4920025, 5.9232817, 11.72563, 15.469013, 6.1774178, 11.125845, 15.1226845, 6.643654, 10.969555, 15.370789, 6.2906885, 10.961456, 15.469013, 6.1774178, 11.125845, 14.925304, 6.9021482, 11.535374, 15.1226845, 6.643654, 10.969555, 15.222748, 4.557292, 13.366293, 15.268087, 5.398923, 12.871037, 15.330811, 4.5490294, 13.357082, 15.268087, 5.398923, 12.871037, 15.222748, 4.557292, 13.366293, 15.117676, 4.9987955, 13.081551, 15.117676, 4.9987955, 13.081551, 15.222748, 4.557292, 13.366293, 14.994282, 4.8528996, 13.149015, 15.268087, 5.398923, 12.871037, 14.755228, 5.765085, 12.572195, 15.162661, 6.218977, 12.31654, 15.268087, 5.398923, 12.871037, 14.77644, 5.523675, 12.745871, 14.755228, 5.765085, 12.572195, 14.232555, 6.018609, 12.3237705, 14.925304, 6.9021482, 11.535374, 15.162661, 6.218977, 12.31654, 14.925304, 6.9021482, 11.535374, 14.232555, 6.018609, 12.3237705, 14.130428, 6.281682, 11.9799385, 14.925304, 6.9021482, 11.535374, 14.130428, 6.281682, 11.9799385, 14.391569, 6.5280485, 11.829561, 14.462694, 6.9798803, 11.226421, 14.640917, 7.231346, 10.77033, 14.925304, 6.9021482, 11.535374, 14.640917, 7.231346, 10.77033, 14.462694, 6.9798803, 11.226421, 13.968893, 6.899996, 11.121994, 14.640917, 7.231346, 10.77033, 13.968893, 6.899996, 11.121994, 13.950329, 6.9789553, 10.945974, 14.925304, 6.9021482, 11.535374, 15.4920025, 5.9232817, 11.72563, 15.162661, 6.218977, 12.31654, 15.217165, 6.445463, 11.55835, 15.4920025, 5.9232817, 11.72563, 14.925304, 6.9021482, 11.535374, 15.4920025, 5.9232817, 11.72563, 15.217165, 6.445463, 11.55835, 15.550241, 5.8804913, 11.346338, 15.4920025, 5.9232817, 11.72563, 15.550241, 5.8804913, 11.346338, 15.594999, 5.783677, 11.508028, 14.925304, 6.9021482, 11.535374, 15.519932, 6.0406504, 11.294071, 15.217165, 6.445463, 11.55835, 14.925304, 6.9021482, 11.535374, 15.469013, 6.1774178, 11.125845, 15.519932, 6.0406504, 11.294071, 14.391569, 6.5280485, 11.829561, 14.036055, 6.5608177, 11.683962, 14.925304, 6.9021482, 11.535374, 14.391569, 6.5280485, 11.829561, 14.080282, 6.442515, 11.830221, 14.036055, 6.5608177, 11.683962, 14.640917, 7.231346, 10.77033, 15.1226845, 6.643654, 10.969555, 14.925304, 6.9021482, 11.535374, 15.1226845, 6.643654, 10.969555, 14.640917, 7.231346, 10.77033, 15.086668, 6.645689, 10.496582, 15.1226845, 6.643654, 10.969555, 15.086668, 6.645689, 10.496582, 15.225153, 6.48761, 10.729311, 14.462694, 6.9798803, 11.226421, 14.036055, 6.5608177, 11.683962, 13.989519, 6.7390223, 11.216955, 14.462694, 6.9798803, 11.226421, 14.925304, 6.9021482, 11.535374, 14.036055, 6.5608177, 11.683962, 14.311281, 7.246917, 9.992706, 14.715862, 7.027137, 10.425049, 14.640917, 7.231346, 10.77033, 14.715862, 7.027137, 10.425049, 14.311281, 7.246917, 9.992706, 14.403456, 7.132376, 9.914658, 14.715862, 7.027137, 10.425049, 14.403456, 7.132376, 9.914658, 14.700848, 6.947154, 10.176794, 14.755228, 5.765085, 12.572195, 14.232555, 6.018609, 12.3237705, 15.162661, 6.218977, 12.31654, 14.755228, 5.765085, 12.572195, 14.395441, 5.764677, 12.524948, 14.232555, 6.018609, 12.3237705, 14.77644, 5.523675, 12.745871, 14.502789, 5.5489907, 12.638502, 14.755228, 5.765085, 12.572195, 14.77644, 5.523675, 12.745871, 14.557829, 5.4834146, 12.714375, 14.502789, 5.5489907, 12.638502, 14.77644, 5.523675, 12.745871, 15.268087, 5.398923, 12.871037, 14.671074, 5.3173504, 12.858131, 15.117676, 4.9987955, 13.081551, 14.671074, 5.3173504, 12.858131, 15.268087, 5.398923, 12.871037, 15.117676, 4.9987955, 13.081551, 14.877726, 5.0318, 13.048248, 14.671074, 5.3173504, 12.858131, 15.855707, 1.5785854, 8.303576, 15.537302, 3.513335, 8.969594, 15.675556, 3.59141, 8.79463, 15.855707, 1.5785854, 8.303576, 15.684317, 1.4956186, 8.515871, 15.537302, 3.513335, 8.969594, 15.718592, -0.6945374, 7.979791, 15.684317, 1.4956186, 8.515871, 15.855707, 1.5785854, 8.303576, 15.718592, -0.6945374, 7.979791, 15.528423, -0.7451041, 8.231567, 15.684317, 1.4956186, 8.515871, 15.190344, 1.4642165, 8.406557, 15.070126, 3.5373333, 8.645098, 15.119511, 3.4761071, 8.8678665, 15.190344, 1.4642165, 8.406557, 15.135492, 1.5326246, 8.145638, 15.070126, 3.5373333, 8.645098, 15.578283, 1.6655294, 7.8817935, 15.624922, 3.6523986, 8.571777, 15.435907, 3.6665154, 8.430279, 15.578283, 1.6655294, 7.8817935, 15.805153, 1.6476903, 8.039993, 15.624922, 3.6523986, 8.571777, 15.119511, 3.4761071, 8.8678665, 14.720741, 5.3593645, 9.339607, 14.75539, 5.3158965, 9.486606, 15.119511, 3.4761071, 8.8678665, 15.070126, 3.5373333, 8.645098, 14.720741, 5.3593645, 9.339607, 15.675556, 3.59141, 8.79463, 15.031363, 5.349116, 9.558678, 15.124321, 5.4062643, 9.4429035, 15.675556, 3.59141, 8.79463, 15.537302, 3.513335, 8.969594, 15.031363, 5.349116, 9.558678, 15.124321, 5.4062643, 9.4429035, 15.031363, 5.349116, 9.558678, 14.700575, 6.30957, 9.911303, 15.124321, 5.4062643, 9.4429035, 14.700575, 6.30957, 9.911303, 14.765755, 6.3641186, 9.829805, 15.684317, 1.4956186, 8.515871, 15.309, 3.4624698, 9.008275, 15.537302, 3.513335, 8.969594, 15.684317, 1.4956186, 8.515871, 15.412914, 1.4456806, 8.567421, 15.309, 3.4624698, 9.008275, 15.537302, 3.513335, 8.969594, 15.309, 3.4624698, 9.008275, 14.880146, 5.309407, 9.581961, 15.537302, 3.513335, 8.969594, 14.880146, 5.309407, 9.581961, 15.031363, 5.349116, 9.558678, 15.135492, 1.5326246, 8.145638, 15.208366, 3.6164234, 8.469492, 15.070126, 3.5373333, 8.645098, 15.135492, 1.5326246, 8.145638, 15.30286, 1.6151665, 7.9359193, 15.208366, 3.6164234, 8.469492, 15.070126, 3.5373333, 8.645098, 15.208366, 3.6164234, 8.469492, 14.811323, 5.4180756, 9.224138, 15.070126, 3.5373333, 8.645098, 14.811323, 5.4180756, 9.224138, 14.720741, 5.3593645, 9.339607, 15.435907, 3.6665154, 8.430279, 15.090517, 5.4490795, 9.293634, 14.963499, 5.456666, 9.198397, 15.435907, 3.6665154, 8.430279, 15.624922, 3.6523986, 8.571777, 15.090517, 5.4490795, 9.293634, 15.805153, 1.6476903, 8.039993, 15.675556, 3.59141, 8.79463, 15.624922, 3.6523986, 8.571777, 15.805153, 1.6476903, 8.039993, 15.855707, 1.5785854, 8.303576, 15.675556, 3.59141, 8.79463, 15.624922, 3.6523986, 8.571777, 15.124321, 5.4062643, 9.4429035, 15.090517, 5.4490795, 9.293634, 15.624922, 3.6523986, 8.571777, 15.675556, 3.59141, 8.79463, 15.124321, 5.4062643, 9.4429035, 15.30286, 1.6151665, 7.9359193, 15.435907, 3.6665154, 8.430279, 15.208366, 3.6164234, 8.469492, 15.30286, 1.6151665, 7.9359193, 15.578283, 1.6655294, 7.8817935, 15.435907, 3.6665154, 8.430279, 15.080032, -0.60457873, 7.549851, 15.578283, 1.6655294, 7.8817935, 15.30286, 1.6151665, 7.9359193, 15.080032, -0.60457873, 7.549851, 15.392487, -0.5923369, 7.483996, 15.578283, 1.6655294, 7.8817935, 15.208366, 3.6164234, 8.469492, 14.963499, 5.456666, 9.198397, 14.811323, 5.4180756, 9.224138, 15.208366, 3.6164234, 8.469492, 15.435907, 3.6665154, 8.430279, 14.963499, 5.456666, 9.198397, 14.811323, 5.4180756, 9.224138, 14.963499, 5.456666, 9.198397, 14.646309, 6.401553, 9.648203, 14.811323, 5.4180756, 9.224138, 14.646309, 6.401553, 9.648203, 14.5366535, 6.3609424, 9.662085, 14.498898, 6.2705493, 9.8520975, 14.315634, 6.982005, 10.216946, 14.590864, 6.2697954, 9.924797, 14.498898, 6.2705493, 9.8520975, 14.248718, 6.9810524, 10.163109, 14.315634, 6.982005, 10.216946, 14.700575, 6.30957, 9.911303, 14.437746, 7.065465, 10.1538315, 14.765755, 6.3641186, 9.829805, 14.700575, 6.30957, 9.911303, 14.392586, 7.019636, 10.210747, 14.437746, 7.065465, 10.1538315, 14.590864, 6.2697954, 9.924797, 14.392586, 7.019636, 10.210747, 14.700575, 6.30957, 9.911303, 14.590864, 6.2697954, 9.924797, 14.315634, 6.982005, 10.216946, 14.392586, 7.019636, 10.210747, 14.73908, 6.400427, 9.720892, 14.35066, 7.092757, 10.021431, 14.646309, 6.401553, 9.648203, 14.73908, 6.400427, 9.720892, 14.416679, 7.0957565, 10.076281, 14.35066, 7.092757, 10.021431, 14.765755, 6.3641186, 9.829805, 14.416679, 7.0957565, 10.076281, 14.73908, 6.400427, 9.720892, 14.765755, 6.3641186, 9.829805, 14.437746, 7.065465, 10.1538315, 14.416679, 7.0957565, 10.076281, 14.5366535, 6.3609424, 9.662085, 14.22855, 7.0092974, 10.084545, 14.471951, 6.307063, 9.744005, 14.5366535, 6.3609424, 9.662085, 14.27281, 7.057174, 10.028643, 14.22855, 7.0092974, 10.084545, 14.646309, 6.401553, 9.648203, 14.27281, 7.057174, 10.028643, 14.5366535, 6.3609424, 9.662085, 14.646309, 6.401553, 9.648203, 14.35066, 7.092757, 10.021431, 14.27281, 7.057174, 10.028643, 15.214357, -0.7575648, 8.298452, 15.190344, 1.4642165, 8.406557, 15.412914, 1.4456806, 8.567421, 15.214357, -0.7575648, 8.298452, 14.957146, -0.7182157, 8.110065, 15.190344, 1.4642165, 8.406557, 14.471951, 6.307063, 9.744005, 14.248718, 6.9810524, 10.163109, 14.498898, 6.2705493, 9.8520975, 14.471951, 6.307063, 9.744005, 14.22855, 7.0092974, 10.084545, 14.248718, 6.9810524, 10.163109, 15.412914, 1.4456806, 8.567421, 15.119511, 3.4761071, 8.8678665, 15.309, 3.4624698, 9.008275, 15.412914, 1.4456806, 8.567421, 15.190344, 1.4642165, 8.406557, 15.119511, 3.4761071, 8.8678665, 14.957146, -0.7182157, 8.110065, 15.135492, 1.5326246, 8.145638, 15.190344, 1.4642165, 8.406557, 14.957146, -0.7182157, 8.110065, 14.888254, -0.6553638, 7.8026576, 15.135492, 1.5326246, 8.145638, 15.392487, -0.5923369, 7.483996, 15.805153, 1.6476903, 8.039993, 15.578283, 1.6655294, 7.8817935, 15.392487, -0.5923369, 7.483996, 15.648527, -0.6318462, 7.673121, 15.805153, 1.6476903, 8.039993, 15.528423, -0.7451041, 8.231567, 15.412914, 1.4456806, 8.567421, 15.684317, 1.4956186, 8.515871, 15.528423, -0.7451041, 8.231567, 15.214357, -0.7575648, 8.298452, 15.412914, 1.4456806, 8.567421, 14.888254, -0.6553638, 7.8026576, 15.30286, 1.6151665, 7.9359193, 15.135492, 1.5326246, 8.145638, 14.888254, -0.6553638, 7.8026576, 15.080032, -0.60457873, 7.549851, 15.30286, 1.6151665, 7.9359193, 15.648527, -0.6318462, 7.673121, 15.855707, 1.5785854, 8.303576, 15.805153, 1.6476903, 8.039993, 15.648527, -0.6318462, 7.673121, 15.718592, -0.6945374, 7.979791, 15.855707, 1.5785854, 8.303576, 15.309, 3.4624698, 9.008275, 14.75539, 5.3158965, 9.486606, 14.880146, 5.309407, 9.581961, 15.309, 3.4624698, 9.008275, 15.119511, 3.4761071, 8.8678665, 14.75539, 5.3158965, 9.486606, 14.880146, 5.309407, 9.581961, 14.498898, 6.2705493, 9.8520975, 14.590864, 6.2697954, 9.924797, 14.880146, 5.309407, 9.581961, 14.75539, 5.3158965, 9.486606, 14.498898, 6.2705493, 9.8520975, 14.75539, 5.3158965, 9.486606, 14.471951, 6.307063, 9.744005, 14.498898, 6.2705493, 9.8520975, 14.75539, 5.3158965, 9.486606, 14.720741, 5.3593645, 9.339607, 14.471951, 6.307063, 9.744005, 15.031363, 5.349116, 9.558678, 14.590864, 6.2697954, 9.924797, 14.700575, 6.30957, 9.911303, 15.031363, 5.349116, 9.558678, 14.880146, 5.309407, 9.581961, 14.590864, 6.2697954, 9.924797, 14.963499, 5.456666, 9.198397, 14.73908, 6.400427, 9.720892, 14.646309, 6.401553, 9.648203, 14.963499, 5.456666, 9.198397, 15.090517, 5.4490795, 9.293634, 14.73908, 6.400427, 9.720892, 15.090517, 5.4490795, 9.293634, 14.765755, 6.3641186, 9.829805, 14.73908, 6.400427, 9.720892, 15.090517, 5.4490795, 9.293634, 15.124321, 5.4062643, 9.4429035, 14.765755, 6.3641186, 9.829805, 14.720741, 5.3593645, 9.339607, 14.5366535, 6.3609424, 9.662085, 14.471951, 6.307063, 9.744005, 14.720741, 5.3593645, 9.339607, 14.811323, 5.4180756, 9.224138, 14.5366535, 6.3609424, 9.662085, -18.8297, 3.3895998, -9.473499, -19.616299, 3.2075999, -9.711899, -19.1775, 3.1662998, -9.7628, -18.8297, 3.3895998, -9.473499, -19.3743, 3.391, -9.417299, -19.616299, 3.2075999, -9.711899, -19.3743, 3.391, -9.417299, -19.611, 3.187, -9.246899, -19.616299, 3.2075999, -9.711899, 10.0335, 3.3913999, -10.0343, 13.378099, 3.3913999, -13.378599, 13.378099, 3.3913999, -10.0343, 13.378099, 3.3913999, -13.378599, 16.722599, 3.3913999, -10.0343, 13.378099, 3.3913999, -10.0343, 13.378099, 3.3913999, -6.6894, 13.378099, 3.3913999, -10.0343, 16.722599, 3.3913999, -10.0343, 13.378099, 3.3913999, -6.6894, 16.722599, 3.3913999, -10.0343, 16.722599, 3.3913999, -6.6894, 10.0335, 3.3913999, -6.6894, 13.378099, 3.3913999, -10.0343, 13.378099, 3.3913999, -6.6894, 10.0335, 3.3913999, -6.6894, 10.0335, 3.3913999, -10.0343, 13.378099, 3.3913999, -10.0343, 16.722599, 3.3913999, -10.0343, 16.4746, 3.3913999, -13.3993, 16.722599, 3.3913999, -13.109999, 16.722599, 3.3913999, -10.0343, 13.378099, 3.3913999, -13.378599, 16.4746, 3.3913999, -13.3993, 16.4746, 3.3913999, -13.3993, 15.5536995, 3.3913999, -16.3177, 16.4952, 3.3913999, -15.5392, 16.4746, 3.3913999, -13.3993, 12.7383995, 3.3913999, -15.8953, 15.5536995, 3.3913999, -16.3177, 12.7383995, 3.3913999, -15.8953, 16.4746, 3.3913999, -13.3993, 13.378099, 3.3913999, -13.378599, 13.378099, 3.3913999, -13.378599, 12.460499, 3.3913999, -16.1674, 12.7383995, 3.3913999, -15.8953, 13.378099, 3.3913999, -13.378599, 10.0335, 3.3913999, -16.7235, 12.460499, 3.3913999, -16.1674, 10.0335, 3.3913999, -16.7235, 13.378099, 3.3913999, -13.378599, 10.0335, 3.3913999, -13.378599, 10.0335, 3.3913999, -10.0343, 10.0335, 3.3913999, -13.378599, 13.378099, 3.3913999, -13.378599, 6.6889997, 3.3913999, -13.378599, 10.0335, 3.3913999, -16.7235, 10.0335, 3.3913999, -13.378599, 6.6889997, 3.3913999, -13.378599, 6.6889997, 3.3913999, -16.7235, 10.0335, 3.3913999, -16.7235, 6.6889997, 3.3913999, -10.0343, 10.0335, 3.3913999, -13.378599, 10.0335, 3.3913999, -10.0343, 6.6889997, 3.3913999, -10.0343, 6.6889997, 3.3913999, -13.378599, 10.0335, 3.3913999, -13.378599, 4.6816, 3.3913999, -13.378599, 6.6889997, 3.3913999, -16.7235, 6.6889997, 3.3913999, -13.378599, 4.6816, 3.3913999, -13.378599, 3.8384998, 3.3913999, -16.7235, 6.6889997, 3.3913999, -16.7235, 4.5101, 3.3913999, -8.955799, 4.6816, 3.3913999, -13.378599, 6.6889997, 3.3913999, -13.378599, 4.5101, 3.3913999, -8.955799, 6.6889997, 3.3913999, -13.378599, 6.6889997, 3.3913999, -10.0343, 4.6816, 3.3913999, -13.378599, 4.5101, 3.3913999, -8.955799, 4.1105, 3.3913999, -9.3386, 6.6889997, 3.3913999, -6.6894, 6.6889997, 3.3913999, -10.0343, 10.0335, 3.3913999, -10.0343, 6.6889997, 3.3913999, -6.6894, 4.5101, 3.3913999, -8.955799, 6.6889997, 3.3913999, -10.0343, 6.6889997, 3.3913999, -6.6894, 10.0335, 3.3913999, -10.0343, 10.0335, 3.3913999, -6.6894, 4.1105, 3.3913999, -9.3386, 4.5101, 3.3913999, -8.955799, 3.8492, 3.3913999, -8.198, 6.6889997, 3.3913999, -6.6894, 3.8492, 3.3913999, -8.198, 4.5101, 3.3913999, -8.955799, 4.1105, 3.3913999, -9.3386, 3.8492, 3.3913999, -8.198, 3.4176, 3.3913999, -8.714399, 3.3444998, 3.3913999, -6.6894, 3.4176, 3.3913999, -8.714399, 3.8492, 3.3913999, -8.198, 3.8492, 3.3913999, -8.198, 6.6889997, 3.3913999, -6.6894, 3.3444998, 3.3913999, -6.6894, 3.3444998, 3.3913999, -6.6894, 0, 3.3913999, -8.714399, 3.4176, 3.3913999, -8.714399, 6.6889997, 3.3913999, -3.3451, 6.6889997, 3.3913999, -6.6894, 10.0335, 3.3913999, -6.6894, 6.6889997, 3.3913999, -3.3451, 10.0335, 3.3913999, -6.6894, 10.0335, 3.3913999, -3.3451, 3.3444998, 3.3913999, -3.3451, 3.3444998, 3.3913999, -6.6894, 6.6889997, 3.3913999, -6.6894, 3.3444998, 3.3913999, -3.3451, 6.6889997, 3.3913999, -6.6894, 6.6889997, 3.3913999, -3.3451, 0, 3.3913999, -8.714399, 3.3444998, 3.3913999, -6.6894, 0, 3.3913999, -6.6894, 0, 3.3913999, -6.6894, -3.5373998, 3.3913999, -9.750299, 0, 3.3913999, -8.714399, 0, 3.3913999, -3.3451, 3.3444998, 3.3913999, -6.6894, 3.3444998, 3.3913999, -3.3451, 0, 3.3913999, -3.3451, 0, 3.3913999, -6.6894, 3.3444998, 3.3913999, -6.6894, 3.3444998, 3.3913999, -0.000100016594, 3.3444998, 3.3913999, -3.3451, 6.6889997, 3.3913999, -3.3451, 3.3444998, 3.3913999, -0.000100016594, 6.6889997, 3.3913999, -3.3451, 6.6889997, 3.3913999, -0.000100016594, 3.3444998, 3.3913999, 3.3442, 3.3444998, 3.3913999, -0.000100016594, 6.6889997, 3.3913999, -0.000100016594, 3.3444998, 3.3913999, 3.3442, 6.6889997, 3.3913999, -0.000100016594, 6.6889997, 3.3913999, 3.3442, 0, 3.3913999, 3.3442, 3.3444998, 3.3913999, -0.000100016594, 3.3444998, 3.3913999, 3.3442, 3.3444998, 3.3913999, 6.6891, 3.3444998, 3.3913999, 3.3442, 6.6889997, 3.3913999, 3.3442, 0, 3.3913999, 3.3442, 0, 3.3913999, -0.000100016594, 3.3444998, 3.3913999, -0.000100016594, 0, 3.3913999, -0.000100016594, 3.3444998, 3.3913999, -3.3451, 3.3444998, 3.3913999, -0.000100016594, 0, 3.3913999, -0.000100016594, 0, 3.3913999, -3.3451, 3.3444998, 3.3913999, -3.3451, -2.9569001, 3.3913999, 3.3442, 0, 3.3913999, -0.000100016594, 0, 3.3913999, 3.3442, -2.9569001, 3.3913999, 3.3442, -2.9569001, 3.3913999, -0.000100016594, 0, 3.3913999, -0.000100016594, -2.9569001, 3.3913999, -0.000100016594, 0, 3.3913999, -3.3451, 0, 3.3913999, -0.000100016594, -2.9569001, 3.3913999, -0.000100016594, -3.6383996, 3.3913999, -2.0795999, 0, 3.3913999, -3.3451, -3.6383996, 3.3913999, -2.0795999, 0, 3.3913999, -6.6894, 0, 3.3913999, -3.3451, -3.6383996, 3.3913999, -2.0795999, -3.3452, 3.3913999, -6.6894, 0, 3.3913999, -6.6894, -6.6896996, 3.3913999, -3.7331, -3.3452, 3.3913999, -6.6894, -3.6383996, 3.3913999, -2.0795999, -3.8484998, 3.3913999, -9.5107, 0, 3.3913999, -6.6894, -3.3452, 3.3913999, -6.6894, 0, 3.3913999, -6.6894, -3.8484998, 3.3913999, -9.5107, -3.5373998, 3.3913999, -9.750299, -3.5373998, 3.3913999, -9.750299, -3.8484998, 3.3913999, -9.5107, -4.2641, 3.3913999, -9.881699, -3.3452, 3.3913999, -6.6894, -4.2641, 3.3913999, -9.881699, -3.8484998, 3.3913999, -9.5107, -3.5373998, 3.3913999, -9.750299, -4.2641, 3.3913999, -9.881699, -3.9104, 3.3913999, -10.240799, -4.2641, 3.3913999, -9.881699, -3.9104, 3.3913999, -12.1610985, -3.9104, 3.3913999, -10.240799, -6.6896996, 3.3913999, -3.7331, -6.6896996, 3.3913999, -6.6894, -3.3452, 3.3913999, -6.6894, -10.034201, 3.3913999, -3.7331, -6.6896996, 3.3913999, -6.6894, -6.6896996, 3.3913999, -3.7331, -3.3452, 3.3913999, -6.6894, -6.6896996, 3.3913999, -10.0343, -4.2641, 3.3913999, -9.881699, -6.6896996, 3.3913999, -10.0343, -3.3452, 3.3913999, -6.6894, -6.6896996, 3.3913999, -6.6894, -4.2641, 3.3913999, -9.881699, -6.6896996, 3.3913999, -12.5385, -3.9104, 3.3913999, -12.1610985, -6.6896996, 3.3913999, -12.5385, -4.2641, 3.3913999, -9.881699, -6.6896996, 3.3913999, -10.0343, -3.9104, 3.3913999, -12.1610985, -6.6896996, 3.3913999, -12.5385, -4.2794, 3.3913999, -12.5385, -10.034201, 3.3913999, -3.7331, -10.034201, 3.3913999, -6.6894, -6.6896996, 3.3913999, -6.6894, -10.034201, 3.3913999, -6.6894, -6.6896996, 3.3913999, -10.0343, -6.6896996, 3.3913999, -6.6894, -13.378799, 3.3913999, -3.7331, -10.034201, 3.3913999, -6.6894, -10.034201, 3.3913999, -3.7331, -10.034201, 3.3913999, -10.0343, -6.6896996, 3.3913999, -12.5385, -6.6896996, 3.3913999, -10.0343, -10.034201, 3.3913999, -6.6894, -10.034201, 3.3913999, -10.0343, -6.6896996, 3.3913999, -10.0343, -10.034201, 3.3913999, -10.0343, -10.6998005, 3.3913999, -13.1763, -6.6896996, 3.3913999, -12.5385, -13.378799, 3.3913999, -3.7331, -13.378799, 3.3913999, -6.6894, -10.034201, 3.3913999, -6.6894, -13.378799, 3.3913999, -6.6894, -10.034201, 3.3913999, -10.0343, -10.034201, 3.3913999, -6.6894, -16.192, 3.3913999, -3.0972, -13.378799, 3.3913999, -6.6894, -13.378799, 3.3913999, -3.7331, -13.378799, 3.3913999, -10.0343, -10.6998005, 3.3913999, -13.1763, -10.034201, 3.3913999, -10.0343, -13.378799, 3.3913999, -6.6894, -13.378799, 3.3913999, -10.0343, -10.034201, 3.3913999, -10.0343, -13.378799, 3.3913999, -10.0343, -13.378799, 3.3913999, -13.378599, -10.6998005, 3.3913999, -13.1763, -16.192, 3.3913999, -3.0972, -16.723299, 3.3913999, -6.6894, -13.378799, 3.3913999, -6.6894, -19.754599, 3.3913999, -2.7854, -16.723299, 3.3913999, -6.6894, -16.192, 3.3913999, -3.0972, -19.754599, 3.3913999, -2.7854, -19.2812, 3.3913999, -6.6894, -16.723299, 3.3913999, -6.6894, -17.111599, 3.3913999, -0.000100016594, -19.754599, 3.3913999, -2.7854, -16.192, 3.3913999, -3.0972, -17.111599, 3.3913999, -0.000100016594, -19.9515, 3.3913999, -2.5836997, -19.754599, 3.3913999, -2.7854, -19.9515, 3.3913999, -2.5836997, -17.111599, 3.3913999, -0.000100016594, -20.068499, 3.3913999, -0.000100016594, -20.068499, 3.3913999, 3.3442, -20.068499, 3.3913999, -0.000100016594, -17.111599, 3.3913999, -0.000100016594, -20.068499, 3.3913999, 3.3442, -17.111599, 3.3913999, -0.000100016594, -17.111599, 3.3913999, 3.304, -20.068499, 3.3913999, 6.6891, -20.068499, 3.3913999, 3.3442, -17.111599, 3.3913999, 3.304, -22.332499, 3.3913999, 8.990399, -22.315199, 3.3913999, 6.6891, -20.068499, 3.3913999, 6.6891, -22.332499, 3.3913999, 8.990399, -20.068499, 3.3913999, 6.6891, -20.068499, 3.3913999, 9.1673, -20.068499, 3.3913999, 9.1673, -20.068499, 3.3913999, 6.6891, -16.723299, 3.3913999, 6.6891, -20.068499, 3.3913999, 6.6891, -17.111599, 3.3913999, 3.304, -16.723299, 3.3913999, 6.6891, -20.068499, 3.3913999, 9.1673, -16.723299, 3.3913999, 6.6891, -16.285099, 3.3913999, 9.5862, -16.723299, 3.3913999, 6.6891, -16.0544, 3.3913999, 9.808, -16.285099, 3.3913999, 9.5862, -16.0544, 3.3913999, 9.808, -16.723299, 3.3913999, 6.6891, -13.136101, 3.3913999, 6.7548, -17.111599, 3.3913999, 3.304, -13.136101, 3.3913999, 6.7548, -16.723299, 3.3913999, 6.6891, -16.0544, 3.3913999, 9.808, -13.136101, 3.3913999, 6.7548, -13.378799, 3.3913999, 10.0334, -13.378799, 3.3913999, 10.0334, -13.136101, 3.3913999, 6.7548, -10.034201, 3.3913999, 7.0772, -13.378799, 3.3913999, 10.0334, -10.034201, 3.3913999, 7.0772, -10.034201, 3.3913999, 10.0334, -13.136101, 3.3913999, 6.7548, -17.111599, 3.3913999, 3.304, -14.877501, 3.3913999, 4.6651998, -14.877501, 3.3913999, 4.6651998, -17.111599, 3.3913999, 3.304, -15.5823, 3.3913999, 4.0364, -13.136101, 3.3913999, 6.7548, -14.877501, 3.3913999, 4.6651998, -14.0949, 3.3913999, 5.5070996, 3.3444998, 3.3913999, 10.0334, 6.6889997, 3.3913999, 6.6891, 6.6889997, 3.3913999, 10.0334, 0, 3.3913999, 13.3783, 0, 3.3913999, 10.0334, 3.3444998, 3.3913999, 10.0334, 0, 3.3913999, 13.3783, 3.3444998, 3.3913999, 10.0334, 3.3444998, 3.3913999, 13.3783, 3.3444998, 3.3913999, 13.3783, 3.3444998, 3.3913999, 10.0334, 6.6889997, 3.3913999, 10.0334, 3.3444998, 3.3913999, 10.0334, 3.3444998, 3.3913999, 6.6891, 6.6889997, 3.3913999, 6.6891, 3.3444998, 3.3913999, 6.6891, 6.6889997, 3.3913999, 3.3442, 6.6889997, 3.3913999, 6.6891, 6.6889997, 3.3913999, 6.6891, 6.6889997, 3.3913999, 3.3442, 10.0335, 3.3913999, 4.2546997, 6.6889997, 3.3913999, 6.6891, 10.0335, 3.3913999, 4.2546997, 10.0335, 3.3913999, 6.6891, 6.6889997, 3.3913999, 10.0334, 6.6889997, 3.3913999, 6.6891, 10.0335, 3.3913999, 6.6891, 6.6889997, 3.3913999, 10.0334, 10.0335, 3.3913999, 6.6891, 10.0335, 3.3913999, 10.0334, 3.3444998, 3.3913999, 13.3783, 6.6889997, 3.3913999, 10.0334, 6.6889997, 3.3913999, 13.3783, 3.9143, 3.3913999, 16.5144, 3.3444998, 3.3913999, 13.3783, 6.6889997, 3.3913999, 13.3783, 3.3444998, 3.3913999, 13.3783, 3.9143, 3.3913999, 16.5144, 3.6397, 3.3913999, 16.2405, 3.9143, 3.3913999, 16.5144, 6.6889997, 3.3913999, 13.3783, 6.6889997, 3.3913999, 16.7227, -3.3452, 3.3913999, 13.3783, 0, 3.3913999, 10.0334, 0, 3.3913999, 13.3783, -3.3452, 3.3913999, 13.3783, -3.3452, 3.4796999, 10.0334, 0, 3.3913999, 10.0334, 0, 3.3913999, 10.0334, 0, 3.4796999, 6.6891, 3.3444998, 3.3913999, 6.6891, 0, 3.3913999, 10.0334, 3.3444998, 3.3913999, 6.6891, 3.3444998, 3.3913999, 10.0334, 0, 3.4796999, 6.6891, 3.3444998, 3.3913999, 3.3442, 3.3444998, 3.3913999, 6.6891, 0, 3.4796999, 6.6891, 0, 3.3913999, 3.3442, 3.3444998, 3.3913999, 3.3442, 6.6889997, 3.3913999, -0.000100016594, 10.0335, 3.3913999, -3.3451, 10.0335, 3.2212, -2.2310996, 6.6889997, 3.3913999, -0.000100016594, 6.6889997, 3.3913999, -3.3451, 10.0335, 3.3913999, -3.3451, -10.034201, 3.0033998, 6.6891, -13.136101, 3.3913999, 6.7548, -12.747801, 3.0033998, 6.3666997, -10.034201, 3.0033998, 6.6891, -10.034201, 3.3913999, 7.0772, -13.136101, 3.3913999, 6.7548, -2.5679002, 2.9782999, 7.6208997, -6.6896996, 3.4796999, 7.0772, -6.6896996, 3.0033998, 6.6891, -2.5679002, 2.9782999, 7.6208997, -2.1796002, 3.3107998, 8.009, -6.6896996, 3.4796999, 7.0772, -12.747801, 3.0033998, 6.3666997, -14.0949, 3.3913999, 5.5070996, -13.694601, 3.0033998, 5.1793, -12.747801, 3.0033998, 6.3666997, -13.136101, 3.3913999, 6.7548, -14.0949, 3.3913999, 5.5070996, -6.6896996, 3.0033998, 6.6891, -10.034201, 3.3913999, 7.0772, -10.034201, 3.0033998, 6.6891, -6.6896996, 3.0033998, 6.6891, -6.6896996, 3.4796999, 7.0772, -10.034201, 3.3913999, 7.0772, -16.723299, 3.0033998, -0.000100016594, -16.192, 3.3913999, -3.0972, -15.8036995, 3.0033998, -2.7097, -16.723299, 3.0033998, -0.000100016594, -17.111599, 3.3913999, -0.000100016594, -16.192, 3.3913999, -3.0972, -13.378799, 3.3913999, -3.7331, -15.8036995, 3.0033998, -2.7097, -16.192, 3.3913999, -3.0972, -13.378799, 3.3913999, -3.7331, -13.378799, 3.0033998, -3.3451, -15.8036995, 3.0033998, -2.7097, -2.9569001, 3.3913999, -0.000100016594, -4.0260997, 3.0033998, -1.6915, -3.6383996, 3.3913999, -2.0795999, -2.9569001, 3.3913999, -0.000100016594, -3.3452, 3.0033998, -0.000100016594, -4.0260997, 3.0033998, -1.6915, -3.6383996, 3.3913999, -2.0795999, -6.6896996, 3.0033998, -3.3451, -6.6896996, 3.3913999, -3.7331, -3.6383996, 3.3913999, -2.0795999, -4.0260997, 3.0033998, -1.6915, -6.6896996, 3.0033998, -3.3451, -10.034201, 3.3913999, -3.7331, -13.378799, 3.0033998, -3.3451, -13.378799, 3.3913999, -3.7331, -10.034201, 3.3913999, -3.7331, -10.034201, 3.0033998, -3.3451, -13.378799, 3.0033998, -3.3451, -6.6896996, 3.3913999, -3.7331, -10.034201, 3.0033998, -3.3451, -10.034201, 3.3913999, -3.7331, -6.6896996, 3.3913999, -3.7331, -6.6896996, 3.0033998, -3.3451, -10.034201, 3.0033998, -3.3451, -16.723299, 3.0033998, 2.9164999, -17.111599, 3.3913999, -0.000100016594, -16.723299, 3.0033998, -0.000100016594, -16.723299, 3.0033998, 2.9164999, -17.111599, 3.3913999, 3.304, -17.111599, 3.3913999, -0.000100016594, -14.4897995, 3.0033998, 4.2777, -15.5823, 3.3913999, 4.0364, -15.3722, 3.0033998, 3.6128, -14.4897995, 3.0033998, 4.2777, -14.877501, 3.3913999, 4.6651998, -15.5823, 3.3913999, 4.0364, -2.9569001, 3.3913999, 3.3442, -3.3452, 3.0033998, -0.000100016594, -2.9569001, 3.3913999, -0.000100016594, -2.9569001, 3.3913999, 3.3442, -3.3452, 3.0033998, 3.3442, -3.3452, 3.0033998, -0.000100016594, -2.1796002, 3.3107998, 8.009, -2.1397, 3.0033998, 5.1793, -1.7839999, 3.3913999, 5.216, -2.1796002, 3.3107998, 8.009, -2.5679002, 2.9782999, 7.6208997, -2.1397, 3.0033998, 5.1793, -6.6896996, 3.3913999, 10.0334, -2.1796002, 3.3107998, 8.009, -3.3452, 3.4796999, 10.0334, -6.6896996, 3.3913999, 10.0334, -6.6896996, 3.4796999, 7.0772, -2.1796002, 3.3107998, 8.009, -10.034201, 3.3913999, 10.0334, -6.6896996, 3.4796999, 7.0772, -6.6896996, 3.3913999, 10.0334, -10.034201, 3.3913999, 10.0334, -10.034201, 3.3913999, 7.0772, -6.6896996, 3.4796999, 7.0772, -1.7839999, 3.3913999, 5.216, 0, 3.4796999, 6.6891, -2.1796002, 3.3107998, 8.009, 0, 3.4796999, 6.6891, -1.7839999, 3.3913999, 5.216, -2.9569001, 3.3913999, 3.3442, 0, 3.4796999, 6.6891, -2.9569001, 3.3913999, 3.3442, 0, 3.3913999, 3.3442, -3.3452, 3.4796999, 10.0334, 0, 3.4796999, 6.6891, 0, 3.3913999, 10.0334, -3.3452, 3.4796999, 10.0334, -2.1796002, 3.3107998, 8.009, 0, 3.4796999, 6.6891, -2.1397, 3.0033998, 5.1793, -2.9569001, 3.3913999, 3.3442, -1.7839999, 3.3913999, 5.216, -2.1397, 3.0033998, 5.1793, -3.3452, 3.0033998, 3.3442, -2.9569001, 3.3913999, 3.3442, -14.0949, 3.3913999, 5.5070996, -14.4897995, 3.0033998, 4.2777, -13.694601, 3.0033998, 5.1793, -14.0949, 3.3913999, 5.5070996, -14.877501, 3.3913999, 4.6651998, -14.4897995, 3.0033998, 4.2777, -15.5823, 3.3913999, 4.0364, -16.723299, 3.0033998, 2.9164999, -15.3722, 3.0033998, 3.6128, -15.5823, 3.3913999, 4.0364, -17.111599, 3.3913999, 3.304, -16.723299, 3.0033998, 2.9164999, 16.722599, 3.3913999, -13.109999, 16.4746, 3.3913999, -13.3993, 16.8123, 3.1578999, -13.4637985, 10.0335, 3.0523999, -19.0296, 6.6797, 3.2777998, -18.734999, 6.6763997, 3.0570998, -19.0278, 10.0335, 3.0523999, -19.0296, 10.0335, 3.2784998, -18.7001, 6.6797, 3.2777998, -18.734999, 6.6889997, 3.3913999, -16.7235, 10.0335, 3.2784998, -18.7001, 10.0335, 3.3913999, -16.7235, 6.6889997, 3.3913999, -16.7235, 6.6797, 3.2777998, -18.734999, 10.0335, 3.2784998, -18.7001, 12.7383995, 3.3913999, -15.8953, 12.460499, 3.3913999, -16.1674, 12.747099, 3.1769, -16.1994, 4.6816, 3.3913999, -13.378599, 3.5892, 3.2033, -16.7235, 3.8384998, 3.3913999, -16.7235, 4.6816, 3.3913999, -13.378599, 4.413, 3.2091, -13.357899, 3.5892, 3.2033, -16.7235, -10.6998005, 3.3913999, -13.1763, -6.6896996, 3.175, -12.867498, -6.6896996, 3.3913999, -12.5385, -10.6998005, 3.3913999, -13.1763, -10.4046, 3.1608999, -13.429499, -6.6896996, 3.175, -12.867498, -22.8711, 3.4937, 3.3673, -23.3678, 3.4459999, 0.13709998, -23.171, 3.6251, 0.12290001, -22.8711, 3.4937, 3.3673, -23.079899, 3.314, 3.3673, -23.3678, 3.4459999, 0.13709998, -22.8711, 3.4937, 3.3673, -20.068499, 3.3913999, -0.000100016594, -20.068499, 3.3913999, 3.3442, -22.8711, 3.4937, 3.3673, -23.171, 3.6251, 0.12290001, -20.068499, 3.3913999, -0.000100016594, -23.079899, 3.314, 3.3673, -22.315199, 3.3913999, 6.6891, -22.5639, 3.2219, 6.6891, -23.079899, 3.314, 3.3673, -22.8711, 3.4937, 3.3673, -22.315199, 3.3913999, 6.6891, -22.315199, 3.3913999, 6.6891, -20.068499, 3.3913999, 3.3442, -20.068499, 3.3913999, 6.6891, -22.315199, 3.3913999, 6.6891, -22.8711, 3.4937, 3.3673, -20.068499, 3.3913999, 3.3442, -15.739201, 3.2087, 12.341301, -13.378799, 3.3913999, 10.0334, -13.378799, 3.3922, 12.5714, -15.739201, 3.2087, 12.341301, -16.0544, 3.3913999, 9.808, -13.378799, 3.3913999, 10.0334, -10.034201, 3.2142, 12.105801, -13.378799, 3.1938999, 12.808599, -13.378799, 3.3922, 12.5714, -10.034201, 3.2142, 12.105801, -10.034201, 2.9384, 12.453701, -13.378799, 3.1938999, 12.808599, -13.378799, 3.3922, 12.5714, -10.034201, 3.3913999, 10.0334, -10.034201, 3.2142, 12.105801, -13.378799, 3.3922, 12.5714, -13.378799, 3.3913999, 10.0334, -10.034201, 3.3913999, 10.0334, 0, 3.2243998, 15.7092, -3.3452, 3.1497998, 15.914499, -3.3452, 3.3913999, 15.601, 0, 3.2243998, 15.7092, 0, 2.9636998, 16.0175, -3.3452, 3.1497998, 15.914499, -3.3452, 3.3913999, 15.601, 0, 3.3913999, 13.3783, 0, 3.2243998, 15.7092, -3.3452, 3.3913999, 15.601, -3.3452, 3.3913999, 13.3783, 0, 3.3913999, 13.3783, 0, 3.2243998, 15.7092, 3.3444998, 3.3913999, 13.3783, 3.6397, 3.3913999, 16.2405, 0, 3.2243998, 15.7092, 0, 3.3913999, 13.3783, 3.3444998, 3.3913999, 13.3783, 3.6397, 3.3913999, 16.2405, 3.9143, 3.3913999, 16.5144, 3.6729999, 3.1532, 16.5718, 13.2491, 2.9931, -3.474, 16.722599, 3.3913999, -6.6894, 16.722599, 3.1513, -3.6023998, 13.2491, 2.9931, -3.474, 13.378099, 3.3913999, -6.6894, 16.722599, 3.3913999, -6.6894, 10.0335, 3.3913999, -3.3451, 13.378099, 3.3913999, -6.6894, 13.2491, 2.9931, -3.474, 10.0335, 3.3913999, -3.3451, 10.0335, 3.3913999, -6.6894, 13.378099, 3.3913999, -6.6894, 19.8151, 3.3908, -10.0177, 20.1469, 3.0588999, -6.6894, 19.8317, 3.2891998, -6.6894, 19.8151, 3.3908, -10.0177, 20.188799, 3.1655998, -10.0154, 20.1469, 3.0588999, -6.6894, 19.8317, 3.2891998, -6.6894, 20.139599, 2.779, -3.3616, 19.8317, 3.0491, -3.6023998, 19.8317, 3.2891998, -6.6894, 20.1469, 3.0588999, -6.6894, 20.139599, 2.779, -3.3616, 16.722599, 3.1513, -3.6023998, 19.8317, 3.2891998, -6.6894, 19.8317, 3.0491, -3.6023998, 16.722599, 3.1513, -3.6023998, 16.722599, 3.3913999, -6.6894, 19.8317, 3.2891998, -6.6894, 16.722599, 3.3913999, -6.6894, 19.8151, 3.3908, -10.0177, 19.8317, 3.2891998, -6.6894, 16.722599, 3.3913999, -6.6894, 16.722599, 3.3913999, -10.0343, 19.8151, 3.3908, -10.0177, 20.139599, 2.779, -3.3616, 16.722599, 3.1513, -3.6023998, 19.8317, 3.0491, -3.6023998, 20.139599, 2.779, -3.3616, 16.712599, 2.8734, -3.3451, 16.722599, 3.1513, -3.6023998, 19.767899, 3.1308, -13.4058, 19.7985, 3.3913999, -12.747999, 19.4075, 3.3913999, -13.109999, 19.767899, 3.1308, -13.4058, 20.1582, 3.1318998, -13.041399, 19.7985, 3.3913999, -12.747999, 19.767899, 3.1308, -13.4058, 16.722599, 3.3913999, -13.109999, 16.8123, 3.1578999, -13.4637985, 19.767899, 3.1308, -13.4058, 19.4075, 3.3913999, -13.109999, 16.722599, 3.3913999, -13.109999, 20.188799, 3.1655998, -10.0154, 19.7985, 3.3913999, -12.747999, 20.1582, 3.1318998, -13.041399, 20.188799, 3.1655998, -10.0154, 19.8151, 3.3908, -10.0177, 19.7985, 3.3913999, -12.747999, 16.722599, 3.3913999, -13.109999, 19.8151, 3.3908, -10.0177, 16.722599, 3.3913999, -10.0343, 19.8151, 3.3908, -10.0177, 16.722599, 3.3913999, -13.109999, 19.4075, 3.3913999, -13.109999, 19.8151, 3.3908, -10.0177, 19.4075, 3.3913999, -13.109999, 19.7985, 3.3913999, -12.747999, 16.4952, 3.3913999, -15.5392, 15.828299, 3.1678, -16.6064, 16.7698, 3.1685998, -15.8202, 16.4952, 3.3913999, -15.5392, 15.5536995, 3.3913999, -16.3177, 15.828299, 3.1678, -16.6064, 16.8123, 3.1578999, -13.4637985, 16.4952, 3.3913999, -15.5392, 16.7698, 3.1685998, -15.8202, 16.8123, 3.1578999, -13.4637985, 16.4746, 3.3913999, -13.3993, 16.4952, 3.3913999, -15.5392, 15.828299, 3.1678, -16.6064, 12.7383995, 3.3913999, -15.8953, 12.747099, 3.1769, -16.1994, 15.828299, 3.1678, -16.6064, 15.5536995, 3.3913999, -16.3177, 12.7383995, 3.3913999, -15.8953, 12.4485, 3.2073998, -18.4711, 12.299599, 2.942, -19.2491, 12.726398, 2.9599, -18.824299, 12.4485, 3.2073998, -18.4711, 12.0203, 3.1891, -18.8983, 12.299599, 2.942, -19.2491, 12.4485, 3.2073998, -18.4711, 12.747099, 3.1769, -16.1994, 12.460499, 3.3913999, -16.1674, 12.4485, 3.2073998, -18.4711, 12.726398, 2.9599, -18.824299, 12.747099, 3.1769, -16.1994, 12.299599, 2.942, -19.2491, 10.0335, 3.2784998, -18.7001, 10.0335, 3.0523999, -19.0296, 12.299599, 2.942, -19.2491, 12.0203, 3.1891, -18.8983, 10.0335, 3.2784998, -18.7001, 10.0335, 3.2784998, -18.7001, 12.460499, 3.3913999, -16.1674, 10.0335, 3.3913999, -16.7235, 12.460499, 3.3913999, -16.1674, 10.0335, 3.2784998, -18.7001, 12.0203, 3.1891, -18.8983, 12.460499, 3.3913999, -16.1674, 12.0203, 3.1891, -18.8983, 12.4485, 3.2073998, -18.4711, 5.6212, 3.2835999, -18.3475, 3.9036999, 3.1690998, -17.2696, 5.4681997, 3.0900998, -18.607199, 5.6212, 3.2835999, -18.3475, 4.0333, 3.3667998, -17.0276, 3.9036999, 3.1690998, -17.2696, 4.0333, 3.3667998, -17.0276, 6.6889997, 3.3913999, -16.7235, 3.8384998, 3.3913999, -16.7235, 6.6889997, 3.3913999, -16.7235, 4.0333, 3.3667998, -17.0276, 5.6212, 3.2835999, -18.3475, 6.6889997, 3.3913999, -16.7235, 5.6212, 3.2835999, -18.3475, 6.6797, 3.2777998, -18.734999, 3.8384998, 3.3913999, -16.7235, 3.9036999, 3.1690998, -17.2696, 4.0333, 3.3667998, -17.0276, 3.8384998, 3.3913999, -16.7235, 3.5892, 3.2033, -16.7235, 3.9036999, 3.1690998, -17.2696, 6.6763997, 3.0570998, -19.0278, 5.6212, 3.2835999, -18.3475, 5.4681997, 3.0900998, -18.607199, 6.6763997, 3.0570998, -19.0278, 6.6797, 3.2777998, -18.734999, 5.6212, 3.2835999, -18.3475, -15.2073, 3.5182998, -15.4569, -16.124199, 3.3711998, -15.150499, -15.441299, 3.3151, -15.7533, -15.2073, 3.5182998, -15.4569, -15.9035, 3.5797, -14.867699, -16.124199, 3.3711998, -15.150499, -15.9035, 3.5797, -14.867699, -13.378799, 3.3913999, -13.378599, -16.1309, 3.6227, -13.6241, -13.378799, 3.3913999, -13.378599, -15.9035, 3.5797, -14.867699, -15.2073, 3.5182998, -15.4569, -13.378799, 3.3913999, -13.378599, -15.2073, 3.5182998, -15.4569, -13.3874, 3.3918998, -15.424398, -16.1309, 3.6227, -13.6241, -16.124199, 3.3711998, -15.150499, -15.9035, 3.5797, -14.867699, -16.1309, 3.6227, -13.6241, -16.3755, 3.4225, -13.6283, -16.124199, 3.3711998, -15.150499, -13.385401, 3.1832, -15.7598, -15.2073, 3.5182998, -15.4569, -15.441299, 3.3151, -15.7533, -13.385401, 3.1832, -15.7598, -13.3874, 3.3918998, -15.424398, -15.2073, 3.5182998, -15.4569, -10.749701, 3.1581, -16.1136, -10.6998005, 3.3913999, -15.507799, -11.021601, 3.3913999, -15.7846985, -10.749701, 3.1581, -16.1136, -10.429201, 3.1564, -15.8326, -10.6998005, 3.3913999, -15.507799, -10.749701, 3.1581, -16.1136, -13.3874, 3.3918998, -15.424398, -13.385401, 3.1832, -15.7598, -10.749701, 3.1581, -16.1136, -11.021601, 3.3913999, -15.7846985, -13.3874, 3.3918998, -15.424398, -10.4046, 3.1608999, -13.429499, -10.6998005, 3.3913999, -15.507799, -10.429201, 3.1564, -15.8326, -10.4046, 3.1608999, -13.429499, -10.6998005, 3.3913999, -13.1763, -10.6998005, 3.3913999, -15.507799, -13.3874, 3.3918998, -15.424398, -10.6998005, 3.3913999, -13.1763, -13.378799, 3.3913999, -13.378599, -10.6998005, 3.3913999, -13.1763, -13.3874, 3.3918998, -15.424398, -11.021601, 3.3913999, -15.7846985, -10.6998005, 3.3913999, -13.1763, -11.021601, 3.3913999, -15.7846985, -10.6998005, 3.3913999, -15.507799, -3.9104, 3.3913999, -12.1610985, -4.0188, 3.1604998, -12.820099, -3.6476998, 3.1597998, -12.4415, -3.9104, 3.3913999, -12.1610985, -4.2794, 3.3913999, -12.5385, -4.0188, 3.1604998, -12.820099, -4.0188, 3.1604998, -12.820099, -6.6896996, 3.3913999, -12.5385, -6.6896996, 3.175, -12.867498, -4.0188, 3.1604998, -12.820099, -4.2794, 3.3913999, -12.5385, -6.6896996, 3.3913999, -12.5385, -3.5373998, 3.3913999, -9.750299, -3.6144996, 3.1664, -10.4478, -3.2335, 3.1696, -9.965099, -3.5373998, 3.3913999, -9.750299, -3.9104, 3.3913999, -10.240799, -3.6144996, 3.1664, -10.4478, -3.6144996, 3.1664, -10.4478, -3.9104, 3.3913999, -12.1610985, -3.6476998, 3.1597998, -12.4415, -3.6144996, 3.1664, -10.4478, -3.9104, 3.3913999, -10.240799, -3.9104, 3.3913999, -12.1610985, -0.0046999454, 3.1827998, -9.064699, -3.5373998, 3.3913999, -9.750299, -3.2335, 3.1696, -9.965099, -0.0046999454, 3.1827998, -9.064699, 0, 3.3913999, -8.714399, -3.5373998, 3.3913999, -9.750299, 4.1105, 3.3913999, -9.3386, 3.149, 3.1724, -9.052198, 3.8465, 3.1766, -9.664, 4.1105, 3.3913999, -9.3386, 3.4176, 3.3913999, -8.714399, 3.149, 3.1724, -9.052198, 4.1105, 3.3913999, -9.3386, 4.413, 3.2091, -13.357899, 4.6816, 3.3913999, -13.378599, 4.1105, 3.3913999, -9.3386, 3.8465, 3.1766, -9.664, 4.413, 3.2091, -13.357899, 3.149, 3.1724, -9.052198, 0, 3.3913999, -8.714399, -0.0046999454, 3.1827998, -9.064699, 3.149, 3.1724, -9.052198, 3.4176, 3.3913999, -8.714399, 0, 3.3913999, -8.714399, -19.2812, 3.3913999, -6.6894, -19.611, 3.187, -9.246899, -19.3743, 3.391, -9.417299, -19.2812, 3.3913999, -6.6894, -19.515299, 3.2122, -6.6894, -19.611, 3.187, -9.246899, -16.1255, 3.4196, -10.133099, -16.240599, 3.3848999, -8.961699, -16.7605, 3.3848, -9.501299, -16.1255, 3.4196, -10.133099, -15.5737, 3.3848999, -9.614899, -16.240599, 3.3848999, -8.961699, -16.1255, 3.4196, -10.133099, -17.049099, 3.1696, -9.7829, -16.4075, 3.2108998, -10.402899, -16.1255, 3.4196, -10.133099, -16.7605, 3.3848, -9.501299, -17.049099, 3.1696, -9.7829, -16.7605, 3.3848, -9.501299, -19.1775, 3.1662998, -9.7628, -17.049099, 3.1696, -9.7829, -16.7605, 3.3848, -9.501299, -18.8297, 3.3895998, -9.473499, -19.1775, 3.1662998, -9.7628, -16.3755, 3.4225, -13.6283, -16.1255, 3.4196, -10.133099, -16.4075, 3.2108998, -10.402899, -16.3755, 3.4225, -13.6283, -16.1309, 3.6227, -13.6241, -16.1255, 3.4196, -10.133099, -16.240599, 3.3848999, -8.961699, -13.378799, 3.3913999, -6.6894, -16.723299, 3.3913999, -6.6894, -13.378799, 3.3913999, -6.6894, -16.240599, 3.3848999, -8.961699, -15.5737, 3.3848999, -9.614899, -13.378799, 3.3913999, -6.6894, -15.5737, 3.3848999, -9.614899, -13.378799, 3.3913999, -10.0343, -16.1309, 3.6227, -13.6241, -15.5737, 3.3848999, -9.614899, -16.1255, 3.4196, -10.133099, -15.5737, 3.3848999, -9.614899, -16.1309, 3.6227, -13.6241, -13.378799, 3.3913999, -13.378599, -15.5737, 3.3848999, -9.614899, -13.378799, 3.3913999, -13.378599, -13.378799, 3.3913999, -10.0343, -19.3743, 3.391, -9.417299, -16.723299, 3.3913999, -6.6894, -19.2812, 3.3913999, -6.6894, -16.723299, 3.3913999, -6.6894, -19.3743, 3.391, -9.417299, -16.7605, 3.3848, -9.501299, -16.7605, 3.3848, -9.501299, -19.3743, 3.391, -9.417299, -18.8297, 3.3895998, -9.473499, -16.723299, 3.3913999, -6.6894, -16.7605, 3.3848, -9.501299, -16.240599, 3.3848999, -8.961699, -19.754599, 3.3913999, -2.7854, -19.9515, 3.3913999, -2.5836997, -19.9495, 3.2063, -2.8084998, -19.754599, 3.3913999, -2.7854, -19.515299, 3.2122, -6.6894, -19.2812, 3.3913999, -6.6894, -19.754599, 3.3913999, -2.7854, -19.8424, 3.2077, -3.7668996, -19.515299, 3.2122, -6.6894, -22.7335, 3.5105, -2.4837003, -23.3864, 3.3542, -2.205, -22.931599, 3.3177998, -2.7097, -22.7335, 3.5105, -2.4837003, -23.1883, 3.5481, -1.9755001, -23.3864, 3.3542, -2.205, -23.1883, 3.5481, -1.9755001, -20.068499, 3.3913999, -0.000100016594, -23.171, 3.6251, 0.12290001, -20.068499, 3.3913999, -0.000100016594, -23.1883, 3.5481, -1.9755001, -22.7335, 3.5105, -2.4837003, -20.068499, 3.3913999, -0.000100016594, -22.7335, 3.5105, -2.4837003, -19.9515, 3.3913999, -2.5836997, -23.171, 3.6251, 0.12290001, -23.3864, 3.3542, -2.205, -23.1883, 3.5481, -1.9755001, -23.171, 3.6251, 0.12290001, -23.3678, 3.4459999, 0.13709998, -23.3864, 3.3542, -2.205, -22.7335, 3.5105, -2.4837003, -19.9495, 3.2063, -2.8084998, -19.9515, 3.3913999, -2.5836997, -22.7335, 3.5105, -2.4837003, -22.931599, 3.3177998, -2.7097, -19.9495, 3.2063, -2.8084998, -22.332499, 3.3913999, 8.990399, -22.5912, 3.2112, 9.44, -22.7647, 3.2136, 9.1158, -22.7647, 3.2136, 9.1158, -22.315199, 3.3913999, 6.6891, -22.332499, 3.3913999, 8.990399, -22.7647, 3.2136, 9.1158, -22.5639, 3.2219, 6.6891, -22.315199, 3.3913999, 6.6891, -22.5912, 3.2112, 9.44, -20.068499, 3.3913999, 9.1673, -20.068499, 3.1943, 9.406899, -22.5912, 3.2112, 9.44, -22.332499, 3.3913999, 8.990399, -20.068499, 3.3913999, 9.1673, -6.2755, 3.126, 16.1251, -5.9743, 3.3983, 13.383699, -5.9743, 3.393, 15.6513, -6.2755, 3.126, 16.1251, -6.2755, 3.126, 13.6215, -5.9743, 3.3983, 13.383699, -17.1362, 3.192, 9.7157, -16.285099, 3.3913999, 9.5862, -16.2818, 3.1913998, 9.8050995, -16.285099, 3.3913999, 9.5862, -16.0544, 3.3913999, 9.808, -16.2818, 3.1913998, 9.8050995, -16.285099, 3.3913999, 9.5862, -20.068499, 3.1943, 9.406899, -20.068499, 3.3913999, 9.1673, -16.285099, 3.3913999, 9.5862, -17.1362, 3.192, 9.7157, -20.068499, 3.1943, 9.406899, -16.1122, 3.066, 12.232401, -15.739201, 3.2087, 12.341301, -15.571699, 3.0688, 12.748899, -15.739201, 3.2087, 12.341301, -16.2818, 3.1913998, 9.8050995, -16.0544, 3.3913999, 9.808, -15.739201, 3.2087, 12.341301, -16.1122, 3.066, 12.232401, -16.2818, 3.1913998, 9.8050995, -13.378799, 3.3922, 12.5714, -15.571699, 3.0688, 12.748899, -15.739201, 3.2087, 12.341301, -13.378799, 3.3922, 12.5714, -13.378799, 3.1938999, 12.808599, -15.571699, 3.0688, 12.748899, -6.4743, 3.3766, 12.7861, -5.5162, 3.398, 12.9678, -5.9743, 3.3983, 13.383699, -6.4743, 3.3766, 12.7861, -6.088, 3.3981, 12.428801, -5.5162, 3.398, 12.9678, -6.4743, 3.3766, 12.7861, -6.2755, 3.126, 13.6215, -6.7781997, 3.1009, 13.0322, -6.4743, 3.3766, 12.7861, -5.9743, 3.3983, 13.383699, -6.2755, 3.126, 13.6215, -6.4743, 3.3766, 12.7861, -10.034201, 2.9384, 12.453701, -10.034201, 3.2142, 12.105801, -6.4743, 3.3766, 12.7861, -6.7781997, 3.1009, 13.0322, -10.034201, 2.9384, 12.453701, -10.034201, 3.3913999, 10.0334, -6.4743, 3.3766, 12.7861, -10.034201, 3.2142, 12.105801, -6.4743, 3.3766, 12.7861, -10.034201, 3.3913999, 10.0334, -6.6896996, 3.3913999, 10.0334, -6.4743, 3.3766, 12.7861, -6.6896996, 3.3913999, 10.0334, -6.088, 3.3981, 12.428801, -6.6896996, 3.3913999, 10.0334, -5.5162, 3.398, 12.9678, -6.088, 3.3981, 12.428801, -5.5162, 3.398, 12.9678, -6.6896996, 3.3913999, 10.0334, -3.3452, 3.4796999, 10.0334, -5.5162, 3.398, 12.9678, -3.3452, 3.4796999, 10.0334, -3.3452, 3.3913999, 13.3783, -5.9743, 3.393, 15.6513, -6.0454, 3.135, 16.5322, -6.2755, 3.126, 16.1251, -5.9743, 3.393, 15.6513, -5.5313997, 3.3918, 16.0056, -6.0454, 3.135, 16.5322, -6.0454, 3.135, 16.5322, -3.3452, 3.3913999, 15.601, -3.3452, 3.1497998, 15.914499, -6.0454, 3.135, 16.5322, -5.5313997, 3.3918, 16.0056, -3.3452, 3.3913999, 15.601, -5.9743, 3.3983, 13.383699, -5.5313997, 3.3918, 16.0056, -5.9743, 3.393, 15.6513, -5.5313997, 3.3918, 16.0056, -5.9743, 3.3983, 13.383699, -3.3452, 3.3913999, 13.3783, -3.3452, 3.3913999, 13.3783, -5.9743, 3.3983, 13.383699, -5.5162, 3.398, 12.9678, -5.5313997, 3.3918, 16.0056, -3.3452, 3.3913999, 13.3783, -3.3452, 3.3913999, 15.601, 2.7767, 3.0992, 16.3949, 3.6397, 3.3913999, 16.2405, 3.6729999, 3.1532, 16.5718, 3.6397, 3.3913999, 16.2405, 0, 2.9636998, 16.0175, 0, 3.2243998, 15.7092, 3.6397, 3.3913999, 16.2405, 2.7767, 3.0992, 16.3949, 0, 2.9636998, 16.0175, 3.9987998, 3.4764, 18.6069, 3.9143, 3.3913999, 16.5144, 6.6889997, 3.3913999, 16.7227, 3.8212, 3.3128, 18.7347, 4.941, 3.4643, 19.3458, 4.8006997, 3.3019998, 19.5215, 3.8212, 3.3128, 18.7347, 3.9987998, 3.4764, 18.6069, 4.941, 3.4643, 19.3458, 6.6923, 3.3525, 19.0394, 4.8006997, 3.3019998, 19.5215, 4.941, 3.4643, 19.3458, 6.6923, 3.3525, 19.0394, 6.7794, 3.1271, 19.2435, 4.8006997, 3.3019998, 19.5215, 3.6729999, 3.1532, 16.5718, 3.9987998, 3.4764, 18.6069, 3.8212, 3.3128, 18.7347, 3.6729999, 3.1532, 16.5718, 3.9143, 3.3913999, 16.5144, 3.9987998, 3.4764, 18.6069, 6.6889997, 3.3913999, 16.7227, 4.941, 3.4643, 19.3458, 3.9987998, 3.4764, 18.6069, 6.6923, 3.3525, 19.0394, 4.941, 3.4643, 19.3458, 6.6889997, 3.3913999, 16.7227, 8.6452, 3.3837, 18.872, 9.5275, 3.2163, 18.6602, 8.8241, 3.2010999, 19.0991, 8.6452, 3.3837, 18.872, 9.3605995, 3.3913, 18.4371, 9.5275, 3.2163, 18.6602, 8.6452, 3.3837, 18.872, 6.7794, 3.1271, 19.2435, 6.6923, 3.3525, 19.0394, 8.6452, 3.3837, 18.872, 8.8241, 3.2010999, 19.0991, 6.7794, 3.1271, 19.2435, 9.5275, 3.2163, 18.6602, 10.388599, 3.3911998, 16.7085, 10.590099, 3.2050998, 16.7167, 9.5275, 3.2163, 18.6602, 9.3605995, 3.3913, 18.4371, 10.388599, 3.3911998, 16.7085, 6.6889997, 3.3913999, 16.7227, 8.6452, 3.3837, 18.872, 6.6923, 3.3525, 19.0394, 8.6452, 3.3837, 18.872, 6.6889997, 3.3913999, 16.7227, 10.388599, 3.3911998, 16.7085, 8.6452, 3.3837, 18.872, 10.388599, 3.3911998, 16.7085, 9.3605995, 3.3913, 18.4371, 9.8819, 3.359, 13.619101, 9.838699, 3.359, 12.8258, 10.225699, 3.399, 13.197901, 9.8819, 3.359, 13.619101, 9.4411, 3.359, 13.2192, 9.838699, 3.359, 12.8258, 9.8819, 3.359, 13.619101, 10.462399, 3.2359, 13.4067, 10.122, 3.1906, 13.8256, 9.8819, 3.359, 13.619101, 10.225699, 3.399, 13.197901, 10.462399, 3.2359, 13.4067, 12.575499, 3.4812999, 13.4044, 10.225699, 3.399, 13.197901, 12.125999, 3.6032, 13.197901, 12.575499, 3.4812999, 13.4044, 10.462399, 3.2359, 13.4067, 10.225699, 3.399, 13.197901, 6.6889997, 3.3913999, 13.3783, 10.388599, 3.3911998, 16.7085, 6.6889997, 3.3913999, 16.7227, 10.388599, 3.3911998, 16.7085, 6.6889997, 3.3913999, 13.3783, 9.4411, 3.359, 13.2192, 10.388599, 3.3911998, 16.7085, 9.4411, 3.359, 13.2192, 9.8819, 3.359, 13.619101, 6.6889997, 3.3913999, 10.0334, 9.4411, 3.359, 13.2192, 6.6889997, 3.3913999, 13.3783, 9.4411, 3.359, 13.2192, 6.6889997, 3.3913999, 10.0334, 10.0335, 3.3913999, 10.0334, 9.4411, 3.359, 13.2192, 10.0335, 3.3913999, 10.0334, 9.838699, 3.359, 12.8258, 9.8819, 3.359, 13.619101, 10.590099, 3.2050998, 16.7167, 10.388599, 3.3911998, 16.7085, 9.8819, 3.359, 13.619101, 10.122, 3.1906, 13.8256, 10.590099, 3.2050998, 16.7167, 12.125999, 3.6032, 13.197901, 13.189199, 3.2312999, 12.5394, 13.262999, 3.3165998, 13.403801, 12.125999, 3.6032, 13.197901, 12.9445, 3.6148, 12.325901, 13.189199, 3.2312999, 12.5394, 13.262999, 3.3165998, 13.403801, 12.575499, 3.4812999, 13.4044, 12.125999, 3.6032, 13.197901, 13.189199, 3.2312999, 12.5394, 12.732399, 3.3913999, 10.0334, 12.977798, 3.2031, 10.0163, 13.189199, 3.2312999, 12.5394, 12.9445, 3.6148, 12.325901, 12.732399, 3.3913999, 10.0334, 10.0335, 3.3913999, 10.0334, 10.225699, 3.399, 13.197901, 9.838699, 3.359, 12.8258, 10.225699, 3.399, 13.197901, 10.0335, 3.3913999, 10.0334, 12.9445, 3.6148, 12.325901, 12.9445, 3.6148, 12.325901, 10.0335, 3.3913999, 10.0334, 12.732399, 3.3913999, 10.0334, 10.225699, 3.399, 13.197901, 12.9445, 3.6148, 12.325901, 12.125999, 3.6032, 13.197901, 12.8249, 2.8544, 7.9231997, 12.9499, 3.3256998, 8.668, 11.173199, 3.3332, 6.6548, 10.0335, 3.3913999, 6.6891, 10.0335, 3.3913999, 4.2546997, 11.173199, 3.3332, 6.6548, 12.732399, 3.3913999, 10.0334, 13.175299, 2.4743, 8.0415, 12.977798, 3.2031, 10.0163, 13.175299, 2.4743, 8.0415, 12.732399, 3.3913999, 10.0334, 12.9499, 3.3256998, 8.668, 13.175299, 2.4743, 8.0415, 12.9499, 3.3256998, 8.668, 12.8249, 2.8544, 7.9231997, 10.0335, 3.3913999, 6.6891, 12.732399, 3.3913999, 10.0334, 10.0335, 3.3913999, 10.0334, 12.732399, 3.3913999, 10.0334, 10.0335, 3.3913999, 6.6891, 11.173199, 3.3332, 6.6548, 12.732399, 3.3913999, 10.0334, 11.173199, 3.3332, 6.6548, 12.9499, 3.3256998, 8.668, 16.722599, 3.1513, -3.6023998, 15.810999, 2.9368, -3.3060002, 13.2491, 2.9931, -3.474, 16.722599, 3.1513, -3.6023998, 16.712599, 2.8734, -3.3451, 15.810999, 2.9368, -3.3060002, 10.0335, 3.2212, -2.2310996, 10.0335, 3.3913999, -3.3451, 13.2491, 2.9931, -3.474, -16.7232, 0.6137, 0, -15.754801, 0.6137, 3.5372, -16.7232, 0.6137, 2.9164, -15.754801, 0.6137, 3.5372, -16.7232, 0.6137, 0, -13.378601, 0.6137, 0, -16.7232, 0.6137, 0, -15.803801, 0.6137, -2.7093, -13.378601, 0.6137, -3.3446002, -16.7232, 0.6137, 0, -13.378601, 0.6137, -3.3446002, -13.378601, 0.6137, 0, -13.378601, 0.6137, 0, -13.378601, 0.6137, -3.3446002, -10.033899, 0.6137, -3.3446002, -15.754801, 0.6137, 3.5372, -13.378601, 0.6137, 0, -14.489201, 0.6137, 4.2777, -13.378601, 0.6137, 0, -10.033899, 0.6137, -3.3446002, -10.033899, 0.6137, 0, -14.489201, 0.6137, 4.2777, -13.378601, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, -3.3446002, -6.6893, 0.6137, -3.3446002, -10.033899, 0.6137, 0, -6.6893, 0.6137, -3.3446002, -6.6893, 0.6137, 0, -6.6893, 0.6137, 0, -6.6893, 0.6137, -3.3446002, -4.0259, 0.6137, -1.6911998, -6.6893, 0.6137, 0, -4.0259, 0.6137, -1.6911998, -3.3446999, 0.6137, 0, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, 0, -14.489201, 0.6137, 4.2777, -10.033899, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 0, -3.3446999, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -3.3446999, 0.6137, 0, -3.3446999, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -14.489201, 0.6137, 4.2777, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 6.6893, -10.033899, 0.6137, 6.6893, -13.6772, 0.6137, 5.2616, -14.489201, 0.6137, 4.2777, -13.6772, 0.6137, 5.2616, -10.033899, 0.6137, 6.6893, -12.7477, 0.6137, 6.3669996, -6.6893, 0.6137, 3.3446, -2.5675998, 0.0266, 7.6215, -6.6893, 0.6137, 6.6893, -2.5675998, 0.0266, 7.6215, -6.6893, 0.6137, 3.3446, -3.3446999, 0.6137, 3.3446, -2.5675998, 0.0266, 7.6215, -3.3446999, 0.6137, 3.3446, -2.1193, 0.6137, 5.2736, 10.033899, 0, -20.467, 6.6766996, 3.0570998, -19.0275, 6.6893, 0, -20.1318, 10.033899, 0, -20.467, 10.033899, 3.0523999, -19.0294, 6.6766996, 3.0570998, -19.0275, 3.0140998, 0, -16.7232, 4.4131, 3.2091, -13.357698, 3.3446, -0.3989, -12.9443, 3.0140998, 0, -16.7232, 3.5895, 3.2033, -16.7232, 4.4131, 3.2091, -13.357698, -6.6893, 0, -14.263699, -10.404301, 3.1608999, -13.429499, -9.6168995, 0, -14.127899, -6.6893, 0, -14.263699, -6.6893, 3.175, -12.866999, -10.404301, 3.1608999, -13.429499, -23.412498, -0.2913, 0.26119995, -23.0793, 3.314, 3.3674998, -23.412498, 0, 3.3446, -23.412498, -0.2913, 0.26119995, -23.3676, 3.4459999, 0.1372, -23.0793, 3.314, 3.3674998, -23.412498, 0, 3.3446, -22.563799, 3.2219, 6.6893, -23.8078, 0, 6.6893, -23.412498, 0, 3.3446, -23.0793, 3.314, 3.3674998, -22.563799, 3.2219, 6.6893, -13.378601, 0, 13.378601, -10.033899, 2.9384, 12.453701, -10.033899, 0, 13.378601, -13.378601, 0, 13.378601, -13.378601, 3.194, 12.808901, -10.033899, 2.9384, 12.453701, -3.3446002, 0, 16.7232, 0, 2.9636998, 16.0179, 0, 0, 16.7232, -3.3446002, 0, 16.7232, -3.3446002, 3.1497998, 15.9146, 0, 2.9636998, 16.0179, 21.2349, 0, -6.6893, 20.188799, 3.1655998, -10.014999, 21.8888, 0, -10.033899, 21.2349, 0, -6.6893, 20.147, 3.0588999, -6.6893, 20.188799, 3.1655998, -10.014999, 20.1586, 3.1318998, -13.0413, 20.809599, 0, -13.7309, 21.251299, 0, -13.3043995, 20.1586, 3.1318998, -13.0413, 19.7684, 3.1308, -13.405399, 20.809599, 0, -13.7309, 20.809599, 0, -13.7309, 16.8128, 3.1578999, -13.463299, 18.0241, 0, -14.301899, 20.809599, 0, -13.7309, 19.7684, 3.1308, -13.405399, 16.8128, 3.1578999, -13.463299, 21.8888, 0, -10.033899, 20.1586, 3.1318998, -13.0413, 21.251299, 0, -13.3043995, 21.8888, 0, -10.033899, 20.188799, 3.1655998, -10.014999, 20.1586, 3.1318998, -13.0413, 16.7704, 3.1685998, -15.819698, 16.3113, 0, -16.9629, 17.503, 0, -15.940498, 16.7704, 3.1685998, -15.819698, 15.8286, 3.1678, -16.6059, 16.3113, 0, -16.9629, 16.3113, 0, -16.9629, 12.747099, 3.1769, -16.199, 13.701399, 0, -17.416399, 16.3113, 0, -16.9629, 15.8286, 3.1678, -16.6059, 12.747099, 3.1769, -16.199, 18.0241, 0, -14.301899, 16.7704, 3.1685998, -15.819698, 17.503, 0, -15.940498, 18.0241, 0, -14.301899, 16.8128, 3.1578999, -13.463299, 16.7704, 3.1685998, -15.819698, 12.961399, 0, -20.5828, 12.727098, 2.9599, -18.8239, 12.2999, 2.942, -19.2491, 12.961399, 0, -20.5828, 13.423399, 0, -20.1574, 12.727098, 2.9599, -18.8239, 12.961399, 0, -20.5828, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 12.961399, 0, -20.5828, 12.2999, 2.942, -19.2491, 10.033899, 3.0523999, -19.0294, 13.701399, 0, -17.416399, 12.727098, 2.9599, -18.8239, 13.423399, 0, -20.1574, 13.701399, 0, -17.416399, 12.747099, 3.1769, -16.199, 12.727098, 2.9599, -18.8239, 3.2372, 0, -19.3395, 5.4684, 3.0900998, -18.6069, 3.9039, 3.1690998, -17.2691, 3.2372, 0, -19.3395, 4.4523, 0, -20.444399, 5.4684, 3.0900998, -18.6069, 6.6893, 0, -20.1318, 5.4684, 3.0900998, -18.6069, 4.4523, 0, -20.444399, 6.6893, 0, -20.1318, 6.6766996, 3.0570998, -19.0275, 5.4684, 3.0900998, -18.6069, 3.2372, 0, -19.3395, 3.5895, 3.2033, -16.7232, 3.0140998, 0, -16.7232, 3.2372, 0, -19.3395, 3.9039, 3.1690998, -17.2691, 3.5895, 3.2033, -16.7232, -15.441, 3.3151, -15.753099, -16.7232, 0, -16.113699, -16.1493, 0, -16.8284, -15.441, 3.3151, -15.753099, -16.1238, 3.3711998, -15.15, -16.7232, 0, -16.113699, -16.7232, 0, -16.113699, -16.3753, 3.4225, -13.627699, -16.7232, 0, -13.378599, -16.7232, 0, -16.113699, -16.1238, 3.3711998, -15.15, -16.3753, 3.4225, -13.627699, -13.378601, 0, -17.3364, -15.441, 3.3151, -15.753099, -16.1493, 0, -16.8284, -13.378601, 0, -17.3364, -13.3848, 3.1832, -15.7598, -15.441, 3.3151, -15.753099, -10.3452, 0, -16.7803, -10.4286995, 3.1564, -15.832399, -10.7493, 3.1581, -16.1132, -10.3452, 0, -16.7803, -9.983101, 0, -16.4067, -10.4286995, 3.1564, -15.832399, -10.3452, 0, -16.7803, -13.3848, 3.1832, -15.7598, -13.378601, 0, -17.3364, -10.3452, 0, -16.7803, -10.7493, 3.1581, -16.1132, -13.3848, 3.1832, -15.7598, -9.6168995, 0, -14.127899, -10.4286995, 3.1564, -15.832399, -9.983101, 0, -16.4067, -9.6168995, 0, -14.127899, -10.404301, 3.1608999, -13.429499, -10.4286995, 3.1564, -15.832399, -3.6946998, 0, -13.471199, -3.6475997, 3.1597998, -12.441198, -4.0183997, 3.1606, -12.819899, -3.6946998, 0, -13.471199, -3.2535, 0, -13.020899, -3.6475997, 3.1597998, -12.441198, -3.6946998, 0, -13.471199, -6.6893, 3.175, -12.866999, -6.6893, 0, -14.263699, -3.6946998, 0, -13.471199, -4.0183997, 3.1606, -12.819899, -6.6893, 3.175, -12.866999, -3.2331, 3.1696, -9.964899, -2.7220998, 0, -10.936998, -2.2352996, 0, -10.6031, -3.2331, 3.1696, -9.964899, -3.6138997, 3.1664, -10.447599, -2.7220998, 0, -10.936998, 0, 0, -10.7654, -3.2331, 3.1696, -9.964899, -2.2352996, 0, -10.6031, 0, 0, -10.7654, -0.0039999485, 3.1827998, -9.0644, -3.2331, 3.1696, -9.964899, -2.7220998, 0, -10.936998, -3.6475997, 3.1597998, -12.441198, -3.2535, 0, -13.020899, -2.7220998, 0, -10.936998, -3.6138997, 3.1664, -10.447599, -3.6475997, 3.1597998, -12.441198, 3.847, 3.1766, -9.663599, 2.2182999, 0, -10.525, 2.9573, -0.1021, -11.0952, 3.847, 3.1766, -9.663599, 3.1496, 3.1724, -9.052, 2.2182999, 0, -10.525, 3.3446, -0.3989, -12.9443, 3.847, 3.1766, -9.663599, 2.9573, -0.1021, -11.0952, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357698, 3.847, 3.1766, -9.663599, 2.2182999, 0, -10.525, -0.0039999485, 3.1827998, -9.0644, 0, 0, -10.7654, 2.2182999, 0, -10.525, 3.1496, 3.1724, -9.052, -0.0039999485, 3.1827998, -9.0644, -20.108599, 0, -9.592199, -19.176899, 3.1662998, -9.762199, -19.6161, 3.2075999, -9.711699, -20.108599, 0, -9.592199, -19.6286, 0, -10.1098995, -19.176899, 3.1662998, -9.762199, -19.6161, 3.2075999, -9.711699, -20.3764, 0, -6.6893, -20.108599, 0, -9.592199, -20.3764, 0, -6.6893, -19.6161, 3.2075999, -9.711699, -19.610699, 3.187, -9.246599, -20.3764, 0, -6.6893, -19.610699, 3.187, -9.246599, -19.515, 3.2122, -6.6893, -16.407099, 3.2108998, -10.4028, -17.8929, 0, -10.41, -17.1586, 0, -11.120298, -16.407099, 3.2108998, -10.4028, -17.0486, 3.1696, -9.782799, -17.8929, 0, -10.41, -16.7232, 0, -13.378599, -16.407099, 3.2108998, -10.4028, -17.1586, 0, -11.120298, -16.7232, 0, -13.378599, -16.3753, 3.4225, -13.627699, -16.407099, 3.2108998, -10.4028, -17.8929, 0, -10.41, -19.176899, 3.1662998, -9.762199, -19.6286, 0, -10.1098995, -17.8929, 0, -10.41, -17.0486, 3.1696, -9.782799, -19.176899, 3.1662998, -9.762199, -19.841799, 3.2077, -3.7668, -19.7545, 3.3913999, -2.7851996, -19.9489, 3.2063, -2.8082, -19.841799, 3.2077, -3.7668, -21.0086, -0.2504, -3.5043998, -20.1607, -0.2436, -4.5060997, -19.841799, 3.2077, -3.7668, -19.9489, 3.2063, -2.8082, -21.0086, -0.2504, -3.5043998, -20.3764, 0, -6.6893, -19.841799, 3.2077, -3.7668, -20.1607, -0.2436, -4.5060997, -20.3764, 0, -6.6893, -19.515, 3.2122, -6.6893, -19.841799, 3.2077, -3.7668, -22.9314, 3.3177998, -2.7092, -23.412498, -0.0385, -2.8684, -22.9552, -0.047599997, -3.375, -22.9314, 3.3177998, -2.7092, -23.3864, 3.3542, -2.2045999, -23.412498, -0.0385, -2.8684, -23.412498, -0.0385, -2.8684, -23.3676, 3.4459999, 0.1372, -23.412498, -0.2913, 0.26119995, -23.412498, -0.0385, -2.8684, -23.3864, 3.3542, -2.2045999, -23.3676, 3.4459999, 0.1372, -21.0086, -0.2504, -3.5043998, -22.9314, 3.3177998, -2.7092, -22.9552, -0.047599997, -3.375, -21.0086, -0.2504, -3.5043998, -19.9489, 3.2063, -2.8082, -22.9314, 3.3177998, -2.7092, -22.756899, 0, 10.033899, -22.7643, 3.2136, 9.1159, -22.5907, 3.2112, 9.440499, -22.756899, 0, 10.033899, -23.489, 0, 9.386499, -22.7643, 3.2136, 9.1159, -23.8078, 0, 6.6893, -22.7643, 3.2136, 9.1159, -23.489, 0, 9.386499, -23.8078, 0, 6.6893, -22.563799, 3.2219, 6.6893, -22.7643, 3.2136, 9.1159, -16.7232, 0, 10.8878, -17.1359, 3.192, 9.715799, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.8878, -17.583, 0, 10.033899, -17.1359, 3.192, 9.715799, -20.0679, 0, 10.033899, -17.1359, 3.192, 9.715799, -17.583, 0, 10.033899, -20.0679, 0, 10.033899, -20.0679, 3.1943, 9.407499, -17.1359, 3.192, 9.715799, -16.111599, 3.066, 12.2328, -16.223, 0, 13.378601, -16.7232, 0, 12.878401, -16.111599, 3.066, 12.2328, -15.5713005, 3.0688999, 12.748899, -16.223, 0, 13.378601, -16.223, 0, 13.378601, -13.378601, 3.194, 12.808901, -13.378601, 0, 13.378601, -16.223, 0, 13.378601, -15.5713005, 3.0688999, 12.748899, -13.378601, 3.194, 12.808901, -16.7232, 0, 10.8878, -16.111599, 3.066, 12.2328, -16.7232, 0, 12.878401, -16.7232, 0, 10.8878, -16.2818, 3.1913998, 9.8056, -16.111599, 3.066, 12.2328, -6.6893, 0, 13.8773, -6.7777, 3.1009, 13.0326, -6.2748, 3.126, 13.622, -6.6893, 0, 13.8773, -7.1966996, 0, 13.378601, -6.7777, 3.1009, 13.0326, -10.033899, 0, 13.378601, -6.7777, 3.1009, 13.0326, -7.1966996, 0, 13.378601, -10.033899, 0, 13.378601, -10.033899, 2.9384, 12.453701, -6.7777, 3.1009, 13.0326, -6.1093993, 0, 16.7232, -6.2748, 3.1260998, 16.1257, -6.0452, 3.135, 16.5325, -6.1093993, 0, 16.7232, -6.6893, 0, 16.1455, -6.2748, 3.1260998, 16.1257, -6.1093993, 0, 16.7232, -3.3446002, 3.1497998, 15.9146, -3.3446002, 0, 16.7232, -6.1093993, 0, 16.7232, -6.0452, 3.135, 16.5325, -3.3446002, 3.1497998, 15.9146, -6.6893, 0, 13.8773, -6.2748, 3.1260998, 16.1257, -6.6893, 0, 16.1455, -6.6893, 0, 13.8773, -6.2748, 3.126, 13.622, -6.2748, 3.1260998, 16.1257, 3.3446, 0, 17.592, 2.777, 3.0992, 16.3951, 3.6736, 3.1532, 16.5721, 3.3446, 0, 17.592, 2.4738998, 0, 16.7232, 2.777, 3.0992, 16.3951, 0, 0, 16.7232, 2.777, 3.0992, 16.3951, 2.4738998, 0, 16.7232, 0, 0, 16.7232, 0, 2.9636998, 16.0179, 2.777, 3.0992, 16.3951, 3.3446, 0, 17.592, 3.8212998, 3.3128, 18.735199, 3.3446, 0, 19.1425, 3.3446, 0, 17.592, 3.6736, 3.1532, 16.5721, 3.8212998, 3.3128, 18.735199, 4.275, 0, 20.0679, 6.7795997, 3.1271, 19.243599, 6.6893, 0, 20.0679, 4.275, 0, 20.0679, 4.8012, 3.3019998, 19.5215, 6.7795997, 3.1271, 19.243599, 3.8212998, 3.3128, 18.735199, 4.275, 0, 20.0679, 3.3446, 0, 19.1425, 3.8212998, 3.3128, 18.735199, 4.8012, 3.3019998, 19.5215, 4.275, 0, 20.0679, -22.756899, 0, 10.033899, -20.0679, 3.1943, 9.407499, -20.0679, 0, 10.033899, -22.756899, 0, 10.033899, -22.5907, 3.2112, 9.440499, -20.0679, 3.1943, 9.407499, 10.572599, 0, 19.5895, 8.8245, 3.2012, 19.0993, 9.5278, 3.2163, 18.6607, 10.572599, 0, 19.5895, 10.080299, 0, 20.0679, 8.8245, 3.2012, 19.0993, 6.6893, 0, 20.0679, 8.8245, 3.2012, 19.0993, 10.080299, 0, 20.0679, 6.6893, 0, 20.0679, 6.7795997, 3.1271, 19.243599, 8.8245, 3.2012, 19.0993, 10.572599, 0, 19.5895, 10.5903, 3.2052, 16.7169, 10.572599, 0, 16.7232, 10.572599, 0, 19.5895, 9.5278, 3.2163, 18.6607, 10.5903, 3.2052, 16.7169, 10.986898, 0, 13.9188, 10.122399, 3.1906, 13.826099, 10.462799, 3.2359, 13.406799, 10.986898, 0, 13.9188, 10.572599, 0, 14.3402, 10.122399, 3.1906, 13.826099, 10.572599, 0, 16.7232, 10.122399, 3.1906, 13.826099, 10.572599, 0, 14.3402, 10.572599, 0, 16.7232, 10.5903, 3.2052, 16.7169, 10.122399, 3.1906, 13.826099, 13.8046, 0, 13.2202, 12.5756, 3.4812999, 13.4049, 13.2631, 3.3165998, 13.404301, 13.8046, 0, 13.2202, 13.105099, 0, 13.9188, 12.5756, 3.4812999, 13.4049, 13.2631, 3.3165998, 13.404301, 13.8046, 0, 10.033899, 13.8046, 0, 13.2202, 13.8046, 0, 10.033899, 13.2631, 3.3165998, 13.404301, 13.1894, 3.2312999, 12.539801, 13.8046, 0, 10.033899, 13.1894, 3.2312999, 12.539801, 12.9783, 3.2031, 10.0168, 10.986898, 0, 13.9188, 12.5756, 3.4812999, 13.4049, 13.105099, 0, 13.9188, 10.986898, 0, 13.9188, 10.462799, 3.2359, 13.406799, 12.5756, 3.4812999, 13.4049, 13.8046, 0, 10.033899, 13.175798, 2.4743, 8.042, 14.0257, 0.0442, 7.3252997, 13.8046, 0, 10.033899, 12.9783, 3.2031, 10.0168, 13.175798, 2.4743, 8.042, 20.1399, 2.779, -3.3615003, 21.09, -0.0218, -4.9179, 20.613499, 1.0832, -3.1922002, 21.09, -0.0218, -4.9179, 20.1399, 2.779, -3.3615003, 20.147, 3.0588999, -6.6893, 21.09, -0.0218, -4.9179, 20.147, 3.0588999, -6.6893, 21.2349, 0, -6.6893, 15.6619, 2.2603, -2.7998996, 20.1399, 2.779, -3.3615003, 20.613499, 1.0832, -3.1922002, 20.1399, 2.779, -3.3615003, 15.6619, 2.2603, -2.7998996, 16.7127, 2.8734, -3.3446002, 16.7127, 2.8734, -3.3446002, 15.6619, 2.2603, -2.7998996, 15.810999, 2.9368, -3.3059, 13.249599, 2.9931998, -3.4737, 15.810999, 2.9368, -3.3059, 14.217899, 2.8779, -3.3838, 14.217899, 2.8779, -3.3838, 15.810999, 2.9368, -3.3059, 15.6619, 2.2603, -2.7998996, -16.7232, 3.0033998, 2.9164, -15.754801, 0.6137, 3.5372, -15.372101, 3.0033998, 3.6132, -16.7232, 3.0033998, 2.9164, -16.7232, 0.6137, 2.9164, -15.754801, 0.6137, 3.5372, -14.489201, 3.0033998, 4.2777, -13.6772, 0.6137, 5.2616, -13.694401, 3.0033998, 5.1798, -14.489201, 3.0033998, 4.2777, -14.489201, 0.6137, 4.2777, -13.6772, 0.6137, 5.2616, -15.803801, 3.0033998, -2.7093, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -15.803801, 3.0033998, -2.7093, -15.803801, 0.6137, -2.7093, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 2.9164, -16.7232, 3.0033998, 2.9164, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 0, -16.7232, 0.6137, 2.9164, -3.3446999, 3.0033998, 0, -4.0259, 0.6137, -1.6911998, -4.0259, 3.0033998, -1.6911998, -3.3446999, 3.0033998, 0, -3.3446999, 0.6137, 0, -4.0259, 0.6137, -1.6911998, -3.3446999, 3.0033998, 3.3446, -3.3446999, 0.6137, 0, -3.3446999, 3.0033998, 0, -3.3446999, 3.0033998, 3.3446, -3.3446999, 0.6137, 3.3446, -3.3446999, 0.6137, 0, -12.7477, 3.0033998, 6.3669996, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, 6.6893, -12.7477, 3.0033998, 6.3669996, -12.7477, 0.6137, 6.3669996, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, -3.3446002, -13.378601, 0.6137, -3.3446002, -13.378601, 3.0033998, -3.3446002, -10.033899, 3.0033998, -3.3446002, -10.033899, 0.6137, -3.3446002, -13.378601, 0.6137, -3.3446002, -6.6893, 3.0033998, -3.3446002, -10.033899, 0.6137, -3.3446002, -10.033899, 3.0033998, -3.3446002, -6.6893, 3.0033998, -3.3446002, -6.6893, 0.6137, -3.3446002, -10.033899, 0.6137, -3.3446002, -13.378601, 3.0033998, -3.3446002, -15.803801, 0.6137, -2.7093, -15.803801, 3.0033998, -2.7093, -13.378601, 3.0033998, -3.3446002, -13.378601, 0.6137, -3.3446002, -15.803801, 0.6137, -2.7093, -4.0259, 3.0033998, -1.6911998, -6.6893, 0.6137, -3.3446002, -6.6893, 3.0033998, -3.3446002, -4.0259, 3.0033998, -1.6911998, -4.0259, 0.6137, -1.6911998, -6.6893, 0.6137, -3.3446002, -6.6893, 3.0033998, 6.6893, -2.5675998, 0.0266, 7.6215, -2.5675998, 2.9782, 7.6215, -6.6893, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -2.5675998, 0.0266, 7.6215, -10.033899, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -6.6893, 3.0033998, 6.6893, -10.033899, 3.0033998, 6.6893, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 6.6893, -2.5675998, 2.9782, 7.6215, -2.1193, 0.6137, 5.2736, -2.1397996, 3.0033998, 5.1794, -2.5675998, 2.9782, 7.6215, -2.5675998, 0.0266, 7.6215, -2.1193, 0.6137, 5.2736, -2.1193, 0.6137, 5.2736, -3.3446999, 3.0033998, 3.3446, -2.1397996, 3.0033998, 5.1794, -2.1193, 0.6137, 5.2736, -3.3446999, 0.6137, 3.3446, -3.3446999, 3.0033998, 3.3446, -13.6772, 0.6137, 5.2616, -12.7477, 3.0033998, 6.3669996, -13.694401, 3.0033998, 5.1798, -13.6772, 0.6137, 5.2616, -12.7477, 0.6137, 6.3669996, -12.7477, 3.0033998, 6.3669996, -15.754801, 0.6137, 3.5372, -14.489201, 3.0033998, 4.2777, -15.372101, 3.0033998, 3.6132, -15.754801, 0.6137, 3.5372, -14.489201, 0.6137, 4.2777, -14.489201, 3.0033998, 4.2777, -3.3446002, -1.4008, -23.412498, 0, -1.0608, -26.7572, 0, -0.9271, -23.412498, -3.3446002, -1.4008, -23.412498, -3.3446002, -1.458, -26.7572, 0, -1.0608, -26.7572, -16.7232, -1.7572, -23.412498, -13.378601, -2.7663999, -26.7572, -13.378601, -1.3177999, -23.412498, -16.7232, -1.7572, -23.412498, -16.7232, -3.0929, -26.7572, -13.378601, -2.7663999, -26.7572, 10.033899, 0, -23.412498, 13.378599, -0.8401, -26.7572, 13.378599, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, -0.8648, -26.7572, 13.378599, -0.8401, -26.7572, 3.3446, -0.3434, -23.412498, 6.6893, -1.0129, -26.7572, 6.6893, 0, -23.412498, 3.3446, -0.3434, -23.412498, 3.3446, -0.98829997, -26.7572, 6.6893, -1.0129, -26.7572, -23.412498, -2.7663999, -23.412498, -20.0679, -3.0929, -26.7572, -20.0679, -2.7663999, -23.412498, -23.412498, -2.7663999, -23.412498, -23.412498, -3.0929, -26.7572, -20.0679, -3.0929, -26.7572, -26.7572, -2.7663999, -23.412498, -23.412498, -3.0929, -26.7572, -23.412498, -2.7663999, -23.412498, -26.7572, -2.7663999, -23.412498, -26.7572, -3.0929, -26.7572, -23.412498, -3.0929, -26.7572, -23.412498, -0.7606, -16.7232, -20.0679, -1.7572, -20.0679, -20.0679, -0.43429998, -16.7232, -23.412498, -0.7606, -16.7232, -23.412498, -2.7663999, -20.0679, -20.0679, -1.7572, -20.0679, -10.033899, -0.2814, 23.412498, -6.6893, 0, 20.0679, -6.6893, -0.2814, 23.412498, -6.6893, -0.2814, 23.412498, -6.6893, 0, 20.0679, -3.3446002, 0, 20.0679, -10.033899, -0.2814, 23.412498, -10.033899, 0, 20.0679, -6.6893, 0, 20.0679, -6.6893, -0.2814, 23.412498, -3.3446002, 0, 20.0679, -3.3446002, -0.2814, 23.412498, -3.3446002, -0.2814, 23.412498, -3.3446002, 0, 20.0679, 0, 0, 20.0679, -3.3446002, -0.2814, 23.412498, 0, 0, 20.0679, 0, -0.2814, 23.412498, -13.378601, 0, 20.0679, -10.033899, 0, 20.0679, -10.033899, -0.2814, 23.412498, -13.378601, 0, 20.0679, -10.033899, -0.2814, 23.412498, -12.357901, -0.2814, 23.412498, -13.378601, -0.2814, 23.412498, -13.378601, 0, 20.0679, -12.357901, -0.2814, 23.412498, -23.412498, -0.36479998, 16.7232, -23.412498, 0, 13.378601, -20.0679, -0.36479998, 16.7232, -23.412498, 0, 13.378601, -20.0679, 0, 13.378601, -20.0679, -0.36479998, 16.7232, -23.412498, -0.7373, 23.412498, -23.412498, -0.692, 20.0679, -20.0679, -0.5589, 23.412498, -16.7232, -0.36479998, 23.412498, -16.7232, -0.36479998, 20.0679, -13.378601, -0.2814, 23.412498, 16.7232, -1.0746, 16.7232, 20.0679, -1.0746, 13.378601, 20.0679, -1.8294, 16.7232, 20.0679, -1.8294, 16.7232, 20.0679, -1.0746, 13.378601, 23.412498, -1.8294, 13.378601, 16.7232, -1.8294, 20.0679, 16.7232, -1.0746, 16.7232, 20.0679, -1.8294, 16.7232, 16.7232, -2.2719998, 23.3902, 20.0679, -1.8294, 20.0679, 20.0679, -2.2719998, 23.3902, 16.7232, -2.2719998, 23.3902, 16.7232, -1.8294, 20.0679, 20.0679, -1.8294, 20.0679, 16.7232, -1.8294, 20.0679, 16.7232, -2.2719998, 23.3902, 13.378599, -2.2719998, 23.3902, 20.0679, -2.2719998, 23.3902, 20.0679, -1.8294, 20.0679, 23.412498, -1.8294, 20.0679, 20.0679, -2.2719998, 23.3902, 23.412498, -1.8294, 20.0679, 23.412498, -2.2719998, 23.3902, 23.412498, -1.8294, 20.0679, 26.7582, -2.2719998, 23.3902, 23.412498, -2.2719998, 23.3902, 23.412498, -1.8294, 20.0679, 26.7582, -1.8294, 20.0679, 26.7582, -2.2719998, 23.3902, -10.033899, -0.9271, -23.412498, -6.6893, -1.458, -26.7572, -6.6893, -1.3241999, -23.412498, -10.033899, -0.9271, -23.412498, -10.033899, -1.8786, -26.7572, -6.6893, -1.458, -26.7572, -3.3446002, -0.9271, -16.7232, 0, -0.9271, -20.0679, 0, -0.9271, -16.7232, -3.3446002, -0.9271, -16.7232, -3.3446002, -0.9271, -20.0679, 0, -0.9271, -20.0679, -6.6893, -0.9271, -20.0679, -3.3446002, -0.9271, -20.0679, -3.3446002, -0.9271, -16.7232, 16.7232, -0.3105, -23.412498, 16.7232, -1.3382, -26.7572, 20.0679, -0.8222, -23.412498, 20.0679, 0, -16.7232, 20.0679, -0.2837, -20.0679, 23.412498, -0.456, -16.7232, 13.378599, 0, -23.412498, 13.378599, -0.8401, -26.7572, 16.7232, -0.3105, -23.412498, 16.7232, 0, -20.0679, 16.7232, -0.3105, -23.412498, 20.0679, -0.2837, -20.0679, -6.6893, 0, -16.7232, -6.6893, -0.9271, -20.0679, -3.3446002, -0.9271, -16.7232, -13.378601, -1.3177999, -23.412498, -10.033899, -1.8786, -26.7572, -10.033899, -0.9271, -23.412498, -13.378601, -1.3177999, -23.412498, -13.378601, -2.7663999, -26.7572, -10.033899, -1.8786, -26.7572, -13.210501, -0.3964, -20.032099, -10.033899, -0.9271, -23.412498, -9.318001, -0.3969, -19.413, -13.210501, -0.3964, -20.032099, -13.378601, -1.3177999, -23.412498, -10.033899, -0.9271, -23.412498, -9.318001, -0.3969, -19.413, -10.033899, -0.9271, -23.412498, -6.6893, -0.9271, -20.0679, 20.0679, -1.0746, 13.378601, 20.0679, 0, 10.033899, 23.412498, -0.35529998, 10.033899, 13.378599, -2.2719998, 23.3902, 16.7232, -2.2194998, 26.781, 13.378599, -2.2194998, 26.781, 13.378599, -2.2719998, 23.3902, 16.7232, -2.2719998, 23.3902, 16.7232, -2.2194998, 26.781, 16.7232, -2.2194998, 26.781, 16.7232, -2.2719998, 23.3902, 20.0679, -2.2719998, 23.3902, 16.7232, -2.2194998, 26.781, 20.0679, -2.2719998, 23.3902, 20.0679, -2.2194998, 26.781, 20.0679, -2.2194998, 26.781, 20.0679, -2.2719998, 23.3902, 23.412498, -2.2719998, 23.3902, 20.0679, -2.2194998, 26.781, 23.412498, -2.2719998, 23.3902, 23.412498, -2.2194998, 26.781, 13.378599, -1.0746, 20.0679, 15.0508995, -0.40039998, 16.7232, 16.7232, -1.0746, 16.7232, 16.7232, -1.0746, 13.378601, 16.7232, 0, 10.033899, 20.0679, 0, 10.033899, 20.0679, 0, 10.033899, 20.0679, -1.0746, 13.378601, 16.7232, -1.0746, 13.378601, 6.6893, -1.1209999, 26.7572, 10.033899, -0.501, 23.412498, 10.033899, -1.1209999, 26.7572, 6.6893, -1.1209999, 26.7572, 6.6893, -0.2814, 23.412498, 10.033899, -0.501, 23.412498, 10.033899, -1.1209999, 26.7572, 13.378599, -2.2719998, 23.3902, 13.378599, -2.2194998, 26.781, 10.033899, -1.1209999, 26.7572, 10.033899, -0.501, 23.412498, 13.378599, -2.2719998, 23.3902, 0, -1.1209999, 26.7572, 3.3446, -0.2814, 23.412498, 3.3446, -1.1209999, 26.7572, 3.3446, -1.1209999, 26.7572, 3.3446, -0.2814, 23.412498, 6.6893, -0.2814, 23.412498, 3.3446, -1.1209999, 26.7572, 6.6893, -0.2814, 23.412498, 6.6893, -1.1209999, 26.7572, 0, -1.1209999, 26.7572, 0, -0.2814, 23.412498, 3.3446, -0.2814, 23.412498, -3.3446002, -1.1209999, 26.7572, 0, -0.2814, 23.412498, 0, -1.1209999, 26.7572, -3.3446002, -1.1209999, 26.7572, -3.3446002, -0.2814, 23.412498, 0, -0.2814, 23.412498, -6.6893, -1.1209999, 26.7572, -3.3446002, -0.2814, 23.412498, -3.3446002, -1.1209999, 26.7572, -6.6893, -1.1209999, 26.7572, -6.6893, -0.2814, 23.412498, -3.3446002, -0.2814, 23.412498, -10.033899, -1.1209999, 26.7572, -6.6893, -0.2814, 23.412498, -6.6893, -1.1209999, 26.7572, -10.033899, -1.1209999, 26.7572, -10.033899, -0.2814, 23.412498, -6.6893, -0.2814, 23.412498, -10.033899, -1.1209999, 26.7572, -12.357901, -0.2814, 23.412498, -10.033899, -0.2814, 23.412498, -10.033899, -1.1209999, 26.7572, -13.378601, -0.2814, 23.412498, -12.357901, -0.2814, 23.412498, -13.378601, -0.2814, 23.412498, -10.033899, -1.1209999, 26.7572, -13.378601, -1.1209999, 26.7572, 16.7232, -1.0746, 13.378601, 15.0508995, -0.6288, 13.378601, 16.7232, 0, 10.033899, -29.294899, -0.6617, 3.3446, -25.479, -0.4588, 0.143, -25.869999, -0.2764, 3.3446, -29.294899, -0.6617, 3.3446, -27.9812, -0.6617, 0, -25.479, -0.4588, 0.143, -29.294899, -0.6617, 6.6893, -25.869999, -0.2764, 3.3446, -25.945799, -0.25779998, 6.6893, -29.294899, -0.6617, 6.6893, -29.294899, -0.6617, 3.3446, -25.869999, -0.2764, 3.3446, -20.0679, -0.5589, 23.412498, -20.0679, -0.36479998, 20.0679, -16.7232, -0.36479998, 23.412498, -20.0679, -1.1374999, 26.7572, -20.0679, -0.5589, 23.412498, -16.7232, -1.1374999, 26.7572, -16.7232, -1.1374999, 26.7572, -16.7232, -0.36479998, 23.412498, -13.378601, -1.1209999, 26.7572, -26.7572, -1.0756, 23.412498, -25.174, -0.7391, 20.0679, -23.412498, -0.7373, 23.412498, -26.7572, -1.0756, 23.412498, -26.7572, -1.0756, 20.0679, -25.174, -0.7391, 20.0679, -26.7572, -1.2701, 26.7572, -23.412498, -0.7373, 23.412498, -23.412498, -1.1374999, 26.7572, -26.7572, -1.2701, 26.7572, -26.7572, -1.0756, 23.412498, -23.412498, -0.7373, 23.412498, -23.412498, -1.1374999, 26.7572, -23.412498, -0.7373, 23.412498, -20.0679, -1.1374999, 26.7572, -29.294899, -1.0265, 16.7232, -25.9681, -0.2875, 13.378601, -25.4212, -0.5907, 16.7232, -29.294899, -1.0265, 16.7232, -29.294899, -0.6617, 13.378601, -25.9681, -0.2875, 13.378601, -25.9681, -0.2875, 13.378601, -23.412498, -0.36479998, 16.7232, -25.4212, -0.5907, 16.7232, -23.412498, -0.36479998, 16.7232, -25.9681, -0.2875, 13.378601, -23.412498, 0, 13.378601, -26.7572, -1.0756, 20.0679, -25.4212, -0.5907, 16.7232, -25.174, -0.7391, 20.0679, -26.7572, -1.0756, 20.0679, -29.294899, -1.0265, 16.7232, -25.4212, -0.5907, 16.7232, -23.412498, -0.692, 20.0679, -23.412498, -0.36479998, 16.7232, -20.0679, -0.36479998, 20.0679, -13.378601, 0, 16.7232, -10.033899, 0, 13.378601, -10.033899, 0, 16.7232, -13.378601, 0, 16.7232, -13.378601, 0, 13.378601, -10.033899, 0, 13.378601, -13.378601, 0, 20.0679, -13.378601, 0, 16.7232, -10.033899, 0, 16.7232, -13.378601, 0, 20.0679, -10.033899, 0, 16.7232, -10.033899, 0, 20.0679, -10.033899, 0, 16.7232, -6.6893, 0, 20.0679, -10.033899, 0, 20.0679, -10.033899, 0, 13.378601, -7.2641997, 0, 16.7232, -10.033899, 0, 16.7232, -6.6893, 0, 20.0679, -10.033899, 0, 16.7232, -7.2641997, 0, 16.7232, -6.6893, 0, 13.8773, -10.033899, 0, 13.378601, -7.1966996, 0, 13.378601, -7.2641997, 0, 16.7232, -10.033899, 0, 13.378601, -6.6893, 0, 13.8773, -7.2641997, 0, 16.7232, -6.6893, 0, 13.8773, -6.6893, 0, 16.1455, -6.1093993, 0, 16.7232, -7.2641997, 0, 16.7232, -6.6893, 0, 16.1455, -6.6893, 0, 20.0679, -7.2641997, 0, 16.7232, -6.6893, 0, 17.298199, -6.1093993, 0, 16.7232, -6.6893, 0, 17.298199, -7.2641997, 0, 16.7232, -6.6893, 0, 17.298199, -3.3446002, 0, 20.0679, -6.6893, 0, 20.0679, -3.3446002, 0, 20.0679, -6.6893, 0, 17.298199, -6.1093993, 0, 16.7232, -3.3446002, 0, 20.0679, -6.1093993, 0, 16.7232, -3.3446002, 0, 16.7232, -3.3446002, 0, 20.0679, -3.3446002, 0, 16.7232, 0, 0, 16.7232, -3.3446002, 0, 20.0679, 0, 0, 16.7232, 0, 0, 20.0679, 0, 0, 16.7232, 2.4261, 0, 20.0679, 0, 0, 20.0679, 2.4261, 0, 20.0679, 0, 0, 16.7232, 3.3446, 0, 17.592, 3.3446, 0, 17.592, 0, 0, 16.7232, 2.4738998, 0, 16.7232, 2.4261, 0, 20.0679, 3.3446, 0, 17.592, 3.3446, 0, 19.1425, 4.275, 0, 20.0679, 2.4261, 0, 20.0679, 3.3446, 0, 19.1425, 4.275, 0, 20.0679, 3.3446, 0, 20.986399, 2.4261, 0, 20.0679, -26.7572, -1.6710999, -17.1511, -23.412498, -2.7663999, -20.0679, -23.412498, -0.7606, -16.7232, -26.7572, -1.6710999, -17.1511, -26.7572, -1.6710999, -20.4958, -23.412498, -2.7663999, -20.0679, -29.294899, -0.6617, -13.378599, -23.412498, -0.7606, -16.7232, -23.412498, 0, -13.378599, -29.294899, -0.6617, -13.378599, -26.7572, -1.6710999, -17.1511, -23.412498, -0.7606, -16.7232, -23.412498, 0, -13.378599, -23.412498, -0.7606, -16.7232, -20.0679, -0.43429998, -16.7232, -26.7572, -1.6710999, -20.4958, -23.412498, -2.7663999, -23.412498, -23.412498, -2.7663999, -20.0679, -26.7572, -1.6710999, -20.4958, -26.7572, -2.7663999, -23.412498, -23.412498, -2.7663999, -23.412498, -23.412498, -2.7663999, -20.0679, -20.0679, -2.7663999, -23.412498, -20.0679, -1.7572, -20.0679, -23.412498, -2.7663999, -20.0679, -23.412498, -2.7663999, -23.412498, -20.0679, -2.7663999, -23.412498, -29.294899, -0.6617, -10.033899, -23.412498, 0, -13.378599, -23.412498, 0, -10.033899, -29.294899, -0.6617, -10.033899, -29.294899, -0.6617, -13.378599, -23.412498, 0, -13.378599, -29.294899, -0.6617, -6.6893, -29.294899, -0.6617, -10.033899, -23.412498, 0, -10.033899, -29.294899, -0.6617, -6.6893, -23.412498, 0, -10.033899, -23.412498, 0, -6.6893, 0, 0, -13.378599, 0, -0.9271, -16.7232, 3.0140998, 0, -16.7232, 0, -0.9271, -23.412498, 3.3446, -0.98829997, -26.7572, 3.3446, -0.3434, -23.412498, 0, -0.9271, -23.412498, 0, -1.0608, -26.7572, 3.3446, -0.98829997, -26.7572, 21.2349, 0, -6.6893, 23.412498, 0, -10.033899, 23.412498, 0, -6.6893, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 23.412498, 0, -10.033899, 21.8888, 0, -10.033899, 21.5122, 0, -13.6136, 23.412498, 0, -10.033899, 21.8888, 0, -10.033899, 21.251299, 0, -13.3043995, 21.5122, 0, -13.6136, 21.5122, 0, -13.6136, 23.412498, 0, -13.378599, 23.412498, 0, -10.033899, 20.809599, 0, -13.7309, 21.5122, 0, -13.6136, 21.251299, 0, -13.3043995, 21.0698, 0, -13.966099, 23.412498, 0, -13.378599, 21.5122, 0, -13.6136, 20.809599, 0, -13.7309, 21.0698, 0, -13.966099, 21.5122, 0, -13.6136, 21.0698, 0, -13.966099, 20.0679, 0, -16.7232, 23.412498, 0, -13.378599, 20.809599, 0, -13.7309, 18.053999, 0, -16.7802, 21.0698, 0, -13.966099, 18.053999, 0, -16.7802, 20.0679, 0, -16.7232, 21.0698, 0, -13.966099, 20.809599, 0, -13.7309, 17.503, 0, -15.940498, 18.053999, 0, -16.7802, 17.503, 0, -15.940498, 20.809599, 0, -13.7309, 18.0241, 0, -14.301899, 16.3113, 0, -16.9629, 18.053999, 0, -16.7802, 17.503, 0, -15.940498, 17.1022, 0, -17.6197, 20.0679, 0, -16.7232, 18.053999, 0, -16.7802, 16.3113, 0, -16.9629, 17.1022, 0, -17.6197, 18.053999, 0, -16.7802, 17.1022, 0, -17.6197, 16.7232, 0, -20.0679, 20.0679, 0, -16.7232, 16.3113, 0, -16.9629, 13.748098, 0, -20.540499, 17.1022, 0, -17.6197, 13.748098, 0, -20.540499, 16.7232, 0, -20.0679, 17.1022, 0, -17.6197, 16.3113, 0, -16.9629, 13.423399, 0, -20.1574, 13.748098, 0, -20.540499, 13.423399, 0, -20.1574, 16.3113, 0, -16.9629, 13.701399, 0, -17.416399, 12.961399, 0, -20.5828, 13.748098, 0, -20.540499, 13.423399, 0, -20.1574, 13.378599, 0, -20.9734, 16.7232, 0, -20.0679, 13.748098, 0, -20.540499, 12.961399, 0, -20.5828, 13.378599, 0, -20.9734, 13.748098, 0, -20.540499, 13.378599, 0, -20.9734, 13.378599, 0, -23.412498, 16.7232, 0, -20.0679, 10.033899, 0, -20.467, 13.378599, 0, -20.9734, 12.961399, 0, -20.5828, 10.033899, 0, -23.412498, 13.378599, 0, -23.412498, 13.378599, 0, -20.9734, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.378599, 0, -20.9734, 6.6893, 0, -20.1318, 10.033899, 0, -23.412498, 10.033899, 0, -20.467, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -23.412498, 6.6893, 0, -23.412498, 6.6893, 0, -20.1318, 4.4523, 0, -20.444399, 6.6893, 0, -23.412498, 4.4523, 0, -20.444399, 3.3446, 0, -21.6384, 3.2372, 0, -19.3395, 3.3446, 0, -21.6384, 4.4523, 0, -20.444399, 3.2372, 0, -19.3395, 2.3184, 0, -20.4362, 3.3446, 0, -21.6384, 6.6893, 0, -23.412498, 10.033899, -0.8648, -26.7572, 10.033899, 0, -23.412498, 6.6893, 0, -23.412498, 6.6893, -1.0129, -26.7572, 10.033899, -0.8648, -26.7572, -20.0679, -2.7663999, -23.412498, -16.7232, -3.0929, -26.7572, -16.7232, -1.7572, -23.412498, -20.0679, -2.7663999, -23.412498, -20.0679, -3.0929, -26.7572, -16.7232, -3.0929, -26.7572, -20.0679, -1.7572, -20.0679, -16.7232, -1.7572, -23.412498, -17.2383, -0.66719997, -19.6364, -20.0679, -1.7572, -20.0679, -20.0679, -2.7663999, -23.412498, -16.7232, -1.7572, -23.412498, -17.2383, -0.66719997, -19.6364, -13.378601, -1.3177999, -23.412498, -13.210501, -0.3964, -20.032099, -17.2383, -0.66719997, -19.6364, -16.7232, -1.7572, -23.412498, -13.378601, -1.3177999, -23.412498, -20.0679, -0.36479998, 20.0679, -20.0679, -0.36479998, 16.7232, -16.7232, -0.36479998, 20.0679, -23.412498, -0.36479998, 16.7232, -20.0679, -0.36479998, 16.7232, -20.0679, -0.36479998, 20.0679, -20.0679, -0.36479998, 20.0679, -16.7232, -0.36479998, 20.0679, -16.7232, -0.36479998, 23.412498, -16.7232, -0.36479998, 20.0679, -16.7232, -0.26209998, 16.7232, -13.378601, 0, 20.0679, 20.0679, -1.8294, 20.0679, 23.412498, -1.8294, 16.7232, 23.412498, -1.8294, 20.0679, 23.412498, -1.8294, 16.7232, 26.7582, -1.8294, 20.0679, 23.412498, -1.8294, 20.0679, 23.412498, -1.8294, 16.7232, 26.7582, -1.8294, 16.7232, 26.7582, -1.8294, 20.0679, 20.0679, -1.8294, 20.0679, 20.0679, -1.8294, 16.7232, 23.412498, -1.8294, 16.7232, 20.0679, -1.8294, 16.7232, 20.0679, -1.8294, 20.0679, 16.7232, -1.8294, 20.0679, 23.412498, -1.8294, 13.378601, 23.412498, -1.8294, 16.7232, 20.0679, -1.8294, 16.7232, 23.412498, -1.8294, 13.378601, 26.7582, -1.8294, 16.7232, 23.412498, -1.8294, 16.7232, 23.412498, -1.8294, 13.378601, 26.7582, -1.8294, 13.378601, 26.7582, -1.8294, 16.7232, -6.6893, -1.3241999, -23.412498, -3.3446002, -1.458, -26.7572, -3.3446002, -1.4008, -23.412498, -6.6893, -1.3241999, -23.412498, -6.6893, -1.458, -26.7572, -3.3446002, -1.458, -26.7572, -6.6893, -0.9271, -20.0679, -3.3446002, -1.4008, -23.412498, -3.3446002, -0.9271, -20.0679, -6.6893, -0.9271, -20.0679, -6.6893, -1.3241999, -23.412498, -3.3446002, -1.4008, -23.412498, -3.3446002, -0.9271, -20.0679, 0, -0.9271, -23.412498, 0, -0.9271, -20.0679, -3.3446002, -0.9271, -20.0679, -3.3446002, -1.4008, -23.412498, 0, -0.9271, -23.412498, 20.0679, -0.8222, -23.412498, 23.412498, -1.1158999, -26.7572, 23.412498, -1.0924, -23.412498, 20.0679, -0.8222, -23.412498, 20.0679, -1.1922, -26.7572, 23.412498, -1.1158999, -26.7572, 20.0679, -0.2837, -20.0679, 20.0679, -0.8222, -23.412498, 23.412498, -1.0924, -20.0679, 26.7573, -1.3222, -26.7572, 29.564798, -1.6410999, -23.412498, 26.7582, -1.0924, -23.412498, 26.7573, -1.3222, -26.7572, 29.3928, -1.796, -26.7572, 29.564798, -1.6410999, -23.412498, 13.378599, -1.0746, 20.0679, 16.7232, -1.8294, 20.0679, 13.378599, -2.2719998, 23.3902, 13.378599, -1.0746, 20.0679, 15.0508995, -1.0746, 20.0679, 16.7232, -1.8294, 20.0679, 0, -0.9271, -20.0679, 3.3446, 0, -21.6384, 2.3184, 0, -20.4362, 0, -0.9271, -20.0679, 3.3446, -0.3434, -23.412498, 3.3446, 0, -21.6384, 0, -0.9271, -16.7232, 3.2372, 0, -19.3395, 3.0140998, 0, -16.7232, 0, -0.9271, -16.7232, 2.3184, 0, -20.4362, 3.2372, 0, -19.3395, 3.3446, 0, -21.6384, 3.3446, -0.3434, -23.412498, 6.6893, 0, -23.412498, -16.7232, 0, -16.113699, -16.7232, 0, -17.2664, -16.1493, 0, -16.8284, -16.7232, 0, -16.113699, -17.2664, 0, -16.7232, -16.7232, 0, -17.2664, -20.0679, -0.43429998, -16.7232, -16.7232, 0, -17.2664, -17.2664, 0, -16.7232, -20.0679, -0.43429998, -16.7232, -17.2383, -0.66719997, -19.6364, -16.7232, 0, -17.2664, -20.0679, -0.43429998, -16.7232, -16.7232, 0, -13.378599, -20.0679, 0, -13.378599, -16.7232, 0, -13.378599, -20.0679, -0.43429998, -16.7232, -17.2664, 0, -16.7232, -16.7232, 0, -13.378599, -17.2664, 0, -16.7232, -16.7232, 0, -16.113699, -17.2383, -0.66719997, -19.6364, -16.1493, 0, -16.8284, -16.7232, 0, -17.2664, -16.1493, 0, -16.8284, -17.2383, -0.66719997, -19.6364, -13.210501, -0.3964, -20.032099, -16.1493, 0, -16.8284, -13.210501, -0.3964, -20.032099, -13.378601, 0, -17.3364, -10.3452, 0, -16.7803, -9.7488, 0, -16.7232, -9.983101, 0, -16.4067, -10.3452, 0, -16.7803, -10.033899, 0, -17.008299, -9.7488, 0, -16.7232, -6.6893, 0, -14.263699, -9.983101, 0, -16.4067, -9.7488, 0, -16.7232, -9.983101, 0, -16.4067, -6.6893, 0, -14.263699, -9.6168995, 0, -14.127899, -6.6893, 0, -14.263699, -9.7488, 0, -16.7232, -6.6893, 0, -16.7232, -6.6893, 0, -14.263699, -6.6893, 0, -16.7232, -3.3446002, 0, -13.7127, -6.6893, 0, -14.263699, -3.3446002, 0, -13.7127, -3.6946998, 0, -13.471199, -3.6946998, 0, -13.471199, -3.3446002, 0, -13.7127, -3.0105996, 0, -13.378599, 0, 0, -13.378599, -3.0105996, 0, -13.378599, -3.3446002, 0, -13.7127, -3.6946998, 0, -13.471199, -3.0105996, 0, -13.378599, -3.2535, 0, -13.020899, 0, 0, -13.378599, -3.2535, 0, -13.020899, -3.0105996, 0, -13.378599, -2.2352996, 0, -10.6031, -3.2535, 0, -13.020899, 0, 0, -13.378599, -3.2535, 0, -13.020899, -2.2352996, 0, -10.6031, -2.7220998, 0, -10.936998, -2.2352996, 0, -10.6031, 0, 0, -13.378599, 0, 0, -10.7654, -13.210501, -0.3964, -20.032099, -10.3452, 0, -16.7803, -13.378601, 0, -17.3364, -10.3452, 0, -16.7803, -13.210501, -0.3964, -20.032099, -9.318001, -0.3969, -19.413, -10.3452, 0, -16.7803, -9.318001, -0.3969, -19.413, -10.033899, 0, -17.008299, -10.033899, 0, -17.008299, -6.6893, 0, -16.7232, -9.7488, 0, -16.7232, -10.033899, 0, -17.008299, -9.318001, -0.3969, -19.413, -6.6893, 0, -16.7232, -3.3446002, 0, -13.7127, 0, -0.9271, -16.7232, 0, 0, -13.378599, -3.3446002, 0, -13.7127, -3.3446002, -0.9271, -16.7232, 0, -0.9271, -16.7232, 0, 0, -13.378599, 2.2182999, 0, -10.525, 0, 0, -10.7654, 2.2182999, 0, -10.525, 0, 0, -13.378599, 3.3446, -0.3989, -12.9443, 2.2182999, 0, -10.525, 3.3446, -0.3989, -12.9443, 2.9573, -0.1021, -11.0952, -20.108599, 0, -9.592199, -20.0679, 0, -10.476398, -19.6286, 0, -10.1098995, -20.108599, 0, -9.592199, -20.5103, 0, -10.033899, -20.0679, 0, -10.476398, -20.3764, 0, -6.6893, -20.5103, 0, -10.033899, -20.108599, 0, -9.592199, -20.0679, 0, -13.378599, -19.6286, 0, -10.1098995, -20.0679, 0, -10.476398, -20.5103, 0, -10.033899, -20.0679, 0, -13.378599, -20.0679, 0, -10.476398, -19.6286, 0, -10.1098995, -20.0679, 0, -13.378599, -17.1586, 0, -11.120298, -19.6286, 0, -10.1098995, -17.1586, 0, -11.120298, -17.8929, 0, -10.41, -17.1586, 0, -11.120298, -20.0679, 0, -13.378599, -16.7232, 0, -13.378599, -20.5103, 0, -10.033899, -23.412498, 0, -13.378599, -20.0679, 0, -13.378599, -23.412498, 0, -13.378599, -20.5103, 0, -10.033899, -23.412498, 0, -10.033899, -20.3764, 0, -6.6893, -23.412498, 0, -10.033899, -20.5103, 0, -10.033899, -23.412498, 0, -10.033899, -20.3764, 0, -6.6893, -23.412498, 0, -6.6893, -23.412498, -0.0385, -2.8684, -23.412498, 0, -3.7743, -22.9552, -0.047599997, -3.375, -23.412498, -0.0385, -2.8684, -23.8421, 0, -3.3446002, -23.412498, 0, -3.7743, -23.412498, 0, -6.6893, -22.9552, -0.047599997, -3.375, -23.412498, 0, -3.7743, -22.9552, -0.047599997, -3.375, -23.412498, 0, -6.6893, -20.1607, -0.2436, -4.5060997, -20.1607, -0.2436, -4.5060997, -23.412498, 0, -6.6893, -20.3764, 0, -6.6893, -22.9552, -0.047599997, -3.375, -20.1607, -0.2436, -4.5060997, -21.0086, -0.2504, -3.5043998, -29.294899, -0.6617, -6.6893, -23.8421, 0, -3.3446002, -27.9812, -0.6617, -3.3446002, -23.8421, 0, -3.3446002, -29.294899, -0.6617, -6.6893, -23.412498, 0, -6.6893, -23.8421, 0, -3.3446002, -23.412498, 0, -6.6893, -23.412498, 0, -3.7743, -27.9812, -0.6617, -3.3446002, -25.479, -0.4588, 0.143, -27.9812, -0.6617, 0, -25.479, -0.4588, 0.143, -27.9812, -0.6617, -3.3446002, -23.412498, -0.0385, -2.8684, -23.412498, -0.0385, -2.8684, -27.9812, -0.6617, -3.3446002, -23.8421, 0, -3.3446002, -25.479, -0.4588, 0.143, -23.412498, -0.0385, -2.8684, -23.412498, -0.2913, 0.26119995, -22.756899, 0, 10.033899, -24.056599, 0, 10.033899, -23.489, 0, 9.386499, -22.756899, 0, 10.033899, -23.412498, 0, 10.6779995, -24.056599, 0, 10.033899, -20.0679, 0, 13.378601, -23.412498, 0, 10.6779995, -22.756899, 0, 10.033899, -23.412498, 0, 10.6779995, -20.0679, 0, 13.378601, -23.412498, 0, 13.378601, -20.0679, 0, 13.378601, -22.756899, 0, 10.033899, -20.0679, 0, 10.033899, -20.0679, 0, 10.033899, -17.213, 0, 13.378601, -20.0679, 0, 13.378601, -17.213, 0, 13.378601, -20.0679, 0, 10.033899, -16.7232, 0, 10.8878, -16.7232, 0, 10.8878, -20.0679, 0, 10.033899, -17.583, 0, 10.033899, -17.213, 0, 13.378601, -16.7232, 0, 10.8878, -16.7232, 0, 12.878401, -16.223, 0, 13.378601, -17.213, 0, 13.378601, -16.7232, 0, 12.878401, -16.223, 0, 13.378601, -16.7232, 0, 13.868401, -17.213, 0, 13.378601, -29.294899, -0.6617, 10.033899, -25.945799, -0.25779998, 6.6893, -26.0077, -0.2465, 10.033899, -29.294899, -0.6617, 10.033899, -29.294899, -0.6617, 6.6893, -25.945799, -0.25779998, 6.6893, -29.294899, -0.6617, 13.378601, -26.0077, -0.2465, 10.033899, -25.9681, -0.2875, 13.378601, -29.294899, -0.6617, 13.378601, -29.294899, -0.6617, 10.033899, -26.0077, -0.2465, 10.033899, -20.0679, -0.36479998, 16.7232, -20.0679, 0, 13.378601, -16.7232, -0.26209998, 16.7232, -16.7232, 0, 13.868401, -13.378601, 0, 16.7232, -16.7232, -0.26209998, 16.7232, -13.378601, 0, 16.7232, -16.7232, 0, 13.868401, -16.223, 0, 13.378601, -13.378601, 0, 16.7232, -16.223, 0, 13.378601, -13.378601, 0, 13.378601, 0, 0, 20.0679, 3.3446, -0.2814, 23.412498, 0, -0.2814, 23.412498, 3.3446, -0.2814, 23.412498, 0, 0, 20.0679, 2.4261, 0, 20.0679, 3.3446, -0.2814, 23.412498, 2.4261, 0, 20.0679, 3.3446, 0, 20.986399, 3.3446, 0, 20.986399, 6.6893, -0.2814, 23.412498, 3.3446, -0.2814, 23.412498, 6.6893, -0.2814, 23.412498, 3.3446, 0, 20.986399, 4.275, 0, 20.0679, 6.6893, -0.2814, 23.412498, 4.275, 0, 20.0679, 6.6893, 0, 20.0679, 10.572599, 0, 19.5895, 10.501598, 0, 20.5087, 10.080299, 0, 20.0679, 10.572599, 0, 19.5895, 11.0191, 0, 20.0679, 10.501598, 0, 20.5087, 10.572599, 0, 19.5895, 13.378599, 0, 16.7232, 11.0191, 0, 20.0679, 10.572599, 0, 19.5895, 10.572599, 0, 16.7232, 13.378599, 0, 16.7232, 10.572599, 0, 14.3402, 13.378599, 0, 16.7232, 10.572599, 0, 16.7232, 13.378599, 0, 16.7232, 10.572599, 0, 14.3402, 13.105099, 0, 13.9188, 13.105099, 0, 13.9188, 10.572599, 0, 14.3402, 10.986898, 0, 13.9188, 13.378599, 0, 16.7232, 13.105099, 0, 13.9188, 13.700699, 0, 14.602699, 13.8046, 0, 13.2202, 13.700699, 0, 14.602699, 13.105099, 0, 13.9188, 13.8046, 0, 13.2202, 14.439199, 0, 13.6437, 13.700699, 0, 14.602699, 6.6893, -0.2814, 23.412498, 10.501598, 0, 20.5087, 10.033899, -0.501, 23.412498, 10.033899, -0.501, 23.412498, 11.0191, 0, 20.0679, 13.378599, -1.0746, 20.0679, 10.033899, -0.501, 23.412498, 10.501598, 0, 20.5087, 11.0191, 0, 20.0679, 17.4912, 0.0034999999, 7.4080997, 13.8046, 0, 10.033899, 14.0257, 0.0442, 7.3252997, 13.8046, 0, 10.033899, 17.4912, 0.0034999999, 7.4080997, 16.7232, 0, 10.033899, 13.8046, 0, 10.033899, 16.7232, 0, 10.033899, 15.0508995, 0, 10.033899, 16.7232, -1.0746, 13.378601, 15.0508995, -0.40039998, 16.7232, 15.0508995, -0.6288, 13.378601, -16.7232, -1.1374999, 26.7572, -20.0679, -1.9081, 30.3625, -20.0679, -1.1374999, 26.7572, -16.7232, -1.1374999, 26.7572, -16.7232, -1.9068999, 30.3568, -20.0679, -1.9081, 30.3625, -23.412498, -1.9093, 30.3681, -20.0679, -1.1374999, 26.7572, -20.0679, -1.9081, 30.3625, -23.412498, -1.9093, 30.3681, -23.412498, -1.1374999, 26.7572, -20.0679, -1.1374999, 26.7572, -23.412498, -2.6287, 33.7341, -23.412498, -1.9093, 30.3681, -20.0679, -1.9081, 30.3625, -16.7232, -1.9068999, 30.3568, -20.0679, -2.628, 33.7306, -20.0679, -1.9081, 30.3625, -23.412498, -2.6287, 33.7341, -20.0679, -1.9081, 30.3625, -20.0679, -2.628, 33.7306, -16.7232, -1.9068999, 30.3568, -16.7232, -2.6272, 33.726997, -20.0679, -2.628, 33.7306, -23.412498, -3.2477999, 36.6305, -23.412498, -2.6287, 33.7341, -20.0679, -2.628, 33.7306, -16.7232, -2.6272, 33.726997, -20.0679, -3.2473998, 36.6287, -20.0679, -2.628, 33.7306, -23.412498, -3.2477999, 36.6305, -20.0679, -2.628, 33.7306, -20.0679, -3.2473998, 36.6287, -16.7232, -2.6272, 33.726997, -16.7232, -3.247, 36.6269, -20.0679, -3.2473998, 36.6287, -20.0679, -3.8518999, 39.456898, -23.412498, -3.2477999, 36.6305, -20.0679, -3.2473998, 36.6287, -16.7232, -3.247, 36.6269, -20.0679, -3.8518999, 39.456898, -20.0679, -3.2473998, 36.6287, -20.0679, -3.8518999, 39.456898, -23.412498, -3.8518999, 39.456898, -23.412498, -3.2477999, 36.6305, -16.7232, -3.247, 36.6269, -16.7232, -3.8518999, 39.456898, -20.0679, -3.8518999, 39.456898, -27.9812, -0.6617, 0, -30.315699, -0.9931, 3.3446, -30.300001, -1.3205999, 0, -27.9812, -0.6617, 0, -29.294899, -0.6617, 3.3446, -30.315699, -0.9931, 3.3446, -50.290497, -8.1437, 10.033899, -53.117798, -8.6939, 6.6893, -50.290497, -8.1437, 6.6893, -50.290497, -8.1437, 10.033899, -50.290497, -8.1437, 13.378601, -53.117798, -8.6939, 6.6893, -53.117798, -8.6939, 3.3446, -50.290497, -8.1437, 6.6893, -53.117798, -8.6939, 6.6893, -53.117798, -8.6939, 3.3446, -50.290497, -8.1437, 3.3446, -50.290497, -8.1437, 6.6893, -53.117798, -8.6939, 6.6893, -50.290497, -8.1437, 13.378601, -53.117798, -8.6939, 13.378601, -53.117798, -8.6939, 0, -50.290497, -8.1437, 3.3446, -53.117798, -8.6939, 3.3446, -53.117798, -8.6939, 0, -50.290497, -8.1437, 0, -50.290497, -8.1437, 3.3446, -55.5951, -9.176, 3.3446, -53.117798, -8.6939, 3.3446, -53.117798, -8.6939, 6.6893, -53.117798, -8.6939, -3.3446002, -50.290497, -8.1437, 0, -53.117798, -8.6939, 0, -53.117798, -8.6939, -3.3446002, -50.290497, -8.1437, -3.3446002, -50.290497, -8.1437, 0, -55.5951, -9.176, 0, -53.117798, -8.6939, 0, -53.117798, -8.6939, 3.3446, -55.5951, -9.176, 0, -53.117798, -8.6939, 3.3446, -55.5951, -9.176, 3.3446, -55.5951, -9.176, -3.3446002, -53.117798, -8.6939, -3.3446002, -53.117798, -8.6939, 0, -55.5951, -9.176, -3.3446002, -53.117798, -8.6939, 0, -55.5951, -9.176, 0, -53.117798, -8.6939, -6.6893, -50.290497, -8.1437, -3.3446002, -53.117798, -8.6939, -3.3446002, -53.117798, -8.6939, -6.6893, -50.290497, -8.1437, -6.6893, -50.290497, -8.1437, -3.3446002, -55.5951, -9.176, -6.6893, -53.117798, -8.6939, -3.3446002, -55.5951, -9.176, -3.3446002, -55.5951, -9.176, -6.6893, -53.117798, -8.6939, -6.6893, -53.117798, -8.6939, -3.3446002, -53.117798, -8.6939, -10.033899, -50.290497, -8.1437, -6.6893, -53.117798, -8.6939, -6.6893, -53.117798, -8.6939, -10.033899, -50.290497, -8.1437, -10.033899, -50.290497, -8.1437, -6.6893, -55.5951, -9.176, -10.033899, -53.117798, -8.6939, -6.6893, -55.5951, -9.176, -6.6893, -55.5951, -9.176, -10.033899, -53.117798, -8.6939, -10.033899, -53.117798, -8.6939, -6.6893, -53.117798, -8.6939, -13.378599, -50.290497, -8.1437, -10.033899, -53.117798, -8.6939, -10.033899, -53.117798, -8.6939, -13.378599, -50.290497, -8.1437, -13.378599, -50.290497, -8.1437, -10.033899, -53.117798, -8.6939, -13.378599, -50.290497, -8.1437, -16.7232, -50.290497, -8.1437, -13.378599, -53.117798, -8.6939, -13.378599, -50.290497, -8.1437, -16.937199, -50.290497, -8.1437, -16.7232, -50.290497, -8.1437, -16.937199, -53.117798, -8.6939, -13.378599, -53.117798, -8.6939, -16.911499, -53.117798, -8.6939, -16.911499, -50.290497, -8.1437, -20.0679, -50.290497, -8.1437, -16.937199, -55.5951, -9.176, -13.378599, -53.117798, -8.6939, -13.378599, -53.117798, -8.6939, -10.033899, -55.5951, -9.176, -13.378599, -53.117798, -8.6939, -10.033899, -55.5951, -9.176, -10.033899, -53.117798, -8.6939, -16.911499, -50.290497, -8.1437, -20.2818, -50.290497, -8.1437, -20.0679, -55.5951, -9.176, -16.888899, -53.117798, -8.6939, -13.378599, -55.5951, -9.176, -13.378599, -55.5951, -9.176, -16.888899, -53.117798, -8.6939, -16.911499, -53.117798, -8.6939, -13.378599, -58.463497, -9.7343, -13.378599, -55.5951, -9.176, -13.378599, -55.5951, -9.176, -10.033899, -58.463497, -9.7343, -16.8629, -55.5951, -9.176, -16.888899, -55.5951, -9.176, -13.378599, -58.463497, -9.7343, -16.8629, -55.5951, -9.176, -13.378599, -58.463497, -9.7343, -13.378599, -55.5951, -9.176, -20.233599, -53.117798, -8.6939, -16.911499, -55.5951, -9.176, -16.888899, -50.290497, -8.1437, -20.2818, -53.117798, -8.6939, -16.911499, -53.117798, -8.6939, -20.2561, -55.5951, -9.176, -20.233599, -53.117798, -8.6939, -20.2561, -53.117798, -8.6939, -16.911499, -53.117798, -8.6939, -23.412498, -50.290497, -8.1437, -20.2818, -53.117798, -8.6939, -20.2561, -53.117798, -8.6939, -23.412498, -50.290497, -8.1437, -23.412498, -50.290497, -8.1437, -20.2818, -53.117798, -8.6939, -26.7572, -50.290497, -8.1437, -23.412498, -53.117798, -8.6939, -23.412498, -53.117798, -8.6939, -26.7572, -50.290497, -8.1437, -26.7572, -50.290497, -8.1437, -23.412498, -55.5951, -9.176, -26.7572, -53.117798, -8.6939, -26.7572, -53.117798, -8.6939, -23.412498, -55.5951, -9.176, -23.412498, -53.117798, -8.6939, -23.412498, -53.117798, -8.6939, -20.2561, -55.5951, -9.176, -26.7572, -53.117798, -8.6939, -23.412498, -55.5951, -9.176, -23.412498, -55.5951, -9.176, -23.412498, -53.117798, -8.6939, -20.2561, -55.5951, -9.176, -20.233599, -58.463497, -9.7343, -26.7572, -55.5951, -9.176, -26.7572, -55.5951, -9.176, -23.412498, -58.463497, -9.7343, -23.412498, -55.5951, -9.176, -23.412498, -55.5951, -9.176, -20.233599, -58.463497, -9.7343, -26.7572, -55.5951, -9.176, -23.412498, -58.463497, -9.7343, -23.412498, -62.057194, -10.4337, -23.412498, -58.463497, -9.7343, -26.7572, -58.463497, -9.7343, -23.412498, -62.057194, -10.4337, -23.412498, -62.057194, -10.4337, -26.7572, -58.463497, -9.7343, -26.7572, -62.057194, -10.4337, -20.174799, -62.057194, -10.4337, -23.412498, -58.463497, -9.7343, -23.412498, -58.463497, -9.7343, -23.412498, -55.5951, -9.176, -20.233599, -58.463497, -9.7343, -20.207499, -62.057194, -10.4337, -20.174799, -58.463497, -9.7343, -23.412498, -58.463497, -9.7343, -20.207499, -58.463497, -9.7343, -20.207499, -55.5951, -9.176, -20.233599, -55.5951, -9.176, -16.888899, -58.463497, -9.7343, -20.207499, -55.5951, -9.176, -16.888899, -58.463497, -9.7343, -16.8629, -58.463497, -9.7343, -16.8629, -62.057194, -10.4337, -20.174799, -58.463497, -9.7343, -20.207499, -58.463497, -9.7343, -16.8629, -62.057194, -10.4337, -20.0679, -62.057194, -10.4337, -20.174799, -62.057194, -10.4337, -20.0679, -58.463497, -9.7343, -16.8629, -62.057194, -10.4337, -16.8302, -58.463497, -9.7343, -13.378599, -62.057194, -10.4337, -16.8302, -58.463497, -9.7343, -16.8629, -58.463497, -9.7343, -13.378599, -62.057194, -10.4337, -16.7232, -62.057194, -10.4337, -16.8302, -62.057194, -10.4337, -16.7232, -58.463497, -9.7343, -13.378599, -62.057194, -10.4337, -13.378599, -62.057194, -10.4337, -10.033899, -62.057194, -10.4337, -13.378599, -58.463497, -9.7343, -13.378599, -62.057194, -10.4337, -10.033899, -58.463497, -9.7343, -13.378599, -58.463497, -9.7343, -10.033899, -58.463497, -9.7343, -13.378599, -55.5951, -9.176, -10.033899, -58.463497, -9.7343, -10.033899, -62.057194, -10.4337, -6.6893, -62.057194, -10.4337, -10.033899, -58.463497, -9.7343, -10.033899, -58.463497, -9.7343, -10.033899, -55.5951, -9.176, -10.033899, -55.5951, -9.176, -6.6893, -62.057194, -10.4337, -6.6893, -58.463497, -9.7343, -10.033899, -58.463497, -9.7343, -6.6893, -58.463497, -9.7343, -10.033899, -55.5951, -9.176, -6.6893, -58.463497, -9.7343, -6.6893, -62.057194, -10.4337, -3.3446002, -62.057194, -10.4337, -6.6893, -58.463497, -9.7343, -6.6893, -58.463497, -9.7343, -6.6893, -55.5951, -9.176, -6.6893, -55.5951, -9.176, -3.3446002, -62.057194, -10.4337, -3.3446002, -58.463497, -9.7343, -6.6893, -58.463497, -9.7343, -3.3446002, -58.463497, -9.7343, -6.6893, -55.5951, -9.176, -3.3446002, -58.463497, -9.7343, -3.3446002, -62.057194, -10.4337, 0, -62.057194, -10.4337, -3.3446002, -58.463497, -9.7343, -3.3446002, -58.463497, -9.7343, -3.3446002, -55.5951, -9.176, -3.3446002, -55.5951, -9.176, 0, -62.057194, -10.4337, 0, -58.463497, -9.7343, -3.3446002, -58.463497, -9.7343, 0, -58.463497, -9.7343, -3.3446002, -55.5951, -9.176, 0, -58.463497, -9.7343, 0, -62.057194, -10.4337, 3.3446, -62.057194, -10.4337, 0, -58.463497, -9.7343, 0, -58.463497, -9.7343, 0, -55.5951, -9.176, 0, -55.5951, -9.176, 3.3446, -62.057194, -10.4337, 3.3446, -58.463497, -9.7343, 0, -58.463497, -9.7343, 3.3446, -58.463497, -9.7343, 0, -55.5951, -9.176, 3.3446, -58.463497, -9.7343, 3.3446, -62.057194, -10.4337, 6.6893, -62.057194, -10.4337, 3.3446, -58.463497, -9.7343, 3.3446, -62.057194, -10.4337, 6.6893, -58.463497, -9.7343, 3.3446, -58.463497, -9.7343, 6.6893, -58.463497, -9.7343, 6.6893, -62.057194, -10.4337, 10.033899, -62.057194, -10.4337, 6.6893, -58.463497, -9.7343, 3.3446, -55.5951, -9.176, 3.3446, -55.5951, -9.176, 6.6893, -58.463497, -9.7343, 3.3446, -55.5951, -9.176, 6.6893, -58.463497, -9.7343, 6.6893, -55.5951, -9.176, 3.3446, -53.117798, -8.6939, 6.6893, -55.5951, -9.176, 6.6893, -58.463497, -9.7343, 6.6893, -62.057194, -10.4337, 13.378601, -62.057194, -10.4337, 10.033899, -53.117798, -8.6939, 13.378601, -55.5951, -9.176, 6.6893, -53.117798, -8.6939, 6.6893, -55.5951, -9.176, 13.378601, -58.463497, -9.7343, 6.6893, -55.5951, -9.176, 6.6893, -53.117798, -8.6939, 13.378601, -55.5951, -9.176, 13.378601, -55.5951, -9.176, 6.6893, -55.5951, -9.176, 13.378601, -58.463497, -9.7343, 13.378601, -58.463497, -9.7343, 6.6893, -62.057194, -10.4337, 13.378601, -58.463497, -9.7343, 6.6893, -58.463497, -9.7343, 13.378601, -53.117798, -8.6939, 16.7232, -55.5951, -9.176, 13.378601, -53.117798, -8.6939, 13.378601, -50.290497, -8.1437, 13.378601, -53.117798, -8.6939, 16.7232, -53.117798, -8.6939, 13.378601, -50.290497, -8.1437, 13.378601, -50.290497, -8.1437, 16.7232, -53.117798, -8.6939, 16.7232, -53.117798, -8.6939, 16.7232, -55.5951, -9.176, 16.7232, -55.5951, -9.176, 13.378601, -55.5951, -9.176, 16.7232, -58.463497, -9.7343, 13.378601, -55.5951, -9.176, 13.378601, -50.290497, -8.1437, 16.7232, -53.117798, -8.6939, 20.0679, -53.117798, -8.6939, 16.7232, -53.117798, -8.6939, 20.0679, -55.5951, -9.176, 16.7232, -53.117798, -8.6939, 16.7232, -50.290497, -8.1437, 16.7232, -50.290497, -8.1437, 20.0679, -53.117798, -8.6939, 20.0679, -58.463497, -9.7343, 16.7232, -62.057194, -10.4337, 13.378601, -58.463497, -9.7343, 13.378601, -55.5951, -9.176, 16.7232, -58.463497, -9.7343, 16.7232, -58.463497, -9.7343, 13.378601, -58.463497, -9.7343, 16.7232, -62.057194, -10.4337, 16.7232, -62.057194, -10.4337, 13.378601, -53.117798, -8.6939, 20.0679, -55.5951, -9.176, 20.0679, -55.5951, -9.176, 16.7232, -55.5951, -9.176, 20.0679, -58.463497, -9.7343, 16.7232, -55.5951, -9.176, 16.7232, -50.290497, -8.1437, 20.0679, -53.117798, -8.6939, 23.412498, -53.117798, -8.6939, 20.0679, -53.117798, -8.6939, 23.412498, -55.5951, -9.176, 20.0679, -53.117798, -8.6939, 20.0679, -50.290497, -8.1437, 20.0679, -50.290497, -8.1437, 23.412498, -53.117798, -8.6939, 23.412498, -50.290497, -8.1437, 23.412498, -53.117798, -8.6939, 26.7572, -53.117798, -8.6939, 23.412498, -50.290497, -8.1437, 23.412498, -50.290497, -8.1437, 26.7572, -53.117798, -8.6939, 26.7572, -53.117798, -8.6939, 26.7572, -55.5951, -9.176, 23.412498, -53.117798, -8.6939, 23.412498, -53.117798, -8.6939, 23.412498, -55.5951, -9.176, 23.412498, -55.5951, -9.176, 20.0679, -53.117798, -8.6939, 26.7572, -55.5951, -9.176, 26.7572, -55.5951, -9.176, 23.412498, -55.5951, -9.176, 20.0679, -58.463497, -9.7343, 20.0679, -58.463497, -9.7343, 16.7232, -58.463497, -9.7343, 20.0679, -62.057194, -10.4337, 16.7232, -58.463497, -9.7343, 16.7232, -55.5951, -9.176, 23.412498, -58.463497, -9.7343, 20.0679, -55.5951, -9.176, 20.0679, -58.463497, -9.7343, 20.0679, -62.057194, -10.4337, 20.0679, -62.057194, -10.4337, 16.7232, -55.5951, -9.176, 26.7572, -58.463497, -9.7343, 23.412498, -55.5951, -9.176, 23.412498, -55.5951, -9.176, 23.412498, -58.463497, -9.7343, 23.412498, -58.463497, -9.7343, 20.0679, -58.463497, -9.7343, 23.412498, -62.057194, -10.4337, 20.0679, -58.463497, -9.7343, 20.0679, -55.5951, -9.176, 26.7572, -58.463497, -9.7343, 26.7572, -58.463497, -9.7343, 23.412498, -58.463497, -9.7343, 23.412498, -62.057194, -10.4337, 23.412498, -62.057194, -10.4337, 20.0679, -58.463497, -9.7343, 26.7572, -62.057194, -10.4337, 23.412498, -58.463497, -9.7343, 23.412498, -58.463497, -9.7343, 26.7572, -62.057194, -10.4337, 26.7572, -62.057194, -10.4337, 23.412498, -62.057194, -10.4337, 0, -64.963196, -10.4337, -3.3446002, -62.057194, -10.4337, -3.3446002, -64.963196, -10.4337, -6.6893, -62.057194, -10.4337, -3.3446002, -64.963196, -10.4337, -3.3446002, -64.963196, -10.4337, -6.6893, -62.057194, -10.4337, -6.6893, -62.057194, -10.4337, -3.3446002, -62.057194, -10.4337, 0, -64.963196, -10.4337, 0, -64.963196, -10.4337, -3.3446002, -64.963196, -10.4337, 0, -62.057194, -10.4337, 0, -62.057194, -10.4337, 3.3446, -68.2489, -10.4337, -6.6893, -64.963196, -10.4337, -6.6893, -64.963196, -10.4337, -3.3446002, -64.963196, -10.4337, 0, -68.2489, -10.4337, -3.3446002, -64.963196, -10.4337, -3.3446002, -68.2489, -10.4337, -6.6893, -64.963196, -10.4337, -3.3446002, -68.2489, -10.4337, -3.3446002, -64.963196, -10.4337, -10.033899, -62.057194, -10.4337, -6.6893, -64.963196, -10.4337, -6.6893, -64.963196, -10.4337, -10.033899, -62.057194, -10.4337, -10.033899, -62.057194, -10.4337, -6.6893, -68.2489, -10.4337, -10.033899, -64.963196, -10.4337, -6.6893, -68.2489, -10.4337, -6.6893, -68.2489, -10.4337, -10.033899, -64.963196, -10.4337, -10.033899, -64.963196, -10.4337, -6.6893, -64.963196, -10.4337, -13.378599, -62.057194, -10.4337, -10.033899, -64.963196, -10.4337, -10.033899, -64.963196, -10.4337, -13.378599, -62.057194, -10.4337, -13.378599, -62.057194, -10.4337, -10.033899, -64.963196, -10.4337, -13.378599, -62.057194, -10.4337, -16.7232, -62.057194, -10.4337, -13.378599, -64.963196, -10.4337, -13.378599, -62.057194, -10.4337, -16.8302, -62.057194, -10.4337, -16.7232, -62.057194, -10.4337, -16.8302, -64.963196, -10.4337, -13.378599, -64.963196, -10.4337, -16.803799, -64.963196, -10.4337, -16.803799, -62.057194, -10.4337, -20.0679, -62.057194, -10.4337, -16.8302, -68.2489, -10.4337, -13.378599, -64.963196, -10.4337, -13.378599, -64.963196, -10.4337, -10.033899, -68.2489, -10.4337, -13.378599, -64.963196, -10.4337, -10.033899, -68.2489, -10.4337, -10.033899, -64.963196, -10.4337, -16.803799, -62.057194, -10.4337, -20.174799, -62.057194, -10.4337, -20.0679, -68.2489, -10.4337, -16.7739, -64.963196, -10.4337, -13.378599, -68.2489, -10.4337, -13.378599, -68.2489, -10.4337, -16.7739, -64.963196, -10.4337, -16.803799, -64.963196, -10.4337, -13.378599, -71.2999, -10.4337, -13.378599, -68.2489, -10.4337, -13.378599, -68.2489, -10.4337, -10.033899, -71.2999, -10.4337, -16.746199, -68.2489, -10.4337, -16.7739, -68.2489, -10.4337, -13.378599, -71.2999, -10.4337, -16.746199, -68.2489, -10.4337, -13.378599, -71.2999, -10.4337, -13.378599, -73.8239, -10.4337, -13.378599, -71.2999, -10.4337, -16.746199, -71.2999, -10.4337, -13.378599, -73.8239, -10.4337, -13.378599, -73.8239, -10.4337, -16.7232, -71.2999, -10.4337, -16.746199, -73.8239, -10.4337, -10.033899, -73.8239, -10.4337, -13.378599, -71.2999, -10.4337, -13.378599, -73.8239, -10.4337, -16.7232, -71.2999, -10.4337, -20.0908, -71.2999, -10.4337, -16.746199, -71.2999, -10.4337, -20.0908, -68.2489, -10.4337, -16.7739, -71.2999, -10.4337, -16.746199, -73.8239, -10.4337, -16.7232, -73.8239, -10.4337, -20.0679, -71.2999, -10.4337, -20.0908, -71.2999, -10.4337, -13.378599, -68.2489, -10.4337, -10.033899, -71.2999, -10.4337, -10.033899, -73.8239, -10.4337, -10.033899, -71.2999, -10.4337, -13.378599, -71.2999, -10.4337, -10.033899, -71.2999, -10.4337, -10.033899, -68.2489, -10.4337, -10.033899, -68.2489, -10.4337, -6.6893, -73.8239, -10.4337, -6.6893, -73.8239, -10.4337, -10.033899, -71.2999, -10.4337, -10.033899, -71.2999, -10.4337, -10.033899, -68.2489, -10.4337, -6.6893, -71.2999, -10.4337, -6.6893, -73.8239, -10.4337, -6.6893, -71.2999, -10.4337, -10.033899, -71.2999, -10.4337, -6.6893, -71.2999, -10.4337, -6.6893, -68.2489, -10.4337, -6.6893, -68.2489, -10.4337, -3.3446002, -73.8239, -10.4337, -3.3446002, -73.8239, -10.4337, -6.6893, -71.2999, -10.4337, -6.6893, -71.2999, -10.4337, -6.6893, -68.2489, -10.4337, -3.3446002, -71.2999, -10.4337, -3.3446002, -73.8239, -10.4337, -3.3446002, -71.2999, -10.4337, -6.6893, -71.2999, -10.4337, -3.3446002, -68.2489, -10.4337, 0, -71.2999, -10.4337, -3.3446002, -68.2489, -10.4337, -3.3446002, -71.2999, -10.4337, 0, -73.8239, -10.4337, -3.3446002, -71.2999, -10.4337, -3.3446002, -68.2489, -10.4337, 0, -71.2999, -10.4337, 0, -71.2999, -10.4337, -3.3446002, -71.2999, -10.4337, 0, -73.8239, -10.4337, 0, -73.8239, -10.4337, -3.3446002, -64.963196, -10.4337, 0, -68.2489, -10.4337, 0, -68.2489, -10.4337, -3.3446002, -73.8239, -10.4337, 0, -71.2999, -10.4337, 0, -71.2999, -10.4337, 3.3446, -73.8239, -10.4337, 0, -71.2999, -10.4337, 3.3446, -73.8239, -10.4337, 3.3446, -71.2999, -10.4337, 0, -68.2489, -10.4337, 0, -68.2489, -10.4337, 3.3446, -71.2999, -10.4337, 0, -68.2489, -10.4337, 3.3446, -71.2999, -10.4337, 3.3446, -68.2489, -10.4337, 0, -64.963196, -10.4337, 0, -64.963196, -10.4337, 3.3446, -68.2489, -10.4337, 0, -64.963196, -10.4337, 3.3446, -68.2489, -10.4337, 3.3446, -64.963196, -10.4337, 0, -62.057194, -10.4337, 3.3446, -64.963196, -10.4337, 3.3446, -62.057194, -10.4337, 3.3446, -64.963196, -10.4337, 6.6893, -64.963196, -10.4337, 3.3446, -64.963196, -10.4337, 6.6893, -68.2489, -10.4337, 3.3446, -64.963196, -10.4337, 3.3446, -62.057194, -10.4337, 3.3446, -62.057194, -10.4337, 6.6893, -64.963196, -10.4337, 6.6893, -62.057194, -10.4337, 10.033899, -64.963196, -10.4337, 6.6893, -62.057194, -10.4337, 6.6893, -64.963196, -10.4337, 6.6893, -62.057194, -10.4337, 10.033899, -62.057194, -10.4337, 13.378601, -64.963196, -10.4337, 6.6893, -68.2489, -10.4337, 6.6893, -68.2489, -10.4337, 3.3446, -68.2489, -10.4337, 6.6893, -71.2999, -10.4337, 3.3446, -68.2489, -10.4337, 3.3446, -64.963196, -10.4337, 6.6893, -62.057194, -10.4337, 13.378601, -64.963196, -10.4337, 13.378601, -64.963196, -10.4337, 13.378601, -68.2489, -10.4337, 6.6893, -64.963196, -10.4337, 6.6893, -62.057194, -10.4337, 13.378601, -64.963196, -10.4337, 16.7232, -64.963196, -10.4337, 13.378601, -62.057194, -10.4337, 13.378601, -62.057194, -10.4337, 16.7232, -64.963196, -10.4337, 16.7232, -64.963196, -10.4337, 13.378601, -68.2489, -10.4337, 13.378601, -68.2489, -10.4337, 6.6893, -64.963196, -10.4337, 16.7232, -68.2489, -10.4337, 13.378601, -64.963196, -10.4337, 13.378601, -68.2489, -10.4337, 6.6893, -71.2999, -10.4337, 6.6893, -71.2999, -10.4337, 3.3446, -68.2489, -10.4337, 13.378601, -71.2999, -10.4337, 6.6893, -68.2489, -10.4337, 6.6893, -71.2999, -10.4337, 6.6893, -73.8239, -10.4337, 3.3446, -71.2999, -10.4337, 3.3446, -71.2999, -10.4337, 6.6893, -73.8239, -10.4337, 6.6893, -73.8239, -10.4337, 3.3446, -71.2999, -10.4337, 6.6893, -73.8239, -10.4337, 10.033899, -73.8239, -10.4337, 6.6893, -71.2999, -10.4337, 6.6893, -73.8239, -10.4337, 13.378601, -73.8239, -10.4337, 10.033899, -68.2489, -10.4337, 13.378601, -71.2999, -10.4337, 13.378601, -71.2999, -10.4337, 6.6893, -73.8239, -10.4337, 13.378601, -71.2999, -10.4337, 6.6893, -71.2999, -10.4337, 13.378601, -64.963196, -10.4337, 16.7232, -68.2489, -10.4337, 16.7232, -68.2489, -10.4337, 13.378601, -68.2489, -10.4337, 16.7232, -71.2999, -10.4337, 13.378601, -68.2489, -10.4337, 13.378601, -71.2999, -10.4337, 16.7232, -73.8239, -10.4337, 13.378601, -71.2999, -10.4337, 13.378601, -68.2489, -10.4337, 16.7232, -71.2999, -10.4337, 16.7232, -71.2999, -10.4337, 13.378601, -71.2999, -10.4337, 16.7232, -73.8239, -10.4337, 16.7232, -73.8239, -10.4337, 13.378601, -64.963196, -10.4337, 20.0679, -68.2489, -10.4337, 16.7232, -64.963196, -10.4337, 16.7232, -62.057194, -10.4337, 16.7232, -64.963196, -10.4337, 20.0679, -64.963196, -10.4337, 16.7232, -62.057194, -10.4337, 16.7232, -62.057194, -10.4337, 20.0679, -64.963196, -10.4337, 20.0679, -64.963196, -10.4337, 20.0679, -68.2489, -10.4337, 20.0679, -68.2489, -10.4337, 16.7232, -68.2489, -10.4337, 20.0679, -71.2999, -10.4337, 16.7232, -68.2489, -10.4337, 16.7232, -62.057194, -10.4337, 20.0679, -64.963196, -10.4337, 23.412498, -64.963196, -10.4337, 20.0679, -64.963196, -10.4337, 23.412498, -68.2489, -10.4337, 20.0679, -64.963196, -10.4337, 20.0679, -62.057194, -10.4337, 20.0679, -62.057194, -10.4337, 23.412498, -64.963196, -10.4337, 23.412498, -71.2999, -10.4337, 20.0679, -73.8239, -10.4337, 16.7232, -71.2999, -10.4337, 16.7232, -68.2489, -10.4337, 20.0679, -71.2999, -10.4337, 20.0679, -71.2999, -10.4337, 16.7232, -71.2999, -10.4337, 20.0679, -73.8239, -10.4337, 20.0679, -73.8239, -10.4337, 16.7232, -64.963196, -10.4337, 23.412498, -68.2489, -10.4337, 23.412498, -68.2489, -10.4337, 20.0679, -68.2489, -10.4337, 23.412498, -71.2999, -10.4337, 20.0679, -68.2489, -10.4337, 20.0679, -62.057194, -10.4337, 23.412498, -64.963196, -10.4337, 26.7572, -64.963196, -10.4337, 23.412498, -64.963196, -10.4337, 26.7572, -68.2489, -10.4337, 23.412498, -64.963196, -10.4337, 23.412498, -62.057194, -10.4337, 23.412498, -62.057194, -10.4337, 26.7572, -64.963196, -10.4337, 26.7572, -71.2999, -10.4337, 23.412498, -73.8239, -10.4337, 20.0679, -71.2999, -10.4337, 20.0679, -68.2489, -10.4337, 23.412498, -71.2999, -10.4337, 23.412498, -71.2999, -10.4337, 20.0679, -71.2999, -10.4337, 23.412498, -73.8239, -10.4337, 23.412498, -73.8239, -10.4337, 20.0679, -68.2489, -10.4337, 26.7572, -71.2999, -10.4337, 23.412498, -68.2489, -10.4337, 23.412498, -64.963196, -10.4337, 26.7572, -68.2489, -10.4337, 26.7572, -68.2489, -10.4337, 23.412498, -71.2999, -10.4337, 26.7572, -73.8239, -10.4337, 23.412498, -71.2999, -10.4337, 23.412498, -68.2489, -10.4337, 26.7572, -71.2999, -10.4337, 26.7572, -71.2999, -10.4337, 23.412498, -71.2999, -10.4337, 26.7572, -73.8239, -10.4337, 26.7572, -73.8239, -10.4337, 23.412498, -73.8239, -10.4337, 26.7572, -71.2999, -10.4337, 26.7572, -72.9579, -10.4337, 27.7057, -71.2999, -10.4337, 26.7572, -71.694496, -10.4337, 29.089499, -72.9579, -10.4337, 27.7057, -71.2999, -10.4337, 26.7572, -70.1271, -10.4337, 28.0285, -71.694496, -10.4337, 29.089499, -71.2999, -10.4337, 26.7572, -68.2489, -10.4337, 26.7572, -70.1271, -10.4337, 28.0285, -70.1271, -10.4337, 28.0285, -70.3329, -10.4337, 30.580898, -71.694496, -10.4337, 29.089499, -70.1271, -10.4337, 28.0285, -68.7877, -10.4337, 29.480598, -70.3329, -10.4337, 30.580898, -68.2489, -10.4337, 26.7572, -68.7877, -10.4337, 29.480598, -70.1271, -10.4337, 28.0285, -68.7877, -10.4337, 29.480598, -69.0569, -10.4337, 31.9786, -70.3329, -10.4337, 30.580898, -68.2489, -10.4337, 26.7572, -66.948, -10.4337, 28.1705, -68.7877, -10.4337, 29.480598, -68.2489, -10.4337, 26.7572, -64.963196, -10.4337, 26.7572, -66.948, -10.4337, 28.1705, -68.7877, -10.4337, 29.480598, -67.5324, -10.4337, 30.8414, -69.0569, -10.4337, 31.9786, -66.948, -10.4337, 28.1705, -67.5324, -10.4337, 30.8414, -68.7877, -10.4337, 29.480598, -67.5324, -10.4337, 30.8414, -67.618996, -10.4337, 33.553497, -69.0569, -10.4337, 31.9786, -64.963196, -10.4337, 26.7572, -65.7283, -10.4337, 29.495699, -66.948, -10.4337, 28.1705, -66.948, -10.4337, 28.1705, -65.7283, -10.4337, 29.495699, -67.5324, -10.4337, 30.8414, -67.5324, -10.4337, 30.8414, -66.0847, -10.4337, 32.410698, -67.618996, -10.4337, 33.553497, -65.7283, -10.4337, 29.495699, -66.0847, -10.4337, 32.410698, -67.5324, -10.4337, 30.8414, -66.0847, -10.4337, 32.410698, -66.2484, -10.4337, 35.0548, -67.618996, -10.4337, 33.553497, -64.963196, -10.4337, 26.7572, -63.779697, -10.4337, 28.042099, -65.7283, -10.4337, 29.495699, -64.963196, -10.4337, 26.7572, -62.057194, -10.4337, 26.7572, -63.779697, -10.4337, 28.042099, -65.7283, -10.4337, 29.495699, -64.2815, -10.4337, 31.0675, -66.0847, -10.4337, 32.410698, -63.779697, -10.4337, 28.042099, -64.2815, -10.4337, 31.0675, -65.7283, -10.4337, 29.495699, -66.0847, -10.4337, 32.410698, -64.736496, -10.4337, 33.8722, -66.2484, -10.4337, 35.0548, -64.2815, -10.4337, 31.0675, -64.736496, -10.4337, 33.8722, -66.0847, -10.4337, 32.410698, -64.736496, -10.4337, 33.8722, -65.1522, -10.4337, 36.255398, -66.2484, -10.4337, 35.0548, -62.057194, -10.4337, 26.7572, -62.331196, -10.4337, 29.6148, -63.779697, -10.4337, 28.042099, -63.779697, -10.4337, 28.042099, -62.331196, -10.4337, 29.6148, -64.2815, -10.4337, 31.0675, -62.057194, -10.4337, 26.7572, -60.611, -10.4337, 28.3335, -62.331196, -10.4337, 29.6148, -64.2815, -10.4337, 31.0675, -62.971096, -10.4337, 32.4913, -64.736496, -10.4337, 33.8722, -62.331196, -10.4337, 29.6148, -62.971096, -10.4337, 32.4913, -64.2815, -10.4337, 31.0675, -64.736496, -10.4337, 33.8722, -63.642498, -10.4337, 35.0582, -65.1522, -10.4337, 36.255398, -62.971096, -10.4337, 32.4913, -63.642498, -10.4337, 35.0582, -64.736496, -10.4337, 33.8722, -63.642498, -10.4337, 35.0582, -63.8252, -10.4337, 37.7089, -65.1522, -10.4337, 36.255398, -60.611, -10.4337, 28.3335, -61.059097, -10.4337, 30.9959, -62.331196, -10.4337, 29.6148, -62.331196, -10.4337, 29.6148, -61.059097, -10.4337, 30.9959, -62.971096, -10.4337, 32.4913, -60.611, -10.4337, 28.3335, -59.3761, -10.4337, 29.679499, -61.059097, -10.4337, 30.9959, -62.971096, -10.4337, 32.4913, -61.8887, -10.4337, 33.667297, -63.642498, -10.4337, 35.0582, -61.059097, -10.4337, 30.9959, -61.8887, -10.4337, 33.667297, -62.971096, -10.4337, 32.4913, -63.642498, -10.4337, 35.0582, -62.323597, -10.4337, 36.4879, -63.8252, -10.4337, 37.7089, -61.8887, -10.4337, 33.667297, -62.323597, -10.4337, 36.4879, -63.642498, -10.4337, 35.0582, -62.323597, -10.4337, 36.4879, -62.558193, -10.4337, 39.0967, -63.8252, -10.4337, 37.7089, -59.3761, -10.4337, 29.679499, -59.9874, -10.4337, 32.1594, -61.059097, -10.4337, 30.9959, -61.059097, -10.4337, 30.9959, -59.9874, -10.4337, 32.1594, -61.8887, -10.4337, 33.667297, -59.3761, -10.4337, 29.679499, -58.316597, -10.4337, 30.834398, -59.9874, -10.4337, 32.1594, -61.8887, -10.4337, 33.667297, -60.59, -10.4337, 35.078197, -62.323597, -10.4337, 36.4879, -59.9874, -10.4337, 32.1594, -60.59, -10.4337, 35.078197, -61.8887, -10.4337, 33.667297, -62.323597, -10.4337, 36.4879, -61.055798, -10.4337, 37.8623, -62.558193, -10.4337, 39.0967, -60.59, -10.4337, 35.078197, -61.055798, -10.4337, 37.8623, -62.323597, -10.4337, 36.4879, -61.055798, -10.4337, 37.8623, -61.1665, -10.4337, 40.621098, -62.558193, -10.4337, 39.0967, -58.316597, -10.4337, 30.834398, -58.708298, -10.4337, 33.5481, -59.9874, -10.4337, 32.1594, -59.9874, -10.4337, 32.1594, -58.708298, -10.4337, 33.5481, -60.59, -10.4337, 35.078197, -58.316597, -10.4337, 30.834398, -57.058, -10.4337, 32.2062, -58.708298, -10.4337, 33.5481, -60.59, -10.4337, 35.078197, -59.331398, -10.4337, 36.4456, -61.055798, -10.4337, 37.8623, -58.708298, -10.4337, 33.5481, -59.331398, -10.4337, 36.4456, -60.59, -10.4337, 35.078197, -61.055798, -10.4337, 37.8623, -59.6695, -10.4337, 39.3651, -61.1665, -10.4337, 40.621098, -59.331398, -10.4337, 36.4456, -59.6695, -10.4337, 39.3651, -61.055798, -10.4337, 37.8623, -59.6695, -10.4337, 39.3651, -59.815098, -10.4337, 42.1012, -61.1665, -10.4337, 40.621098, -57.058, -10.4337, 32.2062, -57.457497, -10.4337, 34.906097, -58.708298, -10.4337, 33.5481, -58.708298, -10.4337, 33.5481, -57.457497, -10.4337, 34.906097, -59.331398, -10.4337, 36.4456, -57.058, -10.4337, 32.2062, -55.8173, -10.4337, 33.5585, -57.457497, -10.4337, 34.906097, -59.331398, -10.4337, 36.4456, -57.962597, -10.4337, 37.9328, -59.6695, -10.4337, 39.3651, -57.457497, -10.4337, 34.906097, -57.962597, -10.4337, 37.9328, -59.331398, -10.4337, 36.4456, -59.6695, -10.4337, 39.3651, -58.3182, -10.4337, 40.8299, -59.815098, -10.4337, 42.1012, -57.962597, -10.4337, 37.9328, -58.3182, -10.4337, 40.8299, -59.6695, -10.4337, 39.3651, -58.3182, -10.4337, 40.8299, -58.397198, -10.4337, 43.6543, -59.815098, -10.4337, 42.1012, -55.8173, -10.4337, 33.5585, -56.105198, -10.4337, 36.374397, -57.457497, -10.4337, 34.906097, -57.457497, -10.4337, 34.906097, -56.105198, -10.4337, 36.374397, -57.962597, -10.4337, 37.9328, -55.8173, -10.4337, 33.5585, -54.4828, -10.4337, 35.0131, -56.105198, -10.4337, 36.374397, -57.962597, -10.4337, 37.9328, -56.621998, -10.4337, 39.389297, -58.3182, -10.4337, 40.8299, -56.105198, -10.4337, 36.374397, -56.621998, -10.4337, 39.389297, -57.962597, -10.4337, 37.9328, -58.3182, -10.4337, 40.8299, -56.8974, -10.4337, 42.370197, -58.397198, -10.4337, 43.6543, -56.621998, -10.4337, 39.389297, -56.8974, -10.4337, 42.370197, -58.3182, -10.4337, 40.8299, -56.8974, -10.4337, 42.370197, -57.1702, -10.4337, 44.9982, -58.397198, -10.4337, 43.6543, -54.4828, -10.4337, 35.0131, -54.7739, -10.4337, 37.819798, -56.105198, -10.4337, 36.374397, -56.105198, -10.4337, 36.374397, -54.7739, -10.4337, 37.819798, -56.621998, -10.4337, 39.389297, -54.4828, -10.4337, 35.0131, -53.163, -10.4337, 36.4517, -54.7739, -10.4337, 37.819798, -56.621998, -10.4337, 39.389297, -55.2089, -10.4337, 40.9246, -56.8974, -10.4337, 42.370197, -54.7739, -10.4337, 37.819798, -55.2089, -10.4337, 40.9246, -56.621998, -10.4337, 39.389297, -56.8974, -10.4337, 42.370197, -55.676598, -10.4337, 43.6936, -57.1702, -10.4337, 44.9982, -55.2089, -10.4337, 40.9246, -55.676598, -10.4337, 43.6936, -56.8974, -10.4337, 42.370197, -55.676598, -10.4337, 43.6936, -55.7286, -10.4337, 46.577297, -57.1702, -10.4337, 44.9982, -53.163, -10.4337, 36.4517, -53.3668, -10.4337, 39.3474, -54.7739, -10.4337, 37.819798, -54.7739, -10.4337, 37.819798, -53.3668, -10.4337, 39.3474, -55.2089, -10.4337, 40.9246, -53.163, -10.4337, 36.4517, -51.764698, -10.4337, 37.9757, -53.3668, -10.4337, 39.3474, -55.2089, -10.4337, 40.9246, -54.004498, -10.4337, 42.233097, -55.676598, -10.4337, 43.6936, -53.3668, -10.4337, 39.3474, -54.004498, -10.4337, 42.233097, -55.2089, -10.4337, 40.9246, -55.676598, -10.4337, 43.6936, -54.2416, -10.4337, 45.2492, -55.7286, -10.4337, 46.577297, -54.004498, -10.4337, 42.233097, -54.2416, -10.4337, 45.2492, -55.676598, -10.4337, 43.6936, -54.2416, -10.4337, 45.2492, -54.056297, -10.4337, 48.408897, -55.7286, -10.4337, 46.577297, -51.764698, -10.4337, 37.9757, -52.1782, -10.4337, 40.637897, -53.3668, -10.4337, 39.3474, -53.3668, -10.4337, 39.3474, -52.1782, -10.4337, 40.637897, -54.004498, -10.4337, 42.233097, -51.764698, -10.4337, 37.9757, -50.5928, -10.4337, 39.2532, -52.1782, -10.4337, 40.637897, -54.004498, -10.4337, 42.233097, -52.5879, -10.4337, 43.772198, -54.2416, -10.4337, 45.2492, -52.1782, -10.4337, 40.637897, -52.5879, -10.4337, 43.772198, -54.004498, -10.4337, 42.233097, -54.2416, -10.4337, 45.2492, -52.5793, -10.4337, 47.051098, -54.056297, -10.4337, 48.408897, -52.5879, -10.4337, 43.772198, -52.5793, -10.4337, 47.051098, -54.2416, -10.4337, 45.2492, -52.5793, -10.4337, 47.051098, -52.2215, -10.4337, 50.418697, -54.056297, -10.4337, 48.408897, -50.5928, -10.4337, 39.2532, -50.779198, -10.4337, 42.1568, -52.1782, -10.4337, 40.637897, -52.1782, -10.4337, 40.637897, -50.779198, -10.4337, 42.1568, -52.5879, -10.4337, 43.772198, -50.5928, -10.4337, 39.2532, -49.212498, -10.4337, 40.7576, -50.779198, -10.4337, 42.1568, -52.5879, -10.4337, 43.772198, -50.9492, -10.4337, 45.552597, -52.5793, -10.4337, 47.051098, -50.779198, -10.4337, 42.1568, -50.9492, -10.4337, 45.552597, -52.5879, -10.4337, 43.772198, -52.5793, -10.4337, 47.051098, -50.741398, -10.4337, 49.0436, -52.2215, -10.4337, 50.418697, -50.9492, -10.4337, 45.552597, -50.741398, -10.4337, 49.0436, -52.5793, -10.4337, 47.051098, -50.741398, -10.4337, 49.0436, -50.267597, -10.4337, 52.5587, -52.2215, -10.4337, 50.418697, -49.212498, -10.4337, 40.7576, -49.163498, -10.4337, 43.911, -50.779198, -10.4337, 42.1568, -50.779198, -10.4337, 42.1568, -49.163498, -10.4337, 43.911, -50.9492, -10.4337, 45.552597, -49.212498, -10.4337, 40.7576, -47.620697, -10.4337, 42.4927, -49.163498, -10.4337, 43.911, -50.9492, -10.4337, 45.552597, -49.1213, -10.4337, 47.5384, -50.741398, -10.4337, 49.0436, -49.163498, -10.4337, 43.911, -49.1213, -10.4337, 47.5384, -50.9492, -10.4337, 45.552597, -50.741398, -10.4337, 49.0436, -48.787197, -10.4337, 51.162, -50.267597, -10.4337, 52.5587, -49.1213, -10.4337, 47.5384, -48.787197, -10.4337, 51.162, -50.741398, -10.4337, 49.0436, -48.787197, -10.4337, 51.162, -48.3197, -10.4337, 54.6923, -50.267597, -10.4337, 52.5587, -47.620697, -10.4337, 42.4927, -47.343597, -10.4337, 45.8868, -49.163498, -10.4337, 43.911, -49.163498, -10.4337, 43.911, -47.343597, -10.4337, 45.8868, -49.1213, -10.4337, 47.5384, -47.620697, -10.4337, 42.4927, -45.8121, -10.4337, 44.463898, -47.343597, -10.4337, 45.8868, -49.1213, -10.4337, 47.5384, -47.181, -10.4337, 49.6466, -48.787197, -10.4337, 51.162, -47.343597, -10.4337, 45.8868, -47.181, -10.4337, 49.6466, -49.1213, -10.4337, 47.5384, -48.787197, -10.4337, 51.162, -46.8489, -10.4337, 53.263298, -48.3197, -10.4337, 54.6923, -47.181, -10.4337, 49.6466, -46.8489, -10.4337, 53.263298, -48.787197, -10.4337, 51.162, -46.8489, -10.4337, 53.263298, -46.839, -10.4337, 56.314198, -48.3197, -10.4337, 54.6923, -45.8121, -10.4337, 44.463898, -45.4151, -10.4337, 47.9805, -47.343597, -10.4337, 45.8868, -47.343597, -10.4337, 45.8868, -45.4151, -10.4337, 47.9805, -47.181, -10.4337, 49.6466, -45.8121, -10.4337, 44.463898, -43.8985, -10.4337, 46.549698, -45.4151, -10.4337, 47.9805, -47.181, -10.4337, 49.6466, -45.2669, -10.4337, 51.7262, -46.8489, -10.4337, 53.263298, -45.4151, -10.4337, 47.9805, -45.2669, -10.4337, 51.7262, -47.181, -10.4337, 49.6466, -46.8489, -10.4337, 53.263298, -45.3659, -10.4337, 54.8708, -46.839, -10.4337, 56.314198, -45.2669, -10.4337, 51.7262, -45.3659, -10.4337, 54.8708, -46.8489, -10.4337, 53.263298, -45.3659, -10.4337, 54.8708, -45.411, -10.4337, 57.8783, -46.839, -10.4337, 56.314198, -43.8985, -10.4337, 46.549698, -43.524498, -10.4337, 50.0333, -45.4151, -10.4337, 47.9805, -45.4151, -10.4337, 47.9805, -43.524498, -10.4337, 50.0333, -45.2669, -10.4337, 51.7262, -43.8985, -10.4337, 46.549698, -42.032497, -10.4337, 48.5836, -43.524498, -10.4337, 50.0333, -45.2669, -10.4337, 51.7262, -43.792, -10.4337, 53.328598, -45.3659, -10.4337, 54.8708, -43.524498, -10.4337, 50.0333, -43.792, -10.4337, 53.328598, -45.2669, -10.4337, 51.7262, -45.3659, -10.4337, 54.8708, -43.935497, -10.4337, 56.421497, -45.411, -10.4337, 57.8783, -43.792, -10.4337, 53.328598, -43.935497, -10.4337, 56.421497, -45.3659, -10.4337, 54.8708, -43.935497, -10.4337, 56.421497, -43.670998, -10.4337, 59.7842, -45.411, -10.4337, 57.8783, -42.032497, -10.4337, 48.5836, -42.056, -10.4337, 51.6275, -43.524498, -10.4337, 50.0333, -43.524498, -10.4337, 50.0333, -42.056, -10.4337, 51.6275, -43.792, -10.4337, 53.328598, -42.032497, -10.4337, 48.5836, -40.572998, -10.4337, 50.1744, -42.056, -10.4337, 51.6275, -43.792, -10.4337, 53.328598, -42.368797, -10.4337, 54.874798, -43.935497, -10.4337, 56.421497, -42.056, -10.4337, 51.6275, -42.368797, -10.4337, 54.874798, -43.792, -10.4337, 53.328598, -43.935497, -10.4337, 56.421497, -42.2006, -10.4337, 58.3022, -43.670998, -10.4337, 59.7842, -42.368797, -10.4337, 54.874798, -42.2006, -10.4337, 58.3022, -43.935497, -10.4337, 56.421497, -42.2006, -10.4337, 58.3022, -41.9354, -10.4337, 61.685097, -43.670998, -10.4337, 59.7842, -40.572998, -10.4337, 50.1744, -40.6386, -10.4337, 53.1665, -42.056, -10.4337, 51.6275, -42.056, -10.4337, 51.6275, -40.6386, -10.4337, 53.1665, -42.368797, -10.4337, 54.874798, -40.572998, -10.4337, 50.1744, -39.1638, -10.4337, 51.7104, -40.6386, -10.4337, 53.1665, -42.368797, -10.4337, 54.874798, -40.6512, -10.4337, 56.7408, -42.2006, -10.4337, 58.3022, -40.6386, -10.4337, 53.1665, -40.6512, -10.4337, 56.7408, -42.368797, -10.4337, 54.874798, -42.2006, -10.4337, 58.3022, -40.4544, -10.4337, 60.1951, -41.9354, -10.4337, 61.685097, -40.6512, -10.4337, 56.7408, -40.4544, -10.4337, 60.1951, -42.2006, -10.4337, 58.3022, -40.4544, -10.4337, 60.1951, -40.3009, -10.4337, 63.4754, -41.9354, -10.4337, 61.685097, -39.1638, -10.4337, 51.7104, -38.937298, -10.4337, 55.0135, -40.6386, -10.4337, 53.1665, -40.6386, -10.4337, 53.1665, -38.937298, -10.4337, 55.0135, -40.6512, -10.4337, 56.7408, -39.1638, -10.4337, 51.7104, -37.4804, -10.4337, 53.5453, -38.937298, -10.4337, 55.0135, -40.6512, -10.4337, 56.7408, -38.9059, -10.4337, 58.6371, -40.4544, -10.4337, 60.1951, -38.937298, -10.4337, 55.0135, -38.9059, -10.4337, 58.6371, -40.6512, -10.4337, 56.7408, -40.4544, -10.4337, 60.1951, -38.822998, -10.4337, 61.9637, -40.3009, -10.4337, 63.4754, -38.9059, -10.4337, 58.6371, -38.822998, -10.4337, 61.9637, -40.4544, -10.4337, 60.1951, -38.822998, -10.4337, 61.9637, -38.953697, -10.4337, 64.951, -40.3009, -10.4337, 63.4754, -37.4804, -10.4337, 53.5453, -37.1899, -10.4337, 56.910698, -38.937298, -10.4337, 55.0135, -38.937298, -10.4337, 55.0135, -37.1899, -10.4337, 56.910698, -38.9059, -10.4337, 58.6371, -37.4804, -10.4337, 53.5453, -35.735397, -10.4337, 55.4473, -37.1899, -10.4337, 56.910698, -38.9059, -10.4337, 58.6371, -37.2887, -10.4337, 60.3941, -38.822998, -10.4337, 61.9637, -37.1899, -10.4337, 56.910698, -37.2887, -10.4337, 60.3941, -38.9059, -10.4337, 58.6371, -38.822998, -10.4337, 61.9637, -37.4867, -10.4337, 63.4123, -38.953697, -10.4337, 64.951, -37.2887, -10.4337, 60.3941, -37.4867, -10.4337, 63.4123, -38.822998, -10.4337, 61.9637, -37.4867, -10.4337, 63.4123, -37.4493, -10.4337, 66.5989, -38.953697, -10.4337, 64.951, -35.735397, -10.4337, 55.4473, -35.5858, -10.4337, 58.6522, -37.1899, -10.4337, 56.910698, -37.1899, -10.4337, 56.910698, -35.5858, -10.4337, 58.6522, -37.2887, -10.4337, 60.3941, -35.735397, -10.4337, 55.4473, -34.1461, -10.4337, 57.1796, -35.5858, -10.4337, 58.6522, -37.2887, -10.4337, 60.3941, -35.9725, -10.4337, 61.824097, -37.4867, -10.4337, 63.4123, -35.5858, -10.4337, 58.6522, -35.9725, -10.4337, 61.824097, -37.2887, -10.4337, 60.3941, -37.4867, -10.4337, 63.4123, -35.9927, -10.4337, 65.0319, -37.4493, -10.4337, 66.5989, -35.9725, -10.4337, 61.824097, -35.9927, -10.4337, 65.0319, -37.4867, -10.4337, 63.4123, -35.9927, -10.4337, 65.0319, -36.0228, -10.4337, 68.1614, -37.4493, -10.4337, 66.5989, -34.1461, -10.4337, 57.1796, -34.2898, -10.4337, 60.0593, -35.5858, -10.4337, 58.6522, -35.5858, -10.4337, 58.6522, -34.2898, -10.4337, 60.0593, -35.9725, -10.4337, 61.824097, -34.1461, -10.4337, 57.1796, -32.870197, -10.4337, 58.570297, -34.2898, -10.4337, 60.0593, -35.9725, -10.4337, 61.824097, -34.499, -10.4337, 63.425, -35.9927, -10.4337, 65.0319, -34.2898, -10.4337, 60.0593, -34.499, -10.4337, 63.425, -35.9725, -10.4337, 61.824097, -35.9927, -10.4337, 65.0319, -34.5678, -10.4337, 66.5766, -36.0228, -10.4337, 68.1614, -34.499, -10.4337, 63.425, -34.5678, -10.4337, 66.5766, -35.9927, -10.4337, 65.0319, -34.5678, -10.4337, 66.5766, -34.1187, -10.4337, 70.246895, -36.0228, -10.4337, 68.1614, -32.870197, -10.4337, 58.570297, -32.8368, -10.4337, 61.636898, -34.2898, -10.4337, 60.0593, -34.2898, -10.4337, 60.0593, -32.8368, -10.4337, 61.636898, -34.499, -10.4337, 63.425, -32.870197, -10.4337, 58.570297, -31.437601, -10.4337, 60.131798, -32.8368, -10.4337, 61.636898, -34.499, -10.4337, 63.425, -33.0849, -10.4337, 64.961395, -34.5678, -10.4337, 66.5766, -32.8368, -10.4337, 61.636898, -33.0849, -10.4337, 64.961395, -34.499, -10.4337, 63.425, -34.5678, -10.4337, 66.5766, -32.69, -10.4337, 68.6121, -34.1187, -10.4337, 70.246895, -33.0849, -10.4337, 64.961395, -32.69, -10.4337, 68.6121, -34.5678, -10.4337, 66.5766, -32.69, -10.4337, 68.6121, -32.6527, -10.4337, 71.8527, -34.1187, -10.4337, 70.246895, -31.437601, -10.4337, 60.131798, -31.4325, -10.4337, 63.1615, -32.8368, -10.4337, 61.636898, -32.8368, -10.4337, 61.636898, -31.4325, -10.4337, 63.1615, -33.0849, -10.4337, 64.961395, -31.437601, -10.4337, 60.131798, -30.044802, -10.4337, 61.649998, -31.4325, -10.4337, 63.1615, -33.0849, -10.4337, 64.961395, -31.245802, -10.4337, 66.959496, -32.69, -10.4337, 68.6121, -31.4325, -10.4337, 63.1615, -31.245802, -10.4337, 66.959496, -33.0849, -10.4337, 64.961395, -32.69, -10.4337, 68.6121, -31.2026, -10.4337, 70.2246, -32.6527, -10.4337, 71.8527, -31.245802, -10.4337, 66.959496, -31.2026, -10.4337, 70.2246, -32.69, -10.4337, 68.6121, -31.2026, -10.4337, 70.2246, -31.218801, -10.4337, 73.423195, -32.6527, -10.4337, 71.8527, -30.044802, -10.4337, 61.649998, -29.633501, -10.4337, 65.1147, -31.4325, -10.4337, 63.1615, -31.4325, -10.4337, 63.1615, -29.633501, -10.4337, 65.1147, -31.245802, -10.4337, 66.959496, -30.044802, -10.4337, 61.649998, -28.2834, -10.4337, 63.569798, -29.633501, -10.4337, 65.1147, -31.245802, -10.4337, 66.959496, -29.745901, -10.4337, 68.589, -31.2026, -10.4337, 70.2246, -29.633501, -10.4337, 65.1147, -29.745901, -10.4337, 68.589, -31.245802, -10.4337, 66.959496, -31.2026, -10.4337, 70.2246, -29.787, -10.4337, 71.7591, -31.218801, -10.4337, 73.423195, -29.745901, -10.4337, 68.589, -29.787, -10.4337, 71.7591, -31.2026, -10.4337, 70.2246, -29.787, -10.4337, 71.7591, -29.6429, -10.4337, 75.1494, -31.218801, -10.4337, 73.423195, -28.2834, -10.4337, 63.569798, -28.117498, -10.4337, 66.7606, -29.633501, -10.4337, 65.1147, -29.633501, -10.4337, 65.1147, -28.117498, -10.4337, 66.7606, -29.745901, -10.4337, 68.589, -28.2834, -10.4337, 63.569798, -26.7572, -10.4337, 65.2334, -28.117498, -10.4337, 66.7606, -29.745901, -10.4337, 68.589, -28.357399, -10.4337, 70.097496, -29.787, -10.4337, 71.7591, -28.117498, -10.4337, 66.7606, -28.357399, -10.4337, 70.097496, -29.745901, -10.4337, 68.589, -29.787, -10.4337, 71.7591, -28.1941, -10.4337, 73.4859, -29.6429, -10.4337, 75.1494, -28.357399, -10.4337, 70.097496, -28.1941, -10.4337, 73.4859, -29.787, -10.4337, 71.7591, -28.1941, -10.4337, 73.4859, -28.2134, -10.4337, 76.715096, -29.6429, -10.4337, 75.1494, -26.7572, -10.4337, 65.2334, -26.7572, -10.4337, 68.237495, -28.117498, -10.4337, 66.7606, -28.117498, -10.4337, 66.7606, -26.7572, -10.4337, 68.237495, -28.357399, -10.4337, 70.097496, -26.7572, -10.4337, 68.237495, -26.7572, -10.4337, 65.2334, -23.412498, -10.4337, 65.2334, -28.1941, -10.4337, 73.4859, -26.7572, -10.4337, 75.043594, -28.2134, -10.4337, 76.715096, -26.7572, -10.4337, 75.043594, -26.7572, -10.4337, 78.3101, -28.2134, -10.4337, 76.715096, -28.357399, -10.4337, 70.097496, -26.7572, -10.4337, 71.8361, -28.1941, -10.4337, 73.4859, -26.7572, -10.4337, 68.237495, -26.7572, -10.4337, 71.8361, -28.357399, -10.4337, 70.097496, -26.7572, -10.4337, 71.8361, -26.7572, -10.4337, 75.043594, -28.1941, -10.4337, 73.4859, -23.412498, -10.4337, 78.3101, -26.7572, -10.4337, 78.3101, -26.7572, -10.4337, 75.043594, -26.7572, -10.4337, 71.8361, -26.7572, -10.4337, 68.237495, -23.412498, -10.4337, 68.237495, -26.7572, -10.4337, 68.237495, -23.412498, -10.4337, 65.2334, -23.412498, -10.4337, 68.237495, -23.412498, -10.4337, 68.237495, -23.412498, -10.4337, 65.2334, -20.0679, -10.4337, 65.2334, -26.7572, -10.4337, 71.8361, -23.412498, -10.4337, 68.237495, -23.412498, -10.4337, 71.8361, -26.7572, -10.4337, 75.043594, -26.7572, -10.4337, 71.8361, -23.412498, -10.4337, 71.8361, -23.412498, -10.4337, 68.237495, -20.0679, -10.4337, 65.2334, -20.0679, -10.4337, 68.237495, -23.412498, -10.4337, 71.8361, -23.412498, -10.4337, 68.237495, -20.0679, -10.4337, 68.237495, -16.7232, -10.4337, 65.2334, -20.0679, -10.4337, 68.237495, -20.0679, -10.4337, 65.2334, -23.412498, -10.4337, 78.3101, -26.7572, -10.4337, 75.043594, -23.412498, -10.4337, 75.043594, -26.7572, -10.4337, 75.043594, -23.412498, -10.4337, 71.8361, -23.412498, -10.4337, 75.043594, -20.0679, -10.4337, 78.3101, -23.412498, -10.4337, 78.3101, -23.412498, -10.4337, 75.043594, -23.412498, -10.4337, 71.8361, -20.0679, -10.4337, 68.237495, -20.0679, -10.4337, 71.8361, -23.412498, -10.4337, 75.043594, -23.412498, -10.4337, 71.8361, -20.0679, -10.4337, 71.8361, -16.7232, -10.4337, 65.2334, -16.7232, -10.4337, 68.237495, -20.0679, -10.4337, 68.237495, -16.7232, -10.4337, 68.237495, -20.0679, -10.4337, 71.8361, -20.0679, -10.4337, 68.237495, -16.7232, -10.4337, 68.237495, -16.7232, -10.4337, 65.2334, -13.378601, -10.4337, 65.2334, -23.412498, -10.4337, 75.043594, -20.0679, -10.4337, 71.8361, -20.0679, -10.4337, 75.043594, -20.0679, -10.4337, 78.3101, -23.412498, -10.4337, 75.043594, -20.0679, -10.4337, 75.043594, -16.7232, -10.4337, 68.237495, -16.7232, -10.4337, 71.8361, -20.0679, -10.4337, 71.8361, -16.7232, -10.4337, 71.8361, -20.0679, -10.4337, 75.043594, -20.0679, -10.4337, 71.8361, -16.7232, -10.4337, 75.043594, -20.0679, -10.4337, 78.3101, -20.0679, -10.4337, 75.043594, -16.7232, -10.4337, 71.8361, -16.7232, -10.4337, 75.043594, -20.0679, -10.4337, 75.043594, -16.7232, -10.4337, 75.043594, -16.7232, -10.4337, 78.3101, -20.0679, -10.4337, 78.3101, -16.7232, -10.4337, 71.8361, -16.7232, -10.4337, 68.237495, -13.378601, -10.4337, 68.237495, -16.7232, -10.4337, 68.237495, -13.378601, -10.4337, 65.2334, -13.378601, -10.4337, 68.237495, -16.7232, -10.4337, 75.043594, -16.7232, -10.4337, 71.8361, -13.378601, -10.4337, 71.8361, -16.7232, -10.4337, 71.8361, -13.378601, -10.4337, 68.237495, -13.378601, -10.4337, 71.8361, -16.7232, -10.4337, 78.3101, -16.7232, -10.4337, 75.043594, -13.378601, -10.4337, 75.043594, -16.7232, -10.4337, 75.043594, -13.378601, -10.4337, 71.8361, -13.378601, -10.4337, 75.043594, -16.7232, -10.4337, 78.3101, -13.378601, -10.4337, 75.043594, -13.378601, -10.4337, 78.3101, -13.378601, -10.4337, 65.2334, -10.033899, -10.4337, 68.237495, -13.378601, -10.4337, 68.237495, -10.033899, -10.4337, 68.237495, -13.378601, -10.4337, 71.8361, -13.378601, -10.4337, 68.237495, -13.378601, -10.4337, 65.2334, -10.033899, -10.4337, 65.2334, -10.033899, -10.4337, 68.237495, -10.033899, -10.4337, 71.8361, -13.378601, -10.4337, 75.043594, -13.378601, -10.4337, 71.8361, -10.033899, -10.4337, 68.237495, -10.033899, -10.4337, 71.8361, -13.378601, -10.4337, 71.8361, -10.033899, -10.4337, 75.043594, -13.378601, -10.4337, 78.3101, -13.378601, -10.4337, 75.043594, -10.033899, -10.4337, 71.8361, -10.033899, -10.4337, 75.043594, -13.378601, -10.4337, 75.043594, -10.033899, -10.4337, 75.043594, -10.033899, -10.4337, 78.3101, -13.378601, -10.4337, 78.3101, -10.033899, -10.4337, 65.2334, -6.6893, -10.4337, 68.237495, -10.033899, -10.4337, 68.237495, -6.6893, -10.4337, 68.237495, -10.033899, -10.4337, 71.8361, -10.033899, -10.4337, 68.237495, -10.033899, -10.4337, 65.2334, -6.6893, -10.4337, 65.2334, -6.6893, -10.4337, 68.237495, -6.6893, -10.4337, 71.8361, -10.033899, -10.4337, 75.043594, -10.033899, -10.4337, 71.8361, -6.6893, -10.4337, 68.237495, -6.6893, -10.4337, 71.8361, -10.033899, -10.4337, 71.8361, -6.6893, -10.4337, 75.043594, -10.033899, -10.4337, 78.3101, -10.033899, -10.4337, 75.043594, -6.6893, -10.4337, 71.8361, -6.6893, -10.4337, 75.043594, -10.033899, -10.4337, 75.043594, -6.6893, -10.4337, 75.043594, -6.6893, -10.4337, 78.3101, -10.033899, -10.4337, 78.3101, -6.6893, -10.4337, 65.2334, -3.3446002, -10.4337, 68.237495, -6.6893, -10.4337, 68.237495, -3.3446002, -10.4337, 68.237495, -6.6893, -10.4337, 71.8361, -6.6893, -10.4337, 68.237495, -6.6893, -10.4337, 65.2334, -3.3446002, -10.4337, 65.2334, -3.3446002, -10.4337, 68.237495, -3.3446002, -10.4337, 71.8361, -6.6893, -10.4337, 75.043594, -6.6893, -10.4337, 71.8361, -3.3446002, -10.4337, 68.237495, -3.3446002, -10.4337, 71.8361, -6.6893, -10.4337, 71.8361, -3.3446002, -10.4337, 75.043594, -6.6893, -10.4337, 78.3101, -6.6893, -10.4337, 75.043594, -3.3446002, -10.4337, 71.8361, -3.3446002, -10.4337, 75.043594, -6.6893, -10.4337, 75.043594, -3.3446002, -10.4337, 75.043594, -3.3446002, -10.4337, 78.3101, -6.6893, -10.4337, 78.3101, -3.3446002, -10.4337, 65.2334, 0, -10.4337, 68.237495, -3.3446002, -10.4337, 68.237495, 0, -10.4337, 68.237495, -3.3446002, -10.4337, 71.8361, -3.3446002, -10.4337, 68.237495, -3.3446002, -10.4337, 65.2334, 0, -10.4337, 65.2334, 0, -10.4337, 68.237495, 0, -10.4337, 71.8361, -3.3446002, -10.4337, 75.043594, -3.3446002, -10.4337, 71.8361, 0, -10.4337, 68.237495, 0, -10.4337, 71.8361, -3.3446002, -10.4337, 71.8361, 0, -10.4337, 75.043594, -3.3446002, -10.4337, 78.3101, -3.3446002, -10.4337, 75.043594, 0, -10.4337, 71.8361, 0, -10.4337, 75.043594, -3.3446002, -10.4337, 75.043594, 0, -10.4337, 75.043594, 0, -10.4337, 78.3101, -3.3446002, -10.4337, 78.3101, 0, -10.4337, 65.2334, 3.3446, -10.4337, 68.237495, 0, -10.4337, 68.237495, 3.3446, -10.4337, 68.237495, 0, -10.4337, 71.8361, 0, -10.4337, 68.237495, 0, -10.4337, 65.2334, 3.3446, -10.4337, 65.2334, 3.3446, -10.4337, 68.237495, 3.3446, -10.4337, 71.8361, 0, -10.4337, 75.043594, 0, -10.4337, 71.8361, 3.3446, -10.4337, 68.237495, 3.3446, -10.4337, 71.8361, 0, -10.4337, 71.8361, 3.3446, -10.4337, 75.043594, 0, -10.4337, 78.3101, 0, -10.4337, 75.043594, 3.3446, -10.4337, 71.8361, 3.3446, -10.4337, 75.043594, 0, -10.4337, 75.043594, 3.3446, -10.4337, 75.043594, 3.3446, -10.4337, 78.3101, 0, -10.4337, 78.3101, 3.3446, -10.4337, 65.2334, 6.6893, -10.4337, 68.237495, 3.3446, -10.4337, 68.237495, 6.6893, -10.4337, 68.237495, 3.3446, -10.4337, 71.8361, 3.3446, -10.4337, 68.237495, 3.3446, -10.4337, 65.2334, 6.6893, -10.4337, 65.2334, 6.6893, -10.4337, 68.237495, 6.6893, -10.4337, 71.8361, 3.3446, -10.4337, 75.043594, 3.3446, -10.4337, 71.8361, 6.6893, -10.4337, 68.237495, 6.6893, -10.4337, 71.8361, 3.3446, -10.4337, 71.8361, 6.6893, -10.4337, 75.043594, 3.3446, -10.4337, 78.3101, 3.3446, -10.4337, 75.043594, 6.6893, -10.4337, 71.8361, 6.6893, -10.4337, 75.043594, 3.3446, -10.4337, 75.043594, 6.6893, -10.4337, 75.043594, 6.6893, -10.4337, 78.3101, 3.3446, -10.4337, 78.3101, 6.6893, -10.4337, 65.2334, 10.033899, -10.4337, 68.237495, 6.6893, -10.4337, 68.237495, 10.033899, -10.4337, 68.237495, 6.6893, -10.4337, 71.8361, 6.6893, -10.4337, 68.237495, 6.6893, -10.4337, 65.2334, 10.033899, -10.4337, 65.2334, 10.033899, -10.4337, 68.237495, 10.033899, -10.4337, 71.8361, 6.6893, -10.4337, 75.043594, 6.6893, -10.4337, 71.8361, 10.033899, -10.4337, 68.237495, 10.033899, -10.4337, 71.8361, 6.6893, -10.4337, 71.8361, 10.033899, -10.4337, 75.043594, 6.6893, -10.4337, 78.3101, 6.6893, -10.4337, 75.043594, 10.033899, -10.4337, 71.8361, 10.033899, -10.4337, 75.043594, 6.6893, -10.4337, 75.043594, 10.033899, -10.4337, 75.043594, 10.033899, -10.4337, 78.3101, 6.6893, -10.4337, 78.3101, 10.033899, -10.4337, 65.2334, 13.378599, -10.4337, 68.237495, 10.033899, -10.4337, 68.237495, 13.378599, -10.4337, 68.237495, 10.033899, -10.4337, 71.8361, 10.033899, -10.4337, 68.237495, 10.033899, -10.4337, 65.2334, 13.378599, -10.4337, 65.2334, 13.378599, -10.4337, 68.237495, 13.378599, -10.4337, 71.8361, 10.033899, -10.4337, 75.043594, 10.033899, -10.4337, 71.8361, 13.378599, -10.4337, 68.237495, 13.378599, -10.4337, 71.8361, 10.033899, -10.4337, 71.8361, 13.378599, -10.4337, 75.043594, 10.033899, -10.4337, 78.3101, 10.033899, -10.4337, 75.043594, 13.378599, -10.4337, 71.8361, 13.378599, -10.4337, 75.043594, 10.033899, -10.4337, 75.043594, 13.378599, -10.4337, 75.043594, 13.378599, -10.4337, 78.3101, 10.033899, -10.4337, 78.3101, 13.378599, -10.4337, 65.2334, 16.7232, -10.4337, 68.237495, 13.378599, -10.4337, 68.237495, 16.7232, -10.4337, 68.237495, 13.378599, -10.4337, 71.8361, 13.378599, -10.4337, 68.237495, 13.378599, -10.4337, 65.2334, 16.7232, -10.4337, 65.2334, 16.7232, -10.4337, 68.237495, 16.7232, -10.4337, 71.8361, 13.378599, -10.4337, 75.043594, 13.378599, -10.4337, 71.8361, 16.7232, -10.4337, 68.237495, 16.7232, -10.4337, 71.8361, 13.378599, -10.4337, 71.8361, 16.7232, -10.4337, 75.043594, 13.378599, -10.4337, 78.3101, 13.378599, -10.4337, 75.043594, 16.7232, -10.4337, 71.8361, 16.7232, -10.4337, 75.043594, 13.378599, -10.4337, 75.043594, 16.7232, -10.4337, 75.043594, 16.7232, -10.4337, 78.3101, 13.378599, -10.4337, 78.3101, 16.7232, -10.4337, 65.2334, 20.0679, -10.4337, 68.237495, 16.7232, -10.4337, 68.237495, 20.0679, -10.4337, 68.237495, 16.7232, -10.4337, 71.8361, 16.7232, -10.4337, 68.237495, 16.7232, -10.4337, 65.2334, 20.0679, -10.4337, 65.2334, 20.0679, -10.4337, 68.237495, 20.0679, -10.4337, 71.8361, 16.7232, -10.4337, 75.043594, 16.7232, -10.4337, 71.8361, 20.0679, -10.4337, 68.237495, 20.0679, -10.4337, 71.8361, 16.7232, -10.4337, 71.8361, 20.0679, -10.4337, 75.043594, 16.7232, -10.4337, 78.3101, 16.7232, -10.4337, 75.043594, 20.0679, -10.4337, 71.8361, 20.0679, -10.4337, 75.043594, 16.7232, -10.4337, 75.043594, 20.0679, -10.4337, 75.043594, 20.0679, -10.4337, 78.3101, 16.7232, -10.4337, 78.3101, 20.0679, -10.4337, 65.2334, 23.412498, -10.4337, 68.237495, 20.0679, -10.4337, 68.237495, 23.412498, -10.4337, 68.237495, 20.0679, -10.4337, 71.8361, 20.0679, -10.4337, 68.237495, 20.0679, -10.4337, 65.2334, 23.412498, -10.4337, 65.2334, 23.412498, -10.4337, 68.237495, 23.412498, -10.4337, 71.8361, 20.0679, -10.4337, 75.043594, 20.0679, -10.4337, 71.8361, 23.412498, -10.4337, 68.237495, 23.412498, -10.4337, 71.8361, 20.0679, -10.4337, 71.8361, 23.412498, -10.4337, 75.043594, 20.0679, -10.4337, 78.3101, 20.0679, -10.4337, 75.043594, 23.412498, -10.4337, 71.8361, 23.412498, -10.4337, 75.043594, 20.0679, -10.4337, 75.043594, 23.412498, -10.4337, 75.043594, 23.412498, -10.4337, 78.3101, 20.0679, -10.4337, 78.3101, 23.412498, -10.4337, 65.2334, 26.7582, -10.4337, 68.237495, 23.412498, -10.4337, 68.237495, 26.7582, -10.4337, 68.237495, 23.412498, -10.4337, 71.8361, 23.412498, -10.4337, 68.237495, 23.412498, -10.4337, 65.2334, 26.7582, -10.4337, 65.2334, 26.7582, -10.4337, 68.237495, 26.7582, -10.4337, 71.8361, 23.412498, -10.4337, 75.043594, 23.412498, -10.4337, 71.8361, 26.7582, -10.4337, 68.237495, 26.7582, -10.4337, 71.8361, 23.412498, -10.4337, 71.8361, 26.7582, -10.4337, 75.043594, 23.412498, -10.4337, 78.3101, 23.412498, -10.4337, 75.043594, 26.7582, -10.4337, 71.8361, 26.7582, -10.4337, 75.043594, 23.412498, -10.4337, 75.043594, 26.7582, -10.4337, 75.043594, 26.7582, -10.4337, 78.3101, 23.412498, -10.4337, 78.3101, 26.7582, -10.4337, 75.043594, 75.8818, -10.4337, 26.7572, 26.7582, -10.4337, 78.3101, 26.7582, -10.4337, 71.8361, 75.8818, -10.4337, 26.7572, 26.7582, -10.4337, 75.043594, 26.7582, -10.4337, 78.3101, 75.8818, -10.4337, 26.7572, 79.236, -10.4337, 26.7572, 79.236, -10.4337, 23.412498, 79.236, -10.4337, 26.7572, 75.8818, -10.4337, 26.7572, 71.9379, -10.4337, 26.7572, 26.7582, -10.4337, 71.8361, 26.7582, -10.4337, 68.237495, 75.8818, -10.4337, 26.7572, 26.7582, -10.4337, 71.8361, 71.9379, -10.4337, 26.7572, 68.9355, -10.4337, 26.7572, 26.7582, -10.4337, 68.237495, 26.7582, -10.4337, 65.2334, 71.9379, -10.4337, 26.7572, 26.7582, -10.4337, 68.237495, 68.9355, -10.4337, 26.7572, 68.9355, -10.4337, 26.7572, 26.7582, -10.4337, 65.2334, 66.1166, -10.4337, 26.7572, 68.9355, -10.4337, 26.7572, 66.1166, -10.4337, 26.7572, 66.1166, -10.4337, 23.412498, 68.9355, -10.4337, 26.7572, 66.1166, -10.4337, 23.412498, 68.937, -10.4337, 23.412498, 71.9379, -10.4337, 26.7572, 68.9355, -10.4337, 26.7572, 68.937, -10.4337, 23.412498, 68.937, -10.4337, 23.412498, 66.1166, -10.4337, 23.412498, 66.1166, -10.4337, 20.0679, 75.8818, -10.4337, 26.7572, 71.9379, -10.4337, 26.7572, 71.9555, -10.4337, 23.412498, 71.9379, -10.4337, 26.7572, 68.937, -10.4337, 23.412498, 71.9555, -10.4337, 23.412498, 79.236, -10.4337, 23.412498, 75.8818, -10.4337, 26.7572, 75.8708, -10.4337, 23.412498, 75.8818, -10.4337, 26.7572, 71.9555, -10.4337, 23.412498, 75.8708, -10.4337, 23.412498, 79.236, -10.4337, 20.0679, 79.236, -10.4337, 23.412498, 75.8708, -10.4337, 23.412498, 68.937, -10.4337, 23.412498, 66.1166, -10.4337, 20.0679, 68.9385, -10.4337, 20.0679, 71.9555, -10.4337, 23.412498, 68.937, -10.4337, 23.412498, 68.9385, -10.4337, 20.0679, 68.9385, -10.4337, 20.0679, 66.1166, -10.4337, 20.0679, 66.1166, -10.4337, 16.7232, 75.8708, -10.4337, 23.412498, 71.9555, -10.4337, 23.412498, 71.9732, -10.4337, 20.0679, 71.9555, -10.4337, 23.412498, 68.9385, -10.4337, 20.0679, 71.9732, -10.4337, 20.0679, 79.236, -10.4337, 20.0679, 75.8708, -10.4337, 23.412498, 75.859795, -10.4337, 20.0679, 75.8708, -10.4337, 23.412498, 71.9732, -10.4337, 20.0679, 75.859795, -10.4337, 20.0679, 79.236, -10.4337, 16.7232, 79.236, -10.4337, 20.0679, 75.859795, -10.4337, 20.0679, 68.9385, -10.4337, 20.0679, 66.1166, -10.4337, 16.7232, 68.9401, -10.4337, 16.7232, 71.9732, -10.4337, 20.0679, 68.9385, -10.4337, 20.0679, 68.9401, -10.4337, 16.7232, 68.9401, -10.4337, 16.7232, 66.1166, -10.4337, 16.7232, 66.1166, -10.4337, 13.378601, 75.859795, -10.4337, 20.0679, 71.9732, -10.4337, 20.0679, 71.9908, -10.4337, 16.7232, 71.9732, -10.4337, 20.0679, 68.9401, -10.4337, 16.7232, 71.9908, -10.4337, 16.7232, 79.236, -10.4337, 16.7232, 75.859795, -10.4337, 20.0679, 75.8488, -10.4337, 16.7232, 75.859795, -10.4337, 20.0679, 71.9908, -10.4337, 16.7232, 75.8488, -10.4337, 16.7232, 79.236, -10.4337, 13.378601, 79.236, -10.4337, 16.7232, 75.8488, -10.4337, 16.7232, 68.9401, -10.4337, 16.7232, 66.1166, -10.4337, 13.378601, 68.9416, -10.4337, 13.378601, 71.9908, -10.4337, 16.7232, 68.9401, -10.4337, 16.7232, 68.9416, -10.4337, 13.378601, 68.9416, -10.4337, 13.378601, 66.1166, -10.4337, 13.378601, 66.1166, -10.4337, 10.033899, 75.8488, -10.4337, 16.7232, 71.9908, -10.4337, 16.7232, 72.0085, -10.4337, 13.378601, 71.9908, -10.4337, 16.7232, 68.9416, -10.4337, 13.378601, 72.0085, -10.4337, 13.378601, 79.236, -10.4337, 13.378601, 75.8488, -10.4337, 16.7232, 75.8377, -10.4337, 13.378601, 75.8488, -10.4337, 16.7232, 72.0085, -10.4337, 13.378601, 75.8377, -10.4337, 13.378601, 79.236, -10.4337, 10.033899, 79.236, -10.4337, 13.378601, 75.8377, -10.4337, 13.378601, 68.9416, -10.4337, 13.378601, 66.1166, -10.4337, 10.033899, 68.9432, -10.4337, 10.033899, 72.0085, -10.4337, 13.378601, 68.9416, -10.4337, 13.378601, 68.9432, -10.4337, 10.033899, 68.9432, -10.4337, 10.033899, 66.1166, -10.4337, 10.033899, 66.1166, -10.4337, 6.6893, 75.8377, -10.4337, 13.378601, 72.0085, -10.4337, 13.378601, 72.0261, -10.4337, 10.033899, 72.0085, -10.4337, 13.378601, 68.9432, -10.4337, 10.033899, 72.0261, -10.4337, 10.033899, 79.236, -10.4337, 10.033899, 75.8377, -10.4337, 13.378601, 75.8267, -10.4337, 10.033899, 75.8377, -10.4337, 13.378601, 72.0261, -10.4337, 10.033899, 75.8267, -10.4337, 10.033899, 79.236, -10.4337, 6.6893, 79.236, -10.4337, 10.033899, 75.8267, -10.4337, 10.033899, 68.9432, -10.4337, 10.033899, 66.1166, -10.4337, 6.6893, 68.944695, -10.4337, 6.6893, 72.0261, -10.4337, 10.033899, 68.9432, -10.4337, 10.033899, 68.944695, -10.4337, 6.6893, 68.944695, -10.4337, 6.6893, 66.1166, -10.4337, 6.6893, 66.1166, -10.4337, 3.3446, 75.8267, -10.4337, 10.033899, 72.0261, -10.4337, 10.033899, 72.0437, -10.4337, 6.6893, 72.0261, -10.4337, 10.033899, 68.944695, -10.4337, 6.6893, 72.0437, -10.4337, 6.6893, 79.236, -10.4337, 6.6893, 75.8267, -10.4337, 10.033899, 75.8157, -10.4337, 6.6893, 75.8267, -10.4337, 10.033899, 72.0437, -10.4337, 6.6893, 75.8157, -10.4337, 6.6893, 79.236, -10.4337, 3.3446, 79.236, -10.4337, 6.6893, 75.8157, -10.4337, 6.6893, 68.944695, -10.4337, 6.6893, 66.1166, -10.4337, 3.3446, 68.9463, -10.4337, 3.3446, 72.0437, -10.4337, 6.6893, 68.944695, -10.4337, 6.6893, 68.9463, -10.4337, 3.3446, 68.9463, -10.4337, 3.3446, 66.1166, -10.4337, 3.3446, 66.1166, -10.4337, 0, 75.8157, -10.4337, 6.6893, 72.0437, -10.4337, 6.6893, 72.0614, -10.4337, 3.3446, 72.0437, -10.4337, 6.6893, 68.9463, -10.4337, 3.3446, 72.0614, -10.4337, 3.3446, 79.236, -10.4337, 3.3446, 75.8157, -10.4337, 6.6893, 75.804695, -10.4337, 3.3446, 75.8157, -10.4337, 6.6893, 72.0614, -10.4337, 3.3446, 75.804695, -10.4337, 3.3446, 79.236, -10.4337, 0, 79.236, -10.4337, 3.3446, 75.804695, -10.4337, 3.3446, 68.9463, -10.4337, 3.3446, 66.1166, -10.4337, 0, 68.9478, -10.4337, 0, 72.0614, -10.4337, 3.3446, 68.9463, -10.4337, 3.3446, 68.9478, -10.4337, 0, 68.9478, -10.4337, 0, 66.1166, -10.4337, 0, 66.1166, -10.4337, -3.3446002, 75.804695, -10.4337, 3.3446, 72.0614, -10.4337, 3.3446, 72.078995, -10.4337, 0, 72.0614, -10.4337, 3.3446, 68.9478, -10.4337, 0, 72.078995, -10.4337, 0, 79.236, -10.4337, 0, 75.804695, -10.4337, 3.3446, 75.793594, -10.4337, 0, 75.804695, -10.4337, 3.3446, 72.078995, -10.4337, 0, 75.793594, -10.4337, 0, 79.236, -10.4337, -3.3446002, 79.236, -10.4337, 0, 75.793594, -10.4337, 0, 68.9478, -10.4337, 0, 66.1166, -10.4337, -3.3446002, 68.9494, -10.4337, -3.3446002, 72.078995, -10.4337, 0, 68.9478, -10.4337, 0, 68.9494, -10.4337, -3.3446002, 68.9494, -10.4337, -3.3446002, 66.1166, -10.4337, -3.3446002, 66.1166, -10.4337, -6.6893, 75.793594, -10.4337, 0, 72.078995, -10.4337, 0, 72.096695, -10.4337, -3.3446002, 72.078995, -10.4337, 0, 68.9494, -10.4337, -3.3446002, 72.096695, -10.4337, -3.3446002, 79.236, -10.4337, -3.3446002, 75.793594, -10.4337, 0, 75.7826, -10.4337, -3.3446002, 75.793594, -10.4337, 0, 72.096695, -10.4337, -3.3446002, 75.7826, -10.4337, -3.3446002, 79.236, -10.4337, -6.6893, 79.236, -10.4337, -3.3446002, 75.7826, -10.4337, -3.3446002, 68.9494, -10.4337, -3.3446002, 66.1166, -10.4337, -6.6893, 68.9509, -10.4337, -6.6893, 72.096695, -10.4337, -3.3446002, 68.9494, -10.4337, -3.3446002, 68.9509, -10.4337, -6.6893, 68.9509, -10.4337, -6.6893, 66.1166, -10.4337, -6.6893, 66.1166, -10.4337, -10.033899, 75.7826, -10.4337, -3.3446002, 72.096695, -10.4337, -3.3446002, 72.114296, -10.4337, -6.6893, 72.096695, -10.4337, -3.3446002, 68.9509, -10.4337, -6.6893, 72.114296, -10.4337, -6.6893, 79.236, -10.4337, -6.6893, 75.7826, -10.4337, -3.3446002, 75.7716, -10.4337, -6.6893, 75.7826, -10.4337, -3.3446002, 72.114296, -10.4337, -6.6893, 75.7716, -10.4337, -6.6893, 79.236, -10.4337, -10.033899, 79.236, -10.4337, -6.6893, 75.7716, -10.4337, -6.6893, 68.9509, -10.4337, -6.6893, 66.1166, -10.4337, -10.033899, 68.9524, -10.4337, -10.033899, 72.114296, -10.4337, -6.6893, 68.9509, -10.4337, -6.6893, 68.9524, -10.4337, -10.033899, 68.9524, -10.4337, -10.033899, 66.1166, -10.4337, -10.033899, 66.1166, -10.4337, -13.378599, 75.7716, -10.4337, -6.6893, 72.114296, -10.4337, -6.6893, 72.1319, -10.4337, -10.033899, 72.114296, -10.4337, -6.6893, 68.9524, -10.4337, -10.033899, 72.1319, -10.4337, -10.033899, 79.236, -10.4337, -10.033899, 75.7716, -10.4337, -6.6893, 75.7606, -10.4337, -10.033899, 75.7716, -10.4337, -6.6893, 72.1319, -10.4337, -10.033899, 75.7606, -10.4337, -10.033899, 79.236, -10.4337, -13.378599, 79.236, -10.4337, -10.033899, 75.7606, -10.4337, -10.033899, 68.9524, -10.4337, -10.033899, 66.1166, -10.4337, -13.378599, 68.953995, -10.4337, -13.378599, 72.1319, -10.4337, -10.033899, 68.9524, -10.4337, -10.033899, 68.953995, -10.4337, -13.378599, 68.953995, -10.4337, -13.378599, 66.1166, -10.4337, -13.378599, 66.1166, -10.4337, -16.7232, 75.7606, -10.4337, -10.033899, 72.1319, -10.4337, -10.033899, 72.1496, -10.4337, -13.378599, 72.1319, -10.4337, -10.033899, 68.953995, -10.4337, -13.378599, 72.1496, -10.4337, -13.378599, 79.236, -10.4337, -13.378599, 75.7606, -10.4337, -10.033899, 75.749596, -10.4337, -13.378599, 75.7606, -10.4337, -10.033899, 72.1496, -10.4337, -13.378599, 75.749596, -10.4337, -13.378599, 79.236, -10.4337, -16.7232, 79.236, -10.4337, -13.378599, 75.749596, -10.4337, -13.378599, 68.953995, -10.4337, -13.378599, 66.1166, -10.4337, -16.7232, 68.9555, -10.4337, -16.7232, 72.1496, -10.4337, -13.378599, 68.953995, -10.4337, -13.378599, 68.9555, -10.4337, -16.7232, 68.9555, -10.4337, -16.7232, 66.1166, -10.4337, -16.7232, 66.1166, -10.4337, -20.0679, 75.749596, -10.4337, -13.378599, 72.1496, -10.4337, -13.378599, 72.1672, -10.4337, -16.7232, 72.1496, -10.4337, -13.378599, 68.9555, -10.4337, -16.7232, 72.1672, -10.4337, -16.7232, 79.236, -10.4337, -16.7232, 75.749596, -10.4337, -13.378599, 75.738495, -10.4337, -16.7232, 75.749596, -10.4337, -13.378599, 72.1672, -10.4337, -16.7232, 75.738495, -10.4337, -16.7232, 79.236, -10.4337, -20.0679, 79.236, -10.4337, -16.7232, 75.738495, -10.4337, -16.7232, 68.9555, -10.4337, -16.7232, 66.1166, -10.4337, -20.0679, 68.9571, -10.4337, -20.0679, 72.1672, -10.4337, -16.7232, 68.9555, -10.4337, -16.7232, 68.9571, -10.4337, -20.0679, 68.9571, -10.4337, -20.0679, 66.1166, -10.4337, -20.0679, 66.1166, -10.4337, -23.412498, 68.9571, -10.4337, -20.0679, 66.1166, -10.4337, -23.412498, 68.958595, -10.4337, -23.412498, 72.1672, -10.4337, -16.7232, 68.9571, -10.4337, -20.0679, 72.1849, -10.4337, -20.0679, 72.1849, -10.4337, -20.0679, 68.9571, -10.4337, -20.0679, 68.958595, -10.4337, -23.412498, 75.738495, -10.4337, -16.7232, 72.1672, -10.4337, -16.7232, 72.1849, -10.4337, -20.0679, 72.1849, -10.4337, -20.0679, 68.958595, -10.4337, -23.412498, 72.2025, -10.4337, -23.412498, 79.236, -10.4337, -20.0679, 75.738495, -10.4337, -16.7232, 75.7275, -10.4337, -20.0679, 75.738495, -10.4337, -16.7232, 72.1849, -10.4337, -20.0679, 75.7275, -10.4337, -20.0679, 75.7275, -10.4337, -20.0679, 72.1849, -10.4337, -20.0679, 72.2025, -10.4337, -23.412498, 79.236, -10.4337, -23.412498, 79.236, -10.4337, -20.0679, 75.7275, -10.4337, -20.0679, 75.7275, -10.4337, -20.0679, 72.2025, -10.4337, -23.412498, 75.7165, -10.4337, -23.412498, 79.236, -10.4337, -23.412498, 75.7275, -10.4337, -20.0679, 75.7165, -10.4337, -23.412498, -38.5239, -3.6573, 10.033899, -41.627, -4.8403997, 6.6893, -38.5239, -3.6573, 6.6893, -38.5239, -3.6573, 10.033899, -38.5239, -3.6573, 13.378601, -41.627, -4.8403997, 6.6893, -41.627, -4.8403997, 3.3446, -38.5239, -3.6573, 6.6893, -41.627, -4.8403997, 6.6893, -41.627, -4.8403997, 3.3446, -38.5239, -3.6573, 3.3446, -38.5239, -3.6573, 6.6893, -41.627, -4.8403997, 6.6893, -38.5239, -3.6573, 13.378601, -41.627, -4.8403997, 13.378601, -41.627, -4.8403997, 0, -38.5239, -3.6573, 3.3446, -41.627, -4.8403997, 3.3446, -41.627, -4.8403997, 0, -38.5239, -3.6573, 0, -38.5239, -3.6573, 3.3446, -44.537098, -5.95, 3.3446, -41.627, -4.8403997, 3.3446, -41.627, -4.8403997, 6.6893, -41.627, -4.8403997, -3.3446002, -38.5239, -3.6573, 0, -41.627, -4.8403997, 0, -41.627, -4.8403997, -3.3446002, -38.5239, -3.6573, -3.3446002, -38.5239, -3.6573, 0, -44.537098, -5.95, 0, -41.627, -4.8403997, 0, -41.627, -4.8403997, 3.3446, -44.537098, -5.95, 0, -41.627, -4.8403997, 3.3446, -44.537098, -5.95, 3.3446, -44.537098, -5.95, -3.3446002, -41.627, -4.8403997, -3.3446002, -41.627, -4.8403997, 0, -44.537098, -5.95, -3.3446002, -41.627, -4.8403997, 0, -44.537098, -5.95, 0, -41.627, -4.8403997, -6.6893, -38.5239, -3.6573, -3.3446002, -41.627, -4.8403997, -3.3446002, -41.627, -4.8403997, -6.6893, -38.5239, -3.6573, -6.6893, -38.5239, -3.6573, -3.3446002, -44.537098, -5.95, -6.6893, -41.627, -4.8403997, -3.3446002, -44.537098, -5.95, -3.3446002, -44.537098, -5.95, -6.6893, -41.627, -4.8403997, -6.6893, -41.627, -4.8403997, -3.3446002, -41.627, -4.8403997, -10.033899, -38.5239, -3.6573, -6.6893, -41.627, -4.8403997, -6.6893, -41.627, -4.8403997, -10.033899, -38.5239, -3.6573, -10.033899, -38.5239, -3.6573, -6.6893, -44.537098, -5.95, -10.033899, -41.627, -4.8403997, -6.6893, -44.537098, -5.95, -6.6893, -44.537098, -5.95, -10.033899, -41.627, -4.8403997, -10.033899, -41.627, -4.8403997, -6.6893, -41.627, -4.8403997, -13.378599, -38.5239, -3.6573, -10.033899, -41.627, -4.8403997, -10.033899, -41.627, -4.8403997, -13.378599, -38.5239, -3.6573, -13.378599, -38.5239, -3.6573, -10.033899, -41.627, -4.8403997, -13.378599, -38.5239, -3.6573, -16.7232, -38.5239, -3.6573, -13.378599, -41.627, -4.8403997, -13.378599, -38.5239, -3.6573, -17.0441, -38.5239, -3.6573, -16.7232, -38.5239, -3.6573, -17.0441, -41.627, -4.8403997, -13.378599, -41.627, -4.8403997, -17.0159, -41.627, -4.8403997, -17.0159, -38.5239, -3.6573, -20.0679, -38.5239, -3.6573, -17.0441, -44.537098, -5.95, -13.378599, -41.627, -4.8403997, -13.378599, -41.627, -4.8403997, -10.033899, -44.537098, -5.95, -13.378599, -41.627, -4.8403997, -10.033899, -44.537098, -5.95, -10.033899, -41.627, -4.8403997, -17.0159, -38.5239, -3.6573, -20.3888, -38.5239, -3.6573, -20.0679, -44.537098, -5.95, -16.9895, -41.627, -4.8403997, -13.378599, -44.537098, -5.95, -13.378599, -44.537098, -5.95, -16.9895, -41.627, -4.8403997, -17.0159, -41.627, -4.8403997, -13.378599, -47.4473, -7.0596, -13.378599, -44.537098, -5.95, -13.378599, -44.537098, -5.95, -10.033899, -47.4473, -7.0596, -16.963, -44.537098, -5.95, -16.9895, -44.537098, -5.95, -13.378599, -47.4473, -7.0596, -16.963, -44.537098, -5.95, -13.378599, -47.4473, -7.0596, -13.378599, -44.537098, -5.95, -20.334099, -41.627, -4.8403997, -17.0159, -44.537098, -5.95, -16.9895, -38.5239, -3.6573, -20.3888, -41.627, -4.8403997, -17.0159, -41.627, -4.8403997, -20.3606, -44.537098, -5.95, -20.334099, -41.627, -4.8403997, -20.3606, -41.627, -4.8403997, -17.0159, -41.627, -4.8403997, -23.412498, -38.5239, -3.6573, -20.3888, -41.627, -4.8403997, -20.3606, -41.627, -4.8403997, -23.412498, -38.5239, -3.6573, -23.412498, -38.5239, -3.6573, -20.3888, -41.627, -4.8403997, -26.7572, -38.5239, -3.6573, -23.412498, -41.627, -4.8403997, -23.412498, -41.627, -4.8403997, -26.7572, -38.5239, -3.6573, -26.7572, -38.5239, -3.6573, -23.412498, -44.537098, -5.95, -26.7572, -41.627, -4.8403997, -26.7572, -41.627, -4.8403997, -23.412498, -44.537098, -5.95, -23.412498, -41.627, -4.8403997, -23.412498, -41.627, -4.8403997, -20.3606, -44.537098, -5.95, -26.7572, -41.627, -4.8403997, -23.412498, -44.537098, -5.95, -23.412498, -44.537098, -5.95, -23.412498, -41.627, -4.8403997, -20.3606, -44.537098, -5.95, -20.334099, -47.4473, -7.0596, -26.7572, -44.537098, -5.95, -26.7572, -44.537098, -5.95, -23.412498, -47.4473, -7.0596, -23.412498, -44.537098, -5.95, -23.412498, -44.537098, -5.95, -20.334099, -47.4473, -7.0596, -26.7572, -44.537098, -5.95, -23.412498, -47.4473, -7.0596, -23.412498, -50.290497, -8.1437, -23.412498, -47.4473, -7.0596, -26.7572, -47.4473, -7.0596, -23.412498, -50.290497, -8.1437, -23.412498, -50.290497, -8.1437, -26.7572, -47.4473, -7.0596, -26.7572, -50.290497, -8.1437, -20.2818, -50.290497, -8.1437, -23.412498, -47.4473, -7.0596, -23.412498, -47.4473, -7.0596, -23.412498, -44.537098, -5.95, -20.334099, -47.4473, -7.0596, -20.3077, -50.290497, -8.1437, -20.2818, -47.4473, -7.0596, -23.412498, -47.4473, -7.0596, -20.3077, -47.4473, -7.0596, -20.3077, -44.537098, -5.95, -20.334099, -44.537098, -5.95, -16.9895, -47.4473, -7.0596, -20.3077, -44.537098, -5.95, -16.9895, -47.4473, -7.0596, -16.963, -47.4473, -7.0596, -16.963, -50.290497, -8.1437, -20.2818, -47.4473, -7.0596, -20.3077, -47.4473, -7.0596, -16.963, -50.290497, -8.1437, -20.0679, -50.290497, -8.1437, -20.2818, -50.290497, -8.1437, -20.0679, -47.4473, -7.0596, -16.963, -50.290497, -8.1437, -16.937199, -47.4473, -7.0596, -13.378599, -50.290497, -8.1437, -16.937199, -47.4473, -7.0596, -16.963, -47.4473, -7.0596, -13.378599, -50.290497, -8.1437, -16.7232, -50.290497, -8.1437, -16.937199, -50.290497, -8.1437, -16.7232, -47.4473, -7.0596, -13.378599, -50.290497, -8.1437, -13.378599, -50.290497, -8.1437, -10.033899, -50.290497, -8.1437, -13.378599, -47.4473, -7.0596, -13.378599, -50.290497, -8.1437, -10.033899, -47.4473, -7.0596, -13.378599, -47.4473, -7.0596, -10.033899, -47.4473, -7.0596, -13.378599, -44.537098, -5.95, -10.033899, -47.4473, -7.0596, -10.033899, -50.290497, -8.1437, -6.6893, -50.290497, -8.1437, -10.033899, -47.4473, -7.0596, -10.033899, -47.4473, -7.0596, -10.033899, -44.537098, -5.95, -10.033899, -44.537098, -5.95, -6.6893, -50.290497, -8.1437, -6.6893, -47.4473, -7.0596, -10.033899, -47.4473, -7.0596, -6.6893, -47.4473, -7.0596, -10.033899, -44.537098, -5.95, -6.6893, -47.4473, -7.0596, -6.6893, -50.290497, -8.1437, -3.3446002, -50.290497, -8.1437, -6.6893, -47.4473, -7.0596, -6.6893, -47.4473, -7.0596, -6.6893, -44.537098, -5.95, -6.6893, -44.537098, -5.95, -3.3446002, -50.290497, -8.1437, -3.3446002, -47.4473, -7.0596, -6.6893, -47.4473, -7.0596, -3.3446002, -47.4473, -7.0596, -6.6893, -44.537098, -5.95, -3.3446002, -47.4473, -7.0596, -3.3446002, -50.290497, -8.1437, 0, -50.290497, -8.1437, -3.3446002, -47.4473, -7.0596, -3.3446002, -47.4473, -7.0596, -3.3446002, -44.537098, -5.95, -3.3446002, -44.537098, -5.95, 0, -50.290497, -8.1437, 0, -47.4473, -7.0596, -3.3446002, -47.4473, -7.0596, 0, -47.4473, -7.0596, -3.3446002, -44.537098, -5.95, 0, -47.4473, -7.0596, 0, -50.290497, -8.1437, 3.3446, -50.290497, -8.1437, 0, -47.4473, -7.0596, 0, -47.4473, -7.0596, 0, -44.537098, -5.95, 0, -44.537098, -5.95, 3.3446, -50.290497, -8.1437, 3.3446, -47.4473, -7.0596, 0, -47.4473, -7.0596, 3.3446, -47.4473, -7.0596, 0, -44.537098, -5.95, 3.3446, -47.4473, -7.0596, 3.3446, -50.290497, -8.1437, 6.6893, -50.290497, -8.1437, 3.3446, -47.4473, -7.0596, 3.3446, -50.290497, -8.1437, 6.6893, -47.4473, -7.0596, 3.3446, -47.4473, -7.0596, 6.6893, -47.4473, -7.0596, 6.6893, -50.290497, -8.1437, 10.033899, -50.290497, -8.1437, 6.6893, -47.4473, -7.0596, 3.3446, -44.537098, -5.95, 3.3446, -44.537098, -5.95, 6.6893, -47.4473, -7.0596, 3.3446, -44.537098, -5.95, 6.6893, -47.4473, -7.0596, 6.6893, -44.537098, -5.95, 3.3446, -41.627, -4.8403997, 6.6893, -44.537098, -5.95, 6.6893, -47.4473, -7.0596, 6.6893, -50.290497, -8.1437, 13.378601, -50.290497, -8.1437, 10.033899, -41.627, -4.8403997, 13.378601, -44.537098, -5.95, 6.6893, -41.627, -4.8403997, 6.6893, -44.537098, -5.95, 13.378601, -47.4473, -7.0596, 6.6893, -44.537098, -5.95, 6.6893, -41.627, -4.8403997, 13.378601, -44.537098, -5.95, 13.378601, -44.537098, -5.95, 6.6893, -44.537098, -5.95, 13.378601, -47.4473, -7.0596, 13.378601, -47.4473, -7.0596, 6.6893, -50.290497, -8.1437, 13.378601, -47.4473, -7.0596, 6.6893, -47.4473, -7.0596, 13.378601, -41.627, -4.8403997, 16.7232, -44.537098, -5.95, 13.378601, -41.627, -4.8403997, 13.378601, -38.5239, -3.6573, 13.378601, -41.627, -4.8403997, 16.7232, -41.627, -4.8403997, 13.378601, -38.5239, -3.6573, 13.378601, -38.5239, -3.6573, 16.7232, -41.627, -4.8403997, 16.7232, -41.627, -4.8403997, 16.7232, -44.537098, -5.95, 16.7232, -44.537098, -5.95, 13.378601, -44.537098, -5.95, 16.7232, -47.4473, -7.0596, 13.378601, -44.537098, -5.95, 13.378601, -38.5239, -3.6573, 16.7232, -41.627, -4.8403997, 20.0679, -41.627, -4.8403997, 16.7232, -41.627, -4.8403997, 20.0679, -44.537098, -5.95, 16.7232, -41.627, -4.8403997, 16.7232, -38.5239, -3.6573, 16.7232, -38.5239, -3.6573, 20.0679, -41.627, -4.8403997, 20.0679, -47.4473, -7.0596, 16.7232, -50.290497, -8.1437, 13.378601, -47.4473, -7.0596, 13.378601, -44.537098, -5.95, 16.7232, -47.4473, -7.0596, 16.7232, -47.4473, -7.0596, 13.378601, -47.4473, -7.0596, 16.7232, -50.290497, -8.1437, 16.7232, -50.290497, -8.1437, 13.378601, -41.627, -4.8403997, 20.0679, -44.537098, -5.95, 20.0679, -44.537098, -5.95, 16.7232, -44.537098, -5.95, 20.0679, -47.4473, -7.0596, 16.7232, -44.537098, -5.95, 16.7232, -38.5239, -3.6573, 20.0679, -41.627, -4.8403997, 23.412498, -41.627, -4.8403997, 20.0679, -41.627, -4.8403997, 23.412498, -44.537098, -5.95, 20.0679, -41.627, -4.8403997, 20.0679, -38.5239, -3.6573, 20.0679, -38.5239, -3.6573, 23.412498, -41.627, -4.8403997, 23.412498, -38.5239, -3.6573, 23.412498, -41.627, -4.8403997, 26.7572, -41.627, -4.8403997, 23.412498, -38.5239, -3.6573, 23.412498, -38.5239, -3.6573, 26.7572, -41.627, -4.8403997, 26.7572, -41.627, -4.8403997, 26.7572, -44.537098, -5.95, 23.412498, -41.627, -4.8403997, 23.412498, -41.627, -4.8403997, 23.412498, -44.537098, -5.95, 23.412498, -44.537098, -5.95, 20.0679, -41.627, -4.8403997, 26.7572, -44.537098, -5.95, 26.7572, -44.537098, -5.95, 23.412498, -44.537098, -5.95, 20.0679, -47.4473, -7.0596, 20.0679, -47.4473, -7.0596, 16.7232, -47.4473, -7.0596, 20.0679, -50.290497, -8.1437, 16.7232, -47.4473, -7.0596, 16.7232, -44.537098, -5.95, 23.412498, -47.4473, -7.0596, 20.0679, -44.537098, -5.95, 20.0679, -47.4473, -7.0596, 20.0679, -50.290497, -8.1437, 20.0679, -50.290497, -8.1437, 16.7232, -44.537098, -5.95, 26.7572, -47.4473, -7.0596, 23.412498, -44.537098, -5.95, 23.412498, -44.537098, -5.95, 23.412498, -47.4473, -7.0596, 23.412498, -47.4473, -7.0596, 20.0679, -47.4473, -7.0596, 23.412498, -50.290497, -8.1437, 20.0679, -47.4473, -7.0596, 20.0679, -44.537098, -5.95, 26.7572, -47.4473, -7.0596, 26.7572, -47.4473, -7.0596, 23.412498, -47.4473, -7.0596, 23.412498, -50.290497, -8.1437, 23.412498, -50.290497, -8.1437, 20.0679, -47.4473, -7.0596, 26.7572, -50.290497, -8.1437, 23.412498, -47.4473, -7.0596, 23.412498, -47.4473, -7.0596, 26.7572, -50.290497, -8.1437, 26.7572, -50.290497, -8.1437, 23.412498, -6.6893, -8.1437, -54.500298, -3.4038, -8.6382, -57.495697, -3.4199996, -8.1437, -54.500298, -3.4038, -8.6382, -57.495697, -3.3446002, -8.1437, -54.500298, -3.4199996, -8.1437, -54.500298, -6.6893, -8.1437, -54.500298, -6.7919, -8.1437, -54.500298, -3.4038, -8.6382, -57.495697, -3.4038, -8.6382, -57.495697, -0.048199892, -8.1437, -54.500298, -3.3446002, -8.1437, -54.500298, -3.4038, -8.6382, -57.495697, -6.7919, -8.1437, -54.500298, -6.7697997, -8.6367, -57.4865, -10.033899, -8.1437, -54.500298, -6.7697997, -8.6367, -57.4865, -6.7919, -8.1437, -54.500298, -0.048199892, -8.1437, -54.500298, -3.4038, -8.6382, -57.495697, -0.037699938, -8.6397, -57.504997, -6.7697997, -8.6367, -57.4865, -10.033899, -8.1437, -54.500298, -10.141901, -8.1437, -54.500298, -0.037699938, -8.6397, -57.504997, 0, -8.1437, -54.500298, -0.048199892, -8.1437, -54.500298, -6.7697997, -8.6367, -57.4865, -10.141901, -8.1437, -54.500298, -10.1187, -8.635099, -57.4772, -13.378601, -8.1437, -54.500298, -10.1187, -8.635099, -57.4772, -10.141901, -8.1437, -54.500298, -0.037699938, -8.6397, -57.504997, 3.3446, -8.1437, -54.500298, 0, -8.1437, -54.500298, -10.1187, -8.635099, -57.4772, -13.378601, -8.1437, -54.500298, -13.4286, -8.1437, -54.500298, 3.3446, -8.1437, -54.500298, -0.037699938, -8.6397, -57.504997, 3.3446, -8.6413, -57.514297, -10.1187, -8.635099, -57.4772, -6.749, -9.1004, -60.295498, -6.7697997, -8.6367, -57.4865, 6.6893, -8.642799, -57.5235, 3.3446, -8.1437, -54.500298, 3.3446, -8.6413, -57.514297, 6.6893, -8.642799, -57.5235, 6.6893, -8.1437, -54.500298, 3.3446, -8.1437, -54.500298, 3.3446, -9.0949, -60.262497, 3.3446, -8.6413, -57.514297, -0.037699938, -8.6397, -57.504997, 10.033899, -8.6442995, -57.5327, 6.6893, -8.1437, -54.500298, 6.6893, -8.642799, -57.5235, 10.033899, -8.6442995, -57.5327, 10.033899, -8.1437, -54.500298, 6.6893, -8.1437, -54.500298, 10.033899, -8.6442995, -57.5327, 13.378599, -8.1437, -54.500298, 10.033899, -8.1437, -54.500298, 10.033899, -8.6442995, -57.5327, 13.444599, -8.1437, -54.500298, 13.378599, -8.1437, -54.500298, 13.444599, -8.1437, -54.500298, 10.033899, -8.6442995, -57.5327, 13.4300995, -8.6458, -57.5421, 13.4300995, -8.6458, -57.5421, 16.7232, -8.1437, -54.500298, 13.444599, -8.1437, -54.500298, 13.4300995, -8.6458, -57.5421, 16.8188, -8.1437, -54.500298, 16.7232, -8.1437, -54.500298, 16.8188, -8.1437, -54.500298, 13.4300995, -8.6458, -57.5421, 16.7978, -8.6474, -57.5514, 16.7978, -8.6474, -57.5514, 20.0679, -8.1437, -54.500298, 16.8188, -8.1437, -54.500298, 16.7978, -8.6474, -57.5514, 20.1975, -8.1437, -54.500298, 20.0679, -8.1437, -54.500298, 20.1975, -8.1437, -54.500298, 16.7978, -8.6474, -57.5514, 20.1689, -8.6489, -57.5607, 20.1442, -9.085899, -60.207497, 20.1689, -8.6489, -57.5607, 16.7978, -8.6474, -57.5514, 20.1442, -9.085899, -60.207497, 16.7978, -8.6474, -57.5514, 16.7794, -9.0877, -60.2185, 16.7794, -9.0877, -60.2185, 16.7978, -8.6474, -57.5514, 13.4300995, -8.6458, -57.5421, 20.112099, -9.6529, -63.642, 20.1442, -9.085899, -60.207497, 16.7794, -9.0877, -60.2185, 16.7794, -9.0877, -60.2185, 13.4300995, -8.6458, -57.5421, 13.417299, -9.089499, -60.2295, 13.417299, -9.089499, -60.2295, 13.4300995, -8.6458, -57.5421, 10.033899, -8.6442995, -57.5327, 20.112099, -9.6529, -63.642, 16.7794, -9.0877, -60.2185, 16.7557, -9.6571, -63.6679, 16.7557, -9.6571, -63.6679, 16.7794, -9.0877, -60.2185, 13.417299, -9.089499, -60.2295, 16.7232, -10.4337, -68.371895, 20.112099, -9.6529, -63.642, 16.7557, -9.6571, -63.6679, 16.7232, -10.4337, -68.371895, 20.0679, -10.4337, -68.371895, 20.112099, -9.6529, -63.642, 13.378599, -10.4337, -68.371895, 16.7232, -10.4337, -68.371895, 16.7557, -9.6571, -63.6679, 13.378599, -10.4337, -68.371895, 16.7557, -9.6571, -63.6679, 13.400799, -9.6614, -63.693794, 16.7557, -9.6571, -63.6679, 13.417299, -9.089499, -60.2295, 13.400799, -9.6614, -63.693794, 10.033899, -10.4337, -68.371895, 13.378599, -10.4337, -68.371895, 13.400799, -9.6614, -63.693794, 13.417299, -9.089499, -60.2295, 10.033899, -8.6442995, -57.5327, 10.033899, -9.0913, -60.240597, 13.400799, -9.6614, -63.693794, 13.417299, -9.089499, -60.2295, 10.033899, -9.0913, -60.240597, 10.033899, -9.0913, -60.240597, 10.033899, -8.6442995, -57.5327, 6.6893, -8.642799, -57.5235, 10.033899, -10.4337, -68.371895, 13.400799, -9.6614, -63.693794, 10.033899, -9.6657, -63.719696, 13.400799, -9.6614, -63.693794, 10.033899, -9.0913, -60.240597, 10.033899, -9.6657, -63.719696, 6.6893, -10.4337, -68.371895, 10.033899, -10.4337, -68.371895, 10.033899, -9.6657, -63.719696, 10.033899, -9.0913, -60.240597, 6.6893, -8.642799, -57.5235, 6.6893, -9.0931, -60.2515, 10.033899, -9.6657, -63.719696, 10.033899, -9.0913, -60.240597, 6.6893, -9.0931, -60.2515, 6.6893, -9.0931, -60.2515, 6.6893, -8.642799, -57.5235, 3.3446, -8.6413, -57.514297, 6.6893, -10.4337, -68.371895, 10.033899, -9.6657, -63.719696, 6.6893, -9.6699, -63.7455, 10.033899, -9.6657, -63.719696, 6.6893, -9.0931, -60.2515, 6.6893, -9.6699, -63.7455, 3.3446, -10.4337, -68.371895, 6.6893, -10.4337, -68.371895, 6.6893, -9.6699, -63.7455, 6.6893, -9.0931, -60.2515, 3.3446, -8.6413, -57.514297, 3.3446, -9.0949, -60.262497, 6.6893, -9.6699, -63.7455, 6.6893, -9.0931, -60.2515, 3.3446, -9.0949, -60.262497, 3.3446, -10.4337, -68.371895, 6.6893, -9.6699, -63.7455, 3.3446, -9.6742, -63.771194, 6.6893, -9.6699, -63.7455, 3.3446, -9.0949, -60.262497, 3.3446, -9.6742, -63.771194, 0, -10.4337, -68.371895, 3.3446, -10.4337, -68.371895, 3.3446, -9.6742, -63.771194, 3.3446, -9.6742, -63.771194, 3.3446, -9.0949, -60.262497, -0.028100014, -9.0968, -60.2735, 3.3446, -9.0949, -60.262497, -0.037699938, -8.6397, -57.504997, -0.028100014, -9.0968, -60.2735, -0.028100014, -9.0968, -60.2735, -0.037699938, -8.6397, -57.504997, -3.4038, -8.6382, -57.495697, 0, -10.4337, -68.371895, 3.3446, -9.6742, -63.771194, -0.015899897, -9.6785, -63.797096, 3.3446, -9.6742, -63.771194, -0.028100014, -9.0968, -60.2735, -0.015899897, -9.6785, -63.797096, -3.3446002, -10.4337, -68.371895, 0, -10.4337, -68.371895, -0.015899897, -9.6785, -63.797096, -0.028100014, -9.0968, -60.2735, -3.4038, -8.6382, -57.495697, -3.3885999, -9.098599, -60.2845, -0.015899897, -9.6785, -63.797096, -0.028100014, -9.0968, -60.2735, -3.3885999, -9.098599, -60.2845, -6.7697997, -8.6367, -57.4865, -3.3885999, -9.098599, -60.2845, -3.4038, -8.6382, -57.495697, -6.7697997, -8.6367, -57.4865, -6.749, -9.1004, -60.295498, -3.3885999, -9.098599, -60.2845, -0.015899897, -9.6785, -63.797096, -3.3885999, -9.098599, -60.2845, -3.3694, -9.6827, -63.8229, -3.3446002, -10.4337, -68.371895, -0.015899897, -9.6785, -63.797096, -3.3694, -9.6827, -63.8229, -6.749, -9.1004, -60.295498, -3.3694, -9.6827, -63.8229, -3.3885999, -9.098599, -60.2845, -6.7226996, -9.686999, -63.848793, -3.3446002, -10.4337, -68.371895, -3.3694, -9.6827, -63.8229, -6.749, -9.1004, -60.295498, -6.7226996, -9.686999, -63.848793, -3.3694, -9.6827, -63.8229, -6.7226996, -9.686999, -63.848793, -6.6893, -10.4337, -68.371895, -3.3446002, -10.4337, -68.371895, -10.096701, -9.1022, -60.3065, -6.7226996, -9.686999, -63.848793, -6.749, -9.1004, -60.295498, -10.068901, -9.691199, -63.874596, -6.6893, -10.4337, -68.371895, -6.7226996, -9.686999, -63.848793, -10.096701, -9.1022, -60.3065, -10.068901, -9.691199, -63.874596, -6.7226996, -9.686999, -63.848793, -10.068901, -9.691199, -63.874596, -10.033899, -10.4337, -68.371895, -6.6893, -10.4337, -68.371895, -10.1187, -8.635099, -57.4772, -10.096701, -9.1022, -60.3065, -6.749, -9.1004, -60.295498, -13.394701, -9.695499, -63.9002, -10.033899, -10.4337, -68.371895, -10.068901, -9.691199, -63.874596, -13.394701, -9.695499, -63.9002, -13.378601, -10.4337, -68.371895, -10.033899, -10.4337, -68.371895, -13.4076, -9.104, -60.3173, -10.068901, -9.691199, -63.874596, -10.096701, -9.1022, -60.3065, -13.4076, -9.104, -60.3173, -13.394701, -9.695499, -63.9002, -10.068901, -9.691199, -63.874596, -13.4179, -8.6336, -57.468098, -10.096701, -9.1022, -60.3065, -10.1187, -8.635099, -57.4772, -13.4179, -8.6336, -57.468098, -13.4076, -9.104, -60.3173, -10.096701, -9.1022, -60.3065, -10.1187, -8.635099, -57.4772, -13.4286, -8.1437, -54.500298, -13.4179, -8.6336, -57.468098, -13.4286, -8.1437, -54.500298, -16.7232, -8.6321, -57.459, -13.4179, -8.6336, -57.468098, -16.7232, -8.6321, -57.459, -13.4076, -9.104, -60.3173, -13.4179, -8.6336, -57.468098, -13.4286, -8.1437, -54.500298, -16.7232, -8.1437, -54.500298, -16.7232, -8.6321, -57.459, -16.7232, -9.1058, -60.3281, -13.394701, -9.695499, -63.9002, -13.4076, -9.104, -60.3173, -16.7232, -8.6321, -57.459, -16.7232, -9.1058, -60.3281, -13.4076, -9.104, -60.3173, -16.7232, -9.699699, -63.925797, -13.378601, -10.4337, -68.371895, -13.394701, -9.695499, -63.9002, -16.7232, -9.1058, -60.3281, -16.7232, -9.699699, -63.925797, -13.394701, -9.695499, -63.9002, -16.7232, -9.699699, -63.925797, -16.7232, -10.4337, -68.371895, -13.378601, -10.4337, -68.371895, -16.7232, -8.1437, -54.500298, -20.0679, -8.6306, -57.4498, -16.7232, -8.6321, -57.459, -20.0679, -8.6306, -57.4498, -16.7232, -9.1058, -60.3281, -16.7232, -8.6321, -57.459, -16.7232, -8.1437, -54.500298, -20.0679, -8.1437, -54.500298, -20.0679, -8.6306, -57.4498, -20.0679, -9.1076, -60.3391, -16.7232, -9.699699, -63.925797, -16.7232, -9.1058, -60.3281, -20.0679, -8.6306, -57.4498, -20.0679, -9.1076, -60.3391, -16.7232, -9.1058, -60.3281, -20.0679, -9.7039995, -63.9516, -16.7232, -10.4337, -68.371895, -16.7232, -9.699699, -63.925797, -20.0679, -9.1076, -60.3391, -20.0679, -9.7039995, -63.9516, -16.7232, -9.699699, -63.925797, -20.0679, -9.7039995, -63.9516, -20.0679, -10.4337, -68.371895, -16.7232, -10.4337, -68.371895, -20.0679, -8.1437, -54.500298, -23.412498, -8.6291, -57.440598, -20.0679, -8.6306, -57.4498, -23.412498, -8.6291, -57.440598, -20.0679, -9.1076, -60.3391, -20.0679, -8.6306, -57.4498, -20.0679, -8.1437, -54.500298, -23.412498, -8.1437, -54.500298, -23.412498, -8.6291, -57.440598, -23.412498, -8.1437, -54.500298, -26.7572, -8.6276, -57.4314, -23.412498, -8.6291, -57.440598, -23.412498, -8.1437, -54.500298, -26.7572, -8.1437, -54.500298, -26.7572, -8.6276, -57.4314, -23.412498, -8.6291, -57.440598, -23.412498, -9.1094, -60.35, -20.0679, -9.1076, -60.3391, -26.7572, -8.6276, -57.4314, -23.412498, -9.1094, -60.35, -23.412498, -8.6291, -57.440598, -23.412498, -9.1094, -60.35, -20.0679, -9.7039995, -63.9516, -20.0679, -9.1076, -60.3391, -26.7572, -8.6276, -57.4314, -26.7572, -9.111199, -60.361, -23.412498, -9.1094, -60.35, -23.412498, -9.7081995, -63.977394, -20.0679, -10.4337, -68.371895, -20.0679, -9.7039995, -63.9516, -23.412498, -9.1094, -60.35, -23.412498, -9.7081995, -63.977394, -20.0679, -9.7039995, -63.9516, -26.7572, -9.111199, -60.361, -23.412498, -9.7081995, -63.977394, -23.412498, -9.1094, -60.35, -23.412498, -9.7081995, -63.977394, -23.412498, -10.4337, -68.371895, -20.0679, -10.4337, -68.371895, -26.7572, -9.111199, -60.361, -26.7572, -9.7125, -64.0031, -23.412498, -9.7081995, -63.977394, -26.7572, -9.7125, -64.0031, -23.412498, -10.4337, -68.371895, -23.412498, -9.7081995, -63.977394, -26.7572, -9.7125, -64.0031, -26.7572, -10.4337, -68.371895, -23.412498, -10.4337, -68.371895, -3.3446002, -10.4337, -68.371895, -0.0020999908, -10.5633, -70.6314, 0, -10.4337, -68.371895, -3.3446002, -10.4337, -68.371895, -3.3514, -10.5471, -70.669, -0.0020999908, -10.5633, -70.6314, 23.412498, 0, -3.3446002, 26.7577, -0.6956, 0, 23.412498, 0, 0, 23.412498, 0, -3.3446002, 26.7577, -0.4894, -3.3446002, 26.7577, -0.6956, 0, 23.412498, 0, -6.6893, 26.7577, -0.4894, -3.3446002, 23.412498, 0, -3.3446002, 23.412498, 0, -6.6893, 26.7577, -0.3464, -6.6893, 26.7577, -0.4894, -3.3446002, 23.412498, -1.0924, -23.412498, 26.7582, -1.0924, -20.0679, 23.412498, -1.0924, -20.0679, 23.412498, -1.0924, -23.412498, 26.7582, -1.0924, -23.412498, 26.7582, -1.0924, -20.0679, 23.412498, 0, -10.033899, 26.7577, -0.3464, -6.6893, 23.412498, 0, -6.6893, 23.412498, 0, -10.033899, 26.572199, -0.4003, -10.314899, 26.7577, -0.3464, -6.6893, 23.412498, -0.35529998, 10.033899, 26.7582, -1.7623999, 10.033899, 23.412498, -1.8294, 13.378601, 23.412498, -0.456, -16.7232, 26.7577, -0.8804, -16.7232, 26.572199, -0.95549995, -13.659599, 23.412498, -1.1158999, -26.7572, 26.7582, -1.0924, -23.412498, 23.412498, -1.0924, -23.412498, 23.412498, -1.1158999, -26.7572, 26.7573, -1.3222, -26.7572, 26.7582, -1.0924, -23.412498, 23.412498, -2.2719998, 23.3902, 26.7582, -2.7837, 26.6955, 23.412498, -2.2194998, 26.781, 23.412498, -2.2719998, 23.3902, 26.7582, -2.2719998, 23.3902, 26.7582, -2.7837, 26.6955, 23.412498, 0, -13.378599, 26.572199, -0.4003, -10.314899, 23.412498, 0, -10.033899, 23.412498, 0, -13.378599, 26.572199, -0.95549995, -13.659599, 26.572199, -0.4003, -10.314899, 23.412498, 0, 0, 26.7577, -0.6956, 0, 26.7577, -0.73109996, 3.3446, 23.412498, -1.0924, -20.0679, 26.7582, -1.0924, -20.0679, 26.7577, -0.8804, -16.7232, 52.997097, -8.1437, -6.6893, 56.213997, -8.7052, -3.3446002, 52.997097, -8.1437, -3.3446002, 56.173397, -8.6981, 0, 52.997097, -8.1437, -3.3446002, 56.213997, -8.7052, -3.3446002, 56.173397, -8.6981, 0, 52.997097, -8.1437, 0, 52.997097, -8.1437, -3.3446002, 52.997097, -8.1437, -6.6893, 56.254498, -8.712299, -6.6893, 56.213997, -8.7052, -3.3446002, 56.254498, -8.712299, -6.6893, 52.997097, -8.1437, -6.6893, 52.997097, -8.1437, -10.033899, 59.1829, -9.2234, 0, 56.173397, -8.6981, 0, 56.213997, -8.7052, -3.3446002, 56.254498, -8.712299, -6.6893, 59.216, -9.229199, -3.3446002, 56.213997, -8.7052, -3.3446002, 59.1829, -9.2234, 0, 56.213997, -8.7052, -3.3446002, 59.216, -9.229199, -3.3446002, 56.1329, -8.691, 3.3446, 52.997097, -8.1437, 0, 56.173397, -8.6981, 0, 56.1329, -8.691, 3.3446, 52.997097, -8.1437, 3.3446, 52.997097, -8.1437, 0, 59.1498, -9.2176, 3.3446, 56.173397, -8.6981, 0, 59.1829, -9.2234, 0, 59.1498, -9.2176, 3.3446, 56.1329, -8.691, 3.3446, 56.173397, -8.6981, 0, 56.0924, -8.684, 6.6893, 52.997097, -8.1437, 3.3446, 56.1329, -8.691, 3.3446, 56.0924, -8.684, 6.6893, 52.997097, -8.1437, 6.6893, 52.997097, -8.1437, 3.3446, 59.1167, -9.2119, 6.6893, 56.1329, -8.691, 3.3446, 59.1498, -9.2176, 3.3446, 59.1167, -9.2119, 6.6893, 56.0924, -8.684, 6.6893, 56.1329, -8.691, 3.3446, 56.0518, -8.6769, 10.033899, 52.997097, -8.1437, 6.6893, 56.0924, -8.684, 6.6893, 56.0518, -8.6769, 10.033899, 52.997097, -8.1437, 10.033899, 52.997097, -8.1437, 6.6893, 59.0836, -9.2060995, 10.033899, 56.0924, -8.684, 6.6893, 59.1167, -9.2119, 6.6893, 59.0836, -9.2060995, 10.033899, 56.0518, -8.6769, 10.033899, 56.0924, -8.684, 6.6893, 56.0113, -8.6698, 13.378601, 52.997097, -8.1437, 10.033899, 56.0518, -8.6769, 10.033899, 56.0113, -8.6698, 13.378601, 52.997097, -8.1437, 13.378601, 52.997097, -8.1437, 10.033899, 59.0505, -9.2003, 13.378601, 56.0518, -8.6769, 10.033899, 59.0836, -9.2060995, 10.033899, 59.0505, -9.2003, 13.378601, 56.0113, -8.6698, 13.378601, 56.0518, -8.6769, 10.033899, 55.9708, -8.6627, 16.7232, 52.997097, -8.1437, 13.378601, 56.0113, -8.6698, 13.378601, 55.9708, -8.6627, 16.7232, 52.997097, -8.1437, 16.7232, 52.997097, -8.1437, 13.378601, 59.017498, -9.1945, 16.7232, 56.0113, -8.6698, 13.378601, 59.0505, -9.2003, 13.378601, 59.017498, -9.1945, 16.7232, 55.9708, -8.6627, 16.7232, 56.0113, -8.6698, 13.378601, 55.930298, -8.6557, 20.0679, 52.997097, -8.1437, 16.7232, 55.9708, -8.6627, 16.7232, 55.930298, -8.6557, 20.0679, 52.997097, -8.1437, 20.0679, 52.997097, -8.1437, 16.7232, 58.984398, -9.1888, 20.0679, 55.9708, -8.6627, 16.7232, 59.017498, -9.1945, 16.7232, 58.984398, -9.1888, 20.0679, 55.930298, -8.6557, 20.0679, 55.9708, -8.6627, 16.7232, 55.889698, -8.6486, 23.412498, 52.997097, -8.1437, 20.0679, 55.930298, -8.6557, 20.0679, 55.889698, -8.6486, 23.412498, 52.997097, -8.1437, 23.412498, 52.997097, -8.1437, 20.0679, 55.849197, -8.6414995, 26.7572, 52.997097, -8.1437, 23.412498, 55.889698, -8.6486, 23.412498, 55.849197, -8.6414995, 26.7572, 52.997097, -8.1437, 26.7572, 52.997097, -8.1437, 23.412498, 58.918198, -9.177199, 26.7572, 55.849197, -8.6414995, 26.7572, 55.889698, -8.6486, 23.412498, 58.951298, -9.183, 23.412498, 55.889698, -8.6486, 23.412498, 55.930298, -8.6557, 20.0679, 58.918198, -9.177199, 26.7572, 55.889698, -8.6486, 23.412498, 58.951298, -9.183, 23.412498, 58.951298, -9.183, 23.412498, 55.930298, -8.6557, 20.0679, 58.984398, -9.1888, 20.0679, 62.4636, -9.7961, 26.7572, 58.918198, -9.177199, 26.7572, 58.951298, -9.183, 23.412498, 62.4577, -9.795, 23.412498, 58.951298, -9.183, 23.412498, 58.984398, -9.1888, 20.0679, 62.4636, -9.7961, 26.7572, 58.951298, -9.183, 23.412498, 62.4577, -9.795, 23.412498, 66.1166, -10.4337, 23.412498, 62.4636, -9.7961, 26.7572, 62.4577, -9.795, 23.412498, 66.1166, -10.4337, 23.412498, 66.1166, -10.4337, 26.7572, 62.4636, -9.7961, 26.7572, 66.1166, -10.4337, 20.0679, 66.1166, -10.4337, 23.412498, 62.4577, -9.795, 23.412498, 62.4577, -9.795, 23.412498, 58.984398, -9.1888, 20.0679, 62.451897, -9.794, 20.0679, 66.1166, -10.4337, 20.0679, 62.4577, -9.795, 23.412498, 62.451897, -9.794, 20.0679, 62.451897, -9.794, 20.0679, 58.984398, -9.1888, 20.0679, 59.017498, -9.1945, 16.7232, 66.1166, -10.4337, 16.7232, 66.1166, -10.4337, 20.0679, 62.451897, -9.794, 20.0679, 62.451897, -9.794, 20.0679, 59.017498, -9.1945, 16.7232, 62.446, -9.793, 16.7232, 66.1166, -10.4337, 16.7232, 62.451897, -9.794, 20.0679, 62.446, -9.793, 16.7232, 62.446, -9.793, 16.7232, 59.017498, -9.1945, 16.7232, 59.0505, -9.2003, 13.378601, 66.1166, -10.4337, 13.378601, 66.1166, -10.4337, 16.7232, 62.446, -9.793, 16.7232, 62.446, -9.793, 16.7232, 59.0505, -9.2003, 13.378601, 62.440098, -9.792, 13.378601, 66.1166, -10.4337, 13.378601, 62.446, -9.793, 16.7232, 62.440098, -9.792, 13.378601, 62.440098, -9.792, 13.378601, 59.0505, -9.2003, 13.378601, 59.0836, -9.2060995, 10.033899, 66.1166, -10.4337, 10.033899, 66.1166, -10.4337, 13.378601, 62.440098, -9.792, 13.378601, 62.440098, -9.792, 13.378601, 59.0836, -9.2060995, 10.033899, 62.4343, -9.790899, 10.033899, 66.1166, -10.4337, 10.033899, 62.440098, -9.792, 13.378601, 62.4343, -9.790899, 10.033899, 62.4343, -9.790899, 10.033899, 59.0836, -9.2060995, 10.033899, 59.1167, -9.2119, 6.6893, 66.1166, -10.4337, 6.6893, 66.1166, -10.4337, 10.033899, 62.4343, -9.790899, 10.033899, 62.4343, -9.790899, 10.033899, 59.1167, -9.2119, 6.6893, 62.4284, -9.7899, 6.6893, 66.1166, -10.4337, 6.6893, 62.4343, -9.790899, 10.033899, 62.4284, -9.7899, 6.6893, 62.4284, -9.7899, 6.6893, 59.1167, -9.2119, 6.6893, 59.1498, -9.2176, 3.3446, 66.1166, -10.4337, 3.3446, 66.1166, -10.4337, 6.6893, 62.4284, -9.7899, 6.6893, 62.4284, -9.7899, 6.6893, 59.1498, -9.2176, 3.3446, 62.422497, -9.788899, 3.3446, 66.1166, -10.4337, 3.3446, 62.4284, -9.7899, 6.6893, 62.422497, -9.788899, 3.3446, 62.422497, -9.788899, 3.3446, 59.1498, -9.2176, 3.3446, 59.1829, -9.2234, 0, 66.1166, -10.4337, 0, 66.1166, -10.4337, 3.3446, 62.422497, -9.788899, 3.3446, 62.422497, -9.788899, 3.3446, 59.1829, -9.2234, 0, 62.4167, -9.7879, 0, 66.1166, -10.4337, 0, 62.422497, -9.788899, 3.3446, 62.4167, -9.7879, 0, 62.4167, -9.7879, 0, 59.1829, -9.2234, 0, 59.216, -9.229199, -3.3446002, 66.1166, -10.4337, -3.3446002, 66.1166, -10.4337, 0, 62.4167, -9.7879, 0, 62.4167, -9.7879, 0, 59.216, -9.229199, -3.3446002, 62.410797, -9.786799, -3.3446002, 66.1166, -10.4337, -3.3446002, 62.4167, -9.7879, 0, 62.410797, -9.786799, -3.3446002, 59.248997, -9.235, -6.6893, 62.410797, -9.786799, -3.3446002, 59.216, -9.229199, -3.3446002, 62.4049, -9.7858, -6.6893, 66.1166, -10.4337, -3.3446002, 62.410797, -9.786799, -3.3446002, 59.248997, -9.235, -6.6893, 62.4049, -9.7858, -6.6893, 62.410797, -9.786799, -3.3446002, 62.4049, -9.7858, -6.6893, 66.1166, -10.4337, -6.6893, 66.1166, -10.4337, -3.3446002, 56.254498, -8.712299, -6.6893, 59.248997, -9.235, -6.6893, 59.216, -9.229199, -3.3446002, 66.1166, -10.4337, -6.6893, 62.4049, -9.7858, -6.6893, 62.399, -9.7848, -10.033899, 66.1166, -10.4337, -6.6893, 62.399, -9.7848, -10.033899, 66.1166, -10.4337, -10.033899, 62.4049, -9.7858, -6.6893, 59.248997, -9.235, -6.6893, 59.282097, -9.2407, -10.033899, 62.4049, -9.7858, -6.6893, 59.282097, -9.2407, -10.033899, 62.399, -9.7848, -10.033899, 59.248997, -9.235, -6.6893, 56.254498, -8.712299, -6.6893, 56.295, -8.719299, -10.033899, 59.248997, -9.235, -6.6893, 56.295, -8.719299, -10.033899, 59.282097, -9.2407, -10.033899, 56.254498, -8.712299, -6.6893, 52.997097, -8.1437, -10.033899, 56.295, -8.719299, -10.033899, 52.997097, -8.1437, -10.033899, 56.3355, -8.726399, -13.378599, 56.295, -8.719299, -10.033899, 56.3355, -8.726399, -13.378599, 59.282097, -9.2407, -10.033899, 56.295, -8.719299, -10.033899, 52.997097, -8.1437, -10.033899, 52.997097, -8.1437, -13.378599, 56.3355, -8.726399, -13.378599, 59.315197, -9.2465, -13.378599, 62.399, -9.7848, -10.033899, 59.282097, -9.2407, -10.033899, 56.3355, -8.726399, -13.378599, 59.315197, -9.2465, -13.378599, 59.282097, -9.2407, -10.033899, 52.997097, -8.1437, -13.378599, 56.3761, -8.7335, -16.7232, 56.3355, -8.726399, -13.378599, 56.3761, -8.7335, -16.7232, 59.315197, -9.2465, -13.378599, 56.3355, -8.726399, -13.378599, 52.997097, -8.1437, -13.378599, 52.997097, -8.1437, -16.7232, 56.3761, -8.7335, -16.7232, 59.315197, -9.2465, -13.378599, 62.3932, -9.7838, -13.378599, 62.399, -9.7848, -10.033899, 62.3932, -9.7838, -13.378599, 66.1166, -10.4337, -10.033899, 62.399, -9.7848, -10.033899, 62.3932, -9.7838, -13.378599, 66.1166, -10.4337, -13.378599, 66.1166, -10.4337, -10.033899, 59.348297, -9.252299, -16.7232, 62.3932, -9.7838, -13.378599, 59.315197, -9.2465, -13.378599, 56.3761, -8.7335, -16.7232, 59.348297, -9.252299, -16.7232, 59.315197, -9.2465, -13.378599, 62.3873, -9.7827, -16.7232, 66.1166, -10.4337, -13.378599, 62.3932, -9.7838, -13.378599, 59.348297, -9.252299, -16.7232, 62.3873, -9.7827, -16.7232, 62.3932, -9.7838, -13.378599, 62.3873, -9.7827, -16.7232, 66.1166, -10.4337, -16.7232, 66.1166, -10.4337, -13.378599, 56.4166, -8.7406, -20.0679, 59.348297, -9.252299, -16.7232, 56.3761, -8.7335, -16.7232, 52.997097, -8.1437, -16.7232, 56.4166, -8.7406, -20.0679, 56.3761, -8.7335, -16.7232, 52.997097, -8.1437, -16.7232, 52.997097, -8.1437, -20.0679, 56.4166, -8.7406, -20.0679, 52.997097, -8.1437, -20.0679, 56.4571, -8.7476, -23.412498, 56.4166, -8.7406, -20.0679, 52.997097, -8.1437, -20.0679, 52.997097, -8.1437, -23.412498, 56.4571, -8.7476, -23.412498, 56.4166, -8.7406, -20.0679, 59.381397, -9.2581, -20.0679, 59.348297, -9.252299, -16.7232, 56.4571, -8.7476, -23.412498, 59.381397, -9.2581, -20.0679, 56.4166, -8.7406, -20.0679, 59.381397, -9.2581, -20.0679, 62.3873, -9.7827, -16.7232, 59.348297, -9.252299, -16.7232, 56.4571, -8.7476, -23.412498, 59.414497, -9.2638, -23.412498, 59.381397, -9.2581, -20.0679, 62.381397, -9.7817, -20.0679, 66.1166, -10.4337, -16.7232, 62.3873, -9.7827, -16.7232, 59.381397, -9.2581, -20.0679, 62.381397, -9.7817, -20.0679, 62.3873, -9.7827, -16.7232, 59.414497, -9.2638, -23.412498, 62.381397, -9.7817, -20.0679, 59.381397, -9.2581, -20.0679, 62.381397, -9.7817, -20.0679, 66.1166, -10.4337, -20.0679, 66.1166, -10.4337, -16.7232, 59.414497, -9.2638, -23.412498, 62.3756, -9.7807, -23.412498, 62.381397, -9.7817, -20.0679, 62.3756, -9.7807, -23.412498, 66.1166, -10.4337, -20.0679, 62.381397, -9.7817, -20.0679, 62.3756, -9.7807, -23.412498, 66.1166, -10.4337, -23.412498, 66.1166, -10.4337, -20.0679, 66.1166, -10.4572, -26.7572, 68.958595, -10.4337, -23.412498, 66.1166, -10.4337, -23.412498, 66.1166, -10.4572, -26.7572, 68.9481, -10.4572, -26.7572, 68.958595, -10.4337, -23.412498, 68.9481, -10.4572, -26.7572, 72.2025, -10.4337, -23.412498, 68.958595, -10.4337, -23.412498, 68.9481, -10.4572, -26.7572, 72.2092, -10.4572, -26.7572, 72.2025, -10.4337, -23.412498, 72.2092, -10.4572, -26.7572, 75.7165, -10.4337, -23.412498, 72.2025, -10.4337, -23.412498, 72.2092, -10.4572, -26.7572, 75.6957, -10.4572, -26.7572, 75.7165, -10.4337, -23.412498, 75.6957, -10.4572, -26.7572, 79.236, -10.4337, -23.412498, 75.7165, -10.4337, -23.412498, 75.6957, -10.4572, -26.7572, 79.236, -10.4572, -26.7572, 79.236, -10.4337, -23.412498, 39.877598, -3.6573, -13.378599, 42.9754, -4.7166, -10.033899, 39.877598, -3.6573, -10.033899, 39.877598, -3.6573, -13.378599, 42.951797, -4.7086, -13.378599, 42.9754, -4.7166, -10.033899, 42.951797, -4.7086, -13.378599, 46.3474, -5.8697, -10.033899, 42.9754, -4.7166, -10.033899, 42.951797, -4.7086, -13.378599, 39.877598, -3.6573, -13.378599, 39.877598, -3.6573, -16.7232, 42.951797, -4.7086, -13.378599, 46.326397, -5.8626, -13.378599, 46.3474, -5.8697, -10.033899, 46.326397, -5.8626, -13.378599, 49.4666, -6.9364, -10.033899, 46.3474, -5.8697, -10.033899, 42.951797, -4.7086, -13.378599, 39.877598, -3.6573, -16.7232, 42.9282, -4.7005, -16.7232, 46.326397, -5.8626, -13.378599, 42.951797, -4.7086, -13.378599, 42.9282, -4.7005, -16.7232, 39.877598, -3.6573, -16.7232, 42.9046, -4.6924, -20.0679, 42.9282, -4.7005, -16.7232, 39.877598, -3.6573, -16.7232, 39.877598, -3.6573, -20.0679, 42.9046, -4.6924, -20.0679, 46.326397, -5.8626, -13.378599, 42.9282, -4.7005, -16.7232, 46.3055, -5.8554, -16.7232, 42.9046, -4.6924, -20.0679, 46.3055, -5.8554, -16.7232, 42.9282, -4.7005, -16.7232, 46.326397, -5.8626, -13.378599, 49.4568, -6.9329996, -13.378599, 49.4666, -6.9364, -10.033899, 49.4568, -6.9329996, -13.378599, 46.326397, -5.8626, -13.378599, 46.3055, -5.8554, -16.7232, 49.4568, -6.9329996, -13.378599, 52.997097, -8.1437, -10.033899, 49.4666, -6.9364, -10.033899, 49.4568, -6.9329996, -13.378599, 52.997097, -8.1437, -13.378599, 52.997097, -8.1437, -10.033899, 39.877598, -3.6573, -20.0679, 42.881, -4.6844, -23.412498, 42.9046, -4.6924, -20.0679, 39.877598, -3.6573, -20.0679, 39.877598, -3.6573, -23.412498, 42.881, -4.6844, -23.412498, 42.881, -4.6844, -23.412498, 46.2845, -5.8482, -20.0679, 42.9046, -4.6924, -20.0679, 42.9046, -4.6924, -20.0679, 46.2845, -5.8482, -20.0679, 46.3055, -5.8554, -16.7232, 42.881, -4.6844, -23.412498, 46.2636, -5.8410997, -23.412498, 46.2845, -5.8482, -20.0679, 52.997097, -8.1437, -13.378599, 49.4568, -6.9329996, -13.378599, 49.447, -6.9297, -16.7232, 49.4568, -6.9329996, -13.378599, 46.3055, -5.8554, -16.7232, 49.447, -6.9297, -16.7232, 46.2845, -5.8482, -20.0679, 49.447, -6.9297, -16.7232, 46.3055, -5.8554, -16.7232, 52.997097, -8.1437, -13.378599, 49.447, -6.9297, -16.7232, 52.997097, -8.1437, -16.7232, 46.2636, -5.8410997, -23.412498, 49.4372, -6.9263997, -20.0679, 46.2845, -5.8482, -20.0679, 46.2845, -5.8482, -20.0679, 49.4372, -6.9263997, -20.0679, 49.447, -6.9297, -16.7232, 49.4372, -6.9263997, -20.0679, 52.997097, -8.1437, -16.7232, 49.447, -6.9297, -16.7232, 46.2636, -5.8410997, -23.412498, 49.427498, -6.923, -23.412498, 49.4372, -6.9263997, -20.0679, 49.4372, -6.9263997, -20.0679, 52.997097, -8.1437, -20.0679, 52.997097, -8.1437, -16.7232, 49.427498, -6.923, -23.412498, 52.997097, -8.1437, -20.0679, 49.4372, -6.9263997, -20.0679, 49.427498, -6.923, -23.412498, 52.997097, -8.1437, -23.412498, 52.997097, -8.1437, -20.0679, 20.0679, -8.1437, 52.156597, 16.7232, -8.658199, 55.0947, 16.7232, -8.1437, 52.156597, 13.378599, -8.658199, 55.0947, 16.7232, -8.1437, 52.156597, 16.7232, -8.658199, 55.0947, 13.378599, -8.658199, 55.0947, 13.378599, -8.1437, 52.156597, 16.7232, -8.1437, 52.156597, 20.0679, -8.1437, 52.156597, 20.0679, -8.658199, 55.0947, 16.7232, -8.658199, 55.0947, 20.0679, -8.658199, 55.0947, 20.0679, -8.1437, 52.156597, 23.412498, -8.1437, 52.156597, 13.378599, -9.1788, 58.067497, 13.378599, -8.658199, 55.0947, 16.7232, -8.658199, 55.0947, 20.0679, -8.658199, 55.0947, 16.7232, -9.1788, 58.067497, 16.7232, -8.658199, 55.0947, 13.378599, -9.1788, 58.067497, 16.7232, -8.658199, 55.0947, 16.7232, -9.1788, 58.067497, 10.033899, -8.658199, 55.0947, 13.378599, -8.1437, 52.156597, 13.378599, -8.658199, 55.0947, 10.033899, -8.658199, 55.0947, 10.033899, -8.1437, 52.156597, 13.378599, -8.1437, 52.156597, 10.033899, -9.1788, 58.067497, 13.378599, -8.658199, 55.0947, 13.378599, -9.1788, 58.067497, 10.033899, -9.1788, 58.067497, 10.033899, -8.658199, 55.0947, 13.378599, -8.658199, 55.0947, 6.6893, -8.658199, 55.0947, 10.033899, -8.1437, 52.156597, 10.033899, -8.658199, 55.0947, 6.6893, -8.658199, 55.0947, 6.6893, -8.1437, 52.156597, 10.033899, -8.1437, 52.156597, 6.6893, -9.1788, 58.067497, 10.033899, -8.658199, 55.0947, 10.033899, -9.1788, 58.067497, 6.6893, -9.1788, 58.067497, 6.6893, -8.658199, 55.0947, 10.033899, -8.658199, 55.0947, 3.3446, -8.658199, 55.0947, 6.6893, -8.1437, 52.156597, 6.6893, -8.658199, 55.0947, 3.3446, -8.658199, 55.0947, 3.3446, -8.1437, 52.156597, 6.6893, -8.1437, 52.156597, 3.3446, -9.1788, 58.067497, 6.6893, -8.658199, 55.0947, 6.6893, -9.1788, 58.067497, 3.3446, -9.1788, 58.067497, 3.3446, -8.658199, 55.0947, 6.6893, -8.658199, 55.0947, 0, -8.658199, 55.0947, 3.3446, -8.1437, 52.156597, 3.3446, -8.658199, 55.0947, 0, -8.658199, 55.0947, 0, -8.1437, 52.156597, 3.3446, -8.1437, 52.156597, 0, -9.1788, 58.067497, 3.3446, -8.658199, 55.0947, 3.3446, -9.1788, 58.067497, 0, -9.1788, 58.067497, 0, -8.658199, 55.0947, 3.3446, -8.658199, 55.0947, -3.3446002, -8.658199, 55.0947, 0, -8.1437, 52.156597, 0, -8.658199, 55.0947, -3.3446002, -8.658199, 55.0947, -3.3446002, -8.1437, 52.156597, 0, -8.1437, 52.156597, -3.3446002, -9.1788, 58.067497, 0, -8.658199, 55.0947, 0, -9.1788, 58.067497, -3.3446002, -9.1788, 58.067497, -3.3446002, -8.658199, 55.0947, 0, -8.658199, 55.0947, -6.6893, -8.658199, 55.0947, -3.3446002, -8.1437, 52.156597, -3.3446002, -8.658199, 55.0947, -6.6893, -8.658199, 55.0947, -6.6893, -8.1437, 52.156597, -3.3446002, -8.1437, 52.156597, -6.6893, -9.1788, 58.067497, -3.3446002, -8.658199, 55.0947, -3.3446002, -9.1788, 58.067497, -6.6893, -9.1788, 58.067497, -6.6893, -8.658199, 55.0947, -3.3446002, -8.658199, 55.0947, -10.033899, -8.658199, 55.0947, -6.6893, -8.1437, 52.156597, -6.6893, -8.658199, 55.0947, -10.033899, -8.658199, 55.0947, -10.033899, -8.1437, 52.156597, -6.6893, -8.1437, 52.156597, -10.033899, -9.1788, 58.067497, -6.6893, -8.658199, 55.0947, -6.6893, -9.1788, 58.067497, -10.033899, -9.1788, 58.067497, -10.033899, -8.658199, 55.0947, -6.6893, -8.658199, 55.0947, -13.378601, -8.658199, 55.0947, -10.033899, -8.1437, 52.156597, -10.033899, -8.658199, 55.0947, -13.378601, -8.658199, 55.0947, -13.378601, -8.1437, 52.156597, -10.033899, -8.1437, 52.156597, -13.378601, -9.1788, 58.067497, -10.033899, -8.658199, 55.0947, -10.033899, -9.1788, 58.067497, -13.378601, -9.1788, 58.067497, -13.378601, -8.658199, 55.0947, -10.033899, -8.658199, 55.0947, -16.7232, -8.658199, 55.0947, -13.378601, -8.1437, 52.156597, -13.378601, -8.658199, 55.0947, -16.7232, -8.658199, 55.0947, -16.7232, -8.1437, 52.156597, -13.378601, -8.1437, 52.156597, -16.7232, -9.1788, 58.067497, -13.378601, -8.658199, 55.0947, -13.378601, -9.1788, 58.067497, -16.7232, -9.1788, 58.067497, -16.7232, -8.658199, 55.0947, -13.378601, -8.658199, 55.0947, -20.0679, -8.658199, 55.0947, -16.7232, -8.1437, 52.156597, -16.7232, -8.658199, 55.0947, -20.0679, -8.658199, 55.0947, -20.0679, -8.1437, 52.156597, -16.7232, -8.1437, 52.156597, -20.0679, -9.1788, 58.067497, -16.7232, -8.658199, 55.0947, -16.7232, -9.1788, 58.067497, -20.0679, -9.1788, 58.067497, -20.0679, -8.658199, 55.0947, -16.7232, -8.658199, 55.0947, -23.412498, -8.658199, 55.0947, -20.0679, -8.1437, 52.156597, -20.0679, -8.658199, 55.0947, -23.412498, -8.658199, 55.0947, -23.412498, -8.1437, 52.156597, -20.0679, -8.1437, 52.156597, -26.7572, -8.658199, 55.0947, -23.412498, -8.1437, 52.156597, -23.412498, -8.658199, 55.0947, -26.7572, -8.658199, 55.0947, -26.7572, -8.1437, 52.156597, -23.412498, -8.1437, 52.156597, -26.7572, -9.1788, 58.067497, -26.7572, -8.658199, 55.0947, -23.412498, -8.658199, 55.0947, -23.412498, -9.1788, 58.067497, -23.412498, -8.658199, 55.0947, -20.0679, -8.658199, 55.0947, -26.7572, -9.1788, 58.067497, -23.412498, -8.658199, 55.0947, -23.412498, -9.1788, 58.067497, -23.412498, -9.1788, 58.067497, -20.0679, -8.658199, 55.0947, -20.0679, -9.1788, 58.067497, -26.7572, -9.8364, 61.822598, -26.7572, -9.1788, 58.067497, -23.412498, -9.1788, 58.067497, -23.412498, -9.8364, 61.822598, -23.412498, -9.1788, 58.067497, -20.0679, -9.1788, 58.067497, -26.7572, -9.8364, 61.822598, -23.412498, -9.1788, 58.067497, -23.412498, -9.8364, 61.822598, -23.412498, -10.4337, 65.2334, -26.7572, -9.8364, 61.822598, -23.412498, -9.8364, 61.822598, -23.412498, -10.4337, 65.2334, -26.7572, -10.4337, 65.2334, -26.7572, -9.8364, 61.822598, -20.0679, -10.4337, 65.2334, -23.412498, -10.4337, 65.2334, -23.412498, -9.8364, 61.822598, -23.412498, -9.8364, 61.822598, -20.0679, -9.1788, 58.067497, -20.0679, -9.8364, 61.822598, -20.0679, -10.4337, 65.2334, -23.412498, -9.8364, 61.822598, -20.0679, -9.8364, 61.822598, -20.0679, -9.8364, 61.822598, -20.0679, -9.1788, 58.067497, -16.7232, -9.1788, 58.067497, -16.7232, -10.4337, 65.2334, -20.0679, -10.4337, 65.2334, -20.0679, -9.8364, 61.822598, -20.0679, -9.8364, 61.822598, -16.7232, -9.1788, 58.067497, -16.7232, -9.8364, 61.822598, -16.7232, -10.4337, 65.2334, -20.0679, -9.8364, 61.822598, -16.7232, -9.8364, 61.822598, -16.7232, -9.8364, 61.822598, -16.7232, -9.1788, 58.067497, -13.378601, -9.1788, 58.067497, -13.378601, -10.4337, 65.2334, -16.7232, -10.4337, 65.2334, -16.7232, -9.8364, 61.822598, -16.7232, -9.8364, 61.822598, -13.378601, -9.1788, 58.067497, -13.378601, -9.8364, 61.822598, -13.378601, -10.4337, 65.2334, -16.7232, -9.8364, 61.822598, -13.378601, -9.8364, 61.822598, -13.378601, -9.8364, 61.822598, -13.378601, -9.1788, 58.067497, -10.033899, -9.1788, 58.067497, -10.033899, -10.4337, 65.2334, -13.378601, -10.4337, 65.2334, -13.378601, -9.8364, 61.822598, -13.378601, -9.8364, 61.822598, -10.033899, -9.1788, 58.067497, -10.033899, -9.8364, 61.822598, -10.033899, -10.4337, 65.2334, -13.378601, -9.8364, 61.822598, -10.033899, -9.8364, 61.822598, -10.033899, -9.8364, 61.822598, -10.033899, -9.1788, 58.067497, -6.6893, -9.1788, 58.067497, -6.6893, -10.4337, 65.2334, -10.033899, -10.4337, 65.2334, -10.033899, -9.8364, 61.822598, -10.033899, -9.8364, 61.822598, -6.6893, -9.1788, 58.067497, -6.6893, -9.8364, 61.822598, -6.6893, -10.4337, 65.2334, -10.033899, -9.8364, 61.822598, -6.6893, -9.8364, 61.822598, -6.6893, -9.8364, 61.822598, -6.6893, -9.1788, 58.067497, -3.3446002, -9.1788, 58.067497, -3.3446002, -10.4337, 65.2334, -6.6893, -10.4337, 65.2334, -6.6893, -9.8364, 61.822598, -6.6893, -9.8364, 61.822598, -3.3446002, -9.1788, 58.067497, -3.3446002, -9.8364, 61.822598, -3.3446002, -10.4337, 65.2334, -6.6893, -9.8364, 61.822598, -3.3446002, -9.8364, 61.822598, -3.3446002, -9.8364, 61.822598, -3.3446002, -9.1788, 58.067497, 0, -9.1788, 58.067497, 0, -10.4337, 65.2334, -3.3446002, -10.4337, 65.2334, -3.3446002, -9.8364, 61.822598, -3.3446002, -9.8364, 61.822598, 0, -9.1788, 58.067497, 0, -9.8364, 61.822598, 0, -10.4337, 65.2334, -3.3446002, -9.8364, 61.822598, 0, -9.8364, 61.822598, 0, -9.8364, 61.822598, 0, -9.1788, 58.067497, 3.3446, -9.1788, 58.067497, 3.3446, -10.4337, 65.2334, 0, -10.4337, 65.2334, 0, -9.8364, 61.822598, 0, -9.8364, 61.822598, 3.3446, -9.1788, 58.067497, 3.3446, -9.8364, 61.822598, 3.3446, -10.4337, 65.2334, 0, -9.8364, 61.822598, 3.3446, -9.8364, 61.822598, 3.3446, -9.8364, 61.822598, 3.3446, -9.1788, 58.067497, 6.6893, -9.1788, 58.067497, 6.6893, -10.4337, 65.2334, 3.3446, -10.4337, 65.2334, 3.3446, -9.8364, 61.822598, 3.3446, -9.8364, 61.822598, 6.6893, -9.1788, 58.067497, 6.6893, -9.8364, 61.822598, 6.6893, -10.4337, 65.2334, 3.3446, -9.8364, 61.822598, 6.6893, -9.8364, 61.822598, 6.6893, -9.8364, 61.822598, 6.6893, -9.1788, 58.067497, 10.033899, -9.1788, 58.067497, 10.033899, -10.4337, 65.2334, 6.6893, -10.4337, 65.2334, 6.6893, -9.8364, 61.822598, 6.6893, -9.8364, 61.822598, 10.033899, -9.1788, 58.067497, 10.033899, -9.8364, 61.822598, 10.033899, -10.4337, 65.2334, 6.6893, -9.8364, 61.822598, 10.033899, -9.8364, 61.822598, 10.033899, -9.8364, 61.822598, 10.033899, -9.1788, 58.067497, 13.378599, -9.1788, 58.067497, 13.378599, -10.4337, 65.2334, 10.033899, -10.4337, 65.2334, 10.033899, -9.8364, 61.822598, 10.033899, -9.8364, 61.822598, 13.378599, -9.1788, 58.067497, 13.378599, -9.8364, 61.822598, 13.378599, -10.4337, 65.2334, 10.033899, -9.8364, 61.822598, 13.378599, -9.8364, 61.822598, 13.378599, -9.8364, 61.822598, 13.378599, -9.1788, 58.067497, 16.7232, -9.1788, 58.067497, 16.7232, -10.4337, 65.2334, 13.378599, -10.4337, 65.2334, 13.378599, -9.8364, 61.822598, 13.378599, -9.8364, 61.822598, 16.7232, -9.1788, 58.067497, 16.7232, -9.8364, 61.822598, 16.7232, -10.4337, 65.2334, 13.378599, -9.8364, 61.822598, 16.7232, -9.8364, 61.822598, 20.0679, -9.1788, 58.067497, 16.7232, -9.8364, 61.822598, 16.7232, -9.1788, 58.067497, 20.0679, -9.8364, 61.822598, 16.7232, -10.4337, 65.2334, 16.7232, -9.8364, 61.822598, 20.0679, -9.1788, 58.067497, 20.0679, -9.8364, 61.822598, 16.7232, -9.8364, 61.822598, 20.0679, -9.8364, 61.822598, 20.0679, -10.4337, 65.2334, 16.7232, -10.4337, 65.2334, 20.0679, -8.658199, 55.0947, 20.0679, -9.1788, 58.067497, 16.7232, -9.1788, 58.067497, 20.0679, -10.4337, 65.2334, 20.0679, -9.8364, 61.822598, 23.412498, -9.8364, 61.822598, 20.0679, -10.4337, 65.2334, 23.412498, -9.8364, 61.822598, 23.412498, -10.4337, 65.2334, 26.7582, -9.8364, 61.822598, 23.412498, -10.4337, 65.2334, 23.412498, -9.8364, 61.822598, 26.7582, -9.8364, 61.822598, 26.7582, -10.4337, 65.2334, 23.412498, -10.4337, 65.2334, 26.7582, -9.1788, 58.067497, 26.7582, -9.8364, 61.822598, 23.412498, -9.8364, 61.822598, 20.0679, -9.8364, 61.822598, 23.412498, -9.1788, 58.067497, 23.412498, -9.8364, 61.822598, 26.7582, -9.1788, 58.067497, 23.412498, -9.8364, 61.822598, 23.412498, -9.1788, 58.067497, 20.0679, -9.8364, 61.822598, 20.0679, -9.1788, 58.067497, 23.412498, -9.1788, 58.067497, 26.7582, -8.658199, 55.0947, 26.7582, -9.1788, 58.067497, 23.412498, -9.1788, 58.067497, 20.0679, -9.1788, 58.067497, 20.0679, -8.658199, 55.0947, 23.412498, -8.658199, 55.0947, 20.0679, -9.1788, 58.067497, 23.412498, -8.658199, 55.0947, 23.412498, -9.1788, 58.067497, 26.7582, -8.658199, 55.0947, 23.412498, -9.1788, 58.067497, 23.412498, -8.658199, 55.0947, 20.0679, -8.658199, 55.0947, 23.412498, -8.1437, 52.156597, 23.412498, -8.658199, 55.0947, 23.412498, -8.1437, 52.156597, 26.7582, -8.658199, 55.0947, 23.412498, -8.658199, 55.0947, 23.412498, -8.1437, 52.156597, 26.7582, -8.1437, 52.156597, 26.7582, -8.658199, 55.0947, 20.0679, -3.8518999, 39.456898, 16.7232, -4.9595, 42.7343, 16.7232, -3.8518999, 39.456898, 13.378599, -4.9595, 42.7343, 16.7232, -3.8518999, 39.456898, 16.7232, -4.9595, 42.7343, 13.378599, -4.9595, 42.7343, 13.378599, -3.8518999, 39.456898, 16.7232, -3.8518999, 39.456898, 20.0679, -3.8518999, 39.456898, 20.0679, -4.9595, 42.7343, 16.7232, -4.9595, 42.7343, 20.0679, -4.9595, 42.7343, 20.0679, -3.8518999, 39.456898, 23.412498, -3.8518999, 39.456898, 13.378599, -6.2019997, 46.4111, 13.378599, -4.9595, 42.7343, 16.7232, -4.9595, 42.7343, 20.0679, -4.9595, 42.7343, 16.7232, -6.2019997, 46.4111, 16.7232, -4.9595, 42.7343, 13.378599, -6.2019997, 46.4111, 16.7232, -4.9595, 42.7343, 16.7232, -6.2019997, 46.4111, 10.033899, -4.9595, 42.7343, 13.378599, -3.8518999, 39.456898, 13.378599, -4.9595, 42.7343, 10.033899, -4.9595, 42.7343, 10.033899, -3.8518999, 39.456898, 13.378599, -3.8518999, 39.456898, 10.033899, -6.2019997, 46.4111, 13.378599, -4.9595, 42.7343, 13.378599, -6.2019997, 46.4111, 10.033899, -6.2019997, 46.4111, 10.033899, -4.9595, 42.7343, 13.378599, -4.9595, 42.7343, 6.6893, -4.9595, 42.7343, 10.033899, -3.8518999, 39.456898, 10.033899, -4.9595, 42.7343, 6.6893, -4.9595, 42.7343, 6.6893, -3.8518999, 39.456898, 10.033899, -3.8518999, 39.456898, 6.6893, -6.2019997, 46.4111, 10.033899, -4.9595, 42.7343, 10.033899, -6.2019997, 46.4111, 6.6893, -6.2019997, 46.4111, 6.6893, -4.9595, 42.7343, 10.033899, -4.9595, 42.7343, 3.3446, -4.9595, 42.7343, 6.6893, -3.8518999, 39.456898, 6.6893, -4.9595, 42.7343, 3.3446, -4.9595, 42.7343, 3.3446, -3.8518999, 39.456898, 6.6893, -3.8518999, 39.456898, 3.3446, -6.2019997, 46.4111, 6.6893, -4.9595, 42.7343, 6.6893, -6.2019997, 46.4111, 3.3446, -6.2019997, 46.4111, 3.3446, -4.9595, 42.7343, 6.6893, -4.9595, 42.7343, 0, -4.9595, 42.7343, 3.3446, -3.8518999, 39.456898, 3.3446, -4.9595, 42.7343, 0, -4.9595, 42.7343, 0, -3.8518999, 39.456898, 3.3446, -3.8518999, 39.456898, 0, -6.2019997, 46.4111, 3.3446, -4.9595, 42.7343, 3.3446, -6.2019997, 46.4111, 0, -6.2019997, 46.4111, 0, -4.9595, 42.7343, 3.3446, -4.9595, 42.7343, -3.3446002, -4.9595, 42.7343, 0, -3.8518999, 39.456898, 0, -4.9595, 42.7343, -3.3446002, -4.9595, 42.7343, -3.3446002, -3.8518999, 39.456898, 0, -3.8518999, 39.456898, -3.3446002, -6.2019997, 46.4111, 0, -4.9595, 42.7343, 0, -6.2019997, 46.4111, -3.3446002, -6.2019997, 46.4111, -3.3446002, -4.9595, 42.7343, 0, -4.9595, 42.7343, -6.6893, -4.9595, 42.7343, -3.3446002, -3.8518999, 39.456898, -3.3446002, -4.9595, 42.7343, -6.6893, -4.9595, 42.7343, -6.6893, -3.8518999, 39.456898, -3.3446002, -3.8518999, 39.456898, -6.6893, -6.2019997, 46.4111, -3.3446002, -4.9595, 42.7343, -3.3446002, -6.2019997, 46.4111, -6.6893, -6.2019997, 46.4111, -6.6893, -4.9595, 42.7343, -3.3446002, -4.9595, 42.7343, -10.033899, -4.9595, 42.7343, -6.6893, -3.8518999, 39.456898, -6.6893, -4.9595, 42.7343, -10.033899, -4.9595, 42.7343, -10.033899, -3.8518999, 39.456898, -6.6893, -3.8518999, 39.456898, -10.033899, -6.2019997, 46.4111, -6.6893, -4.9595, 42.7343, -6.6893, -6.2019997, 46.4111, -10.033899, -6.2019997, 46.4111, -10.033899, -4.9595, 42.7343, -6.6893, -4.9595, 42.7343, -13.378601, -4.9595, 42.7343, -10.033899, -3.8518999, 39.456898, -10.033899, -4.9595, 42.7343, -13.378601, -4.9595, 42.7343, -13.378601, -3.8518999, 39.456898, -10.033899, -3.8518999, 39.456898, -13.378601, -6.2019997, 46.4111, -10.033899, -4.9595, 42.7343, -10.033899, -6.2019997, 46.4111, -13.378601, -6.2019997, 46.4111, -13.378601, -4.9595, 42.7343, -10.033899, -4.9595, 42.7343, -16.7232, -4.9595, 42.7343, -13.378601, -3.8518999, 39.456898, -13.378601, -4.9595, 42.7343, -16.7232, -4.9595, 42.7343, -16.7232, -3.8518999, 39.456898, -13.378601, -3.8518999, 39.456898, -16.7232, -6.2019997, 46.4111, -13.378601, -4.9595, 42.7343, -13.378601, -6.2019997, 46.4111, -16.7232, -6.2019997, 46.4111, -16.7232, -4.9595, 42.7343, -13.378601, -4.9595, 42.7343, -20.0679, -4.9595, 42.7343, -16.7232, -3.8518999, 39.456898, -16.7232, -4.9595, 42.7343, -20.0679, -4.9595, 42.7343, -20.0679, -3.8518999, 39.456898, -16.7232, -3.8518999, 39.456898, -20.0679, -6.2019997, 46.4111, -16.7232, -4.9595, 42.7343, -16.7232, -6.2019997, 46.4111, -20.0679, -6.2019997, 46.4111, -20.0679, -4.9595, 42.7343, -16.7232, -4.9595, 42.7343, -23.412498, -4.9595, 42.7343, -20.0679, -3.8518999, 39.456898, -20.0679, -4.9595, 42.7343, -23.412498, -4.9595, 42.7343, -23.412498, -3.8518999, 39.456898, -20.0679, -3.8518999, 39.456898, -26.7572, -4.9595, 42.7343, -23.412498, -3.8518999, 39.456898, -23.412498, -4.9595, 42.7343, -26.7572, -4.9595, 42.7343, -26.7572, -3.8518999, 39.456898, -23.412498, -3.8518999, 39.456898, -26.7572, -6.2019997, 46.4111, -26.7572, -4.9595, 42.7343, -23.412498, -4.9595, 42.7343, -23.412498, -6.2019997, 46.4111, -23.412498, -4.9595, 42.7343, -20.0679, -4.9595, 42.7343, -26.7572, -6.2019997, 46.4111, -23.412498, -4.9595, 42.7343, -23.412498, -6.2019997, 46.4111, -23.412498, -6.2019997, 46.4111, -20.0679, -4.9595, 42.7343, -20.0679, -6.2019997, 46.4111, -26.7572, -7.286, 49.6186, -26.7572, -6.2019997, 46.4111, -23.412498, -6.2019997, 46.4111, -23.412498, -7.286, 49.6186, -23.412498, -6.2019997, 46.4111, -20.0679, -6.2019997, 46.4111, -26.7572, -7.286, 49.6186, -23.412498, -6.2019997, 46.4111, -23.412498, -7.286, 49.6186, -23.412498, -8.1437, 52.156597, -26.7572, -7.286, 49.6186, -23.412498, -7.286, 49.6186, -23.412498, -8.1437, 52.156597, -26.7572, -8.1437, 52.156597, -26.7572, -7.286, 49.6186, -20.0679, -8.1437, 52.156597, -23.412498, -8.1437, 52.156597, -23.412498, -7.286, 49.6186, -23.412498, -7.286, 49.6186, -20.0679, -6.2019997, 46.4111, -20.0679, -7.286, 49.6186, -20.0679, -8.1437, 52.156597, -23.412498, -7.286, 49.6186, -20.0679, -7.286, 49.6186, -20.0679, -7.286, 49.6186, -20.0679, -6.2019997, 46.4111, -16.7232, -6.2019997, 46.4111, -16.7232, -8.1437, 52.156597, -20.0679, -8.1437, 52.156597, -20.0679, -7.286, 49.6186, -20.0679, -7.286, 49.6186, -16.7232, -6.2019997, 46.4111, -16.7232, -7.286, 49.6186, -16.7232, -8.1437, 52.156597, -20.0679, -7.286, 49.6186, -16.7232, -7.286, 49.6186, -16.7232, -7.286, 49.6186, -16.7232, -6.2019997, 46.4111, -13.378601, -6.2019997, 46.4111, -13.378601, -8.1437, 52.156597, -16.7232, -8.1437, 52.156597, -16.7232, -7.286, 49.6186, -16.7232, -7.286, 49.6186, -13.378601, -6.2019997, 46.4111, -13.378601, -7.286, 49.6186, -13.378601, -8.1437, 52.156597, -16.7232, -7.286, 49.6186, -13.378601, -7.286, 49.6186, -13.378601, -7.286, 49.6186, -13.378601, -6.2019997, 46.4111, -10.033899, -6.2019997, 46.4111, -10.033899, -8.1437, 52.156597, -13.378601, -8.1437, 52.156597, -13.378601, -7.286, 49.6186, -13.378601, -7.286, 49.6186, -10.033899, -6.2019997, 46.4111, -10.033899, -7.286, 49.6186, -10.033899, -8.1437, 52.156597, -13.378601, -7.286, 49.6186, -10.033899, -7.286, 49.6186, -10.033899, -7.286, 49.6186, -10.033899, -6.2019997, 46.4111, -6.6893, -6.2019997, 46.4111, -6.6893, -8.1437, 52.156597, -10.033899, -8.1437, 52.156597, -10.033899, -7.286, 49.6186, -10.033899, -7.286, 49.6186, -6.6893, -6.2019997, 46.4111, -6.6893, -7.286, 49.6186, -6.6893, -8.1437, 52.156597, -10.033899, -7.286, 49.6186, -6.6893, -7.286, 49.6186, -6.6893, -7.286, 49.6186, -6.6893, -6.2019997, 46.4111, -3.3446002, -6.2019997, 46.4111, -3.3446002, -8.1437, 52.156597, -6.6893, -8.1437, 52.156597, -6.6893, -7.286, 49.6186, -6.6893, -7.286, 49.6186, -3.3446002, -6.2019997, 46.4111, -3.3446002, -7.286, 49.6186, -3.3446002, -8.1437, 52.156597, -6.6893, -7.286, 49.6186, -3.3446002, -7.286, 49.6186, -3.3446002, -7.286, 49.6186, -3.3446002, -6.2019997, 46.4111, 0, -6.2019997, 46.4111, 0, -8.1437, 52.156597, -3.3446002, -8.1437, 52.156597, -3.3446002, -7.286, 49.6186, -3.3446002, -7.286, 49.6186, 0, -6.2019997, 46.4111, 0, -7.286, 49.6186, 0, -8.1437, 52.156597, -3.3446002, -7.286, 49.6186, 0, -7.286, 49.6186, 0, -7.286, 49.6186, 0, -6.2019997, 46.4111, 3.3446, -6.2019997, 46.4111, 3.3446, -8.1437, 52.156597, 0, -8.1437, 52.156597, 0, -7.286, 49.6186, 0, -7.286, 49.6186, 3.3446, -6.2019997, 46.4111, 3.3446, -7.286, 49.6186, 3.3446, -8.1437, 52.156597, 0, -7.286, 49.6186, 3.3446, -7.286, 49.6186, 3.3446, -7.286, 49.6186, 3.3446, -6.2019997, 46.4111, 6.6893, -6.2019997, 46.4111, 6.6893, -8.1437, 52.156597, 3.3446, -8.1437, 52.156597, 3.3446, -7.286, 49.6186, 3.3446, -7.286, 49.6186, 6.6893, -6.2019997, 46.4111, 6.6893, -7.286, 49.6186, 6.6893, -8.1437, 52.156597, 3.3446, -7.286, 49.6186, 6.6893, -7.286, 49.6186, 6.6893, -7.286, 49.6186, 6.6893, -6.2019997, 46.4111, 10.033899, -6.2019997, 46.4111, 10.033899, -8.1437, 52.156597, 6.6893, -8.1437, 52.156597, 6.6893, -7.286, 49.6186, 6.6893, -7.286, 49.6186, 10.033899, -6.2019997, 46.4111, 10.033899, -7.286, 49.6186, 10.033899, -8.1437, 52.156597, 6.6893, -7.286, 49.6186, 10.033899, -7.286, 49.6186, 10.033899, -7.286, 49.6186, 10.033899, -6.2019997, 46.4111, 13.378599, -6.2019997, 46.4111, 13.378599, -8.1437, 52.156597, 10.033899, -8.1437, 52.156597, 10.033899, -7.286, 49.6186, 10.033899, -7.286, 49.6186, 13.378599, -6.2019997, 46.4111, 13.378599, -7.286, 49.6186, 13.378599, -8.1437, 52.156597, 10.033899, -7.286, 49.6186, 13.378599, -7.286, 49.6186, 13.378599, -7.286, 49.6186, 13.378599, -6.2019997, 46.4111, 16.7232, -6.2019997, 46.4111, 16.7232, -8.1437, 52.156597, 13.378599, -8.1437, 52.156597, 13.378599, -7.286, 49.6186, 13.378599, -7.286, 49.6186, 16.7232, -6.2019997, 46.4111, 16.7232, -7.286, 49.6186, 16.7232, -8.1437, 52.156597, 13.378599, -7.286, 49.6186, 16.7232, -7.286, 49.6186, 20.0679, -6.2019997, 46.4111, 16.7232, -7.286, 49.6186, 16.7232, -6.2019997, 46.4111, 20.0679, -7.286, 49.6186, 16.7232, -8.1437, 52.156597, 16.7232, -7.286, 49.6186, 20.0679, -6.2019997, 46.4111, 20.0679, -7.286, 49.6186, 16.7232, -7.286, 49.6186, 20.0679, -7.286, 49.6186, 20.0679, -8.1437, 52.156597, 16.7232, -8.1437, 52.156597, 20.0679, -4.9595, 42.7343, 20.0679, -6.2019997, 46.4111, 16.7232, -6.2019997, 46.4111, 20.0679, -8.1437, 52.156597, 20.0679, -7.286, 49.6186, 23.412498, -7.286, 49.6186, 20.0679, -8.1437, 52.156597, 23.412498, -7.286, 49.6186, 23.412498, -8.1437, 52.156597, 26.7582, -7.286, 49.6186, 23.412498, -8.1437, 52.156597, 23.412498, -7.286, 49.6186, 26.7582, -7.286, 49.6186, 26.7582, -8.1437, 52.156597, 23.412498, -8.1437, 52.156597, 26.7582, -6.2019997, 46.4111, 26.7582, -7.286, 49.6186, 23.412498, -7.286, 49.6186, 20.0679, -7.286, 49.6186, 23.412498, -6.2019997, 46.4111, 23.412498, -7.286, 49.6186, 26.7582, -6.2019997, 46.4111, 23.412498, -7.286, 49.6186, 23.412498, -6.2019997, 46.4111, 20.0679, -7.286, 49.6186, 20.0679, -6.2019997, 46.4111, 23.412498, -6.2019997, 46.4111, 26.7582, -4.9595, 42.7343, 26.7582, -6.2019997, 46.4111, 23.412498, -6.2019997, 46.4111, 20.0679, -6.2019997, 46.4111, 20.0679, -4.9595, 42.7343, 23.412498, -4.9595, 42.7343, 20.0679, -6.2019997, 46.4111, 23.412498, -4.9595, 42.7343, 23.412498, -6.2019997, 46.4111, 26.7582, -4.9595, 42.7343, 23.412498, -6.2019997, 46.4111, 23.412498, -4.9595, 42.7343, 20.0679, -4.9595, 42.7343, 23.412498, -3.8518999, 39.456898, 23.412498, -4.9595, 42.7343, 23.412498, -3.8518999, 39.456898, 26.7582, -4.9595, 42.7343, 23.412498, -4.9595, 42.7343, 23.412498, -3.8518999, 39.456898, 26.7582, -3.8518999, 39.456898, 26.7582, -4.9595, 42.7343, -26.7572, -1.2701, 26.7572, -26.7572, -2.0054, 30.3737, -28.645699, -2.01, 28.5121, -28.645699, -2.01, 28.5121, -30.426, -2.0144, 26.7572, -26.7572, -1.2701, 26.7572, -28.447899, -2.6727, 31.969099, -28.645699, -2.01, 28.5121, -26.7572, -2.0054, 30.3737, -26.7572, -2.0054, 30.3737, -26.7572, -2.6892, 33.737698, -28.447899, -2.6727, 31.969099, -30.291101, -2.6547, 30.040998, -30.426, -2.0144, 26.7572, -28.645699, -2.01, 28.5121, -28.447899, -2.6727, 31.969099, -30.291101, -2.6547, 30.040998, -28.645699, -2.01, 28.5121, -30.291101, -2.6547, 30.040998, -32.027798, -2.6376998, 28.2244, -30.426, -2.0144, 26.7572, -32.027798, -2.6376998, 28.2244, -33.4304, -2.6239998, 26.7572, -30.426, -2.0144, 26.7572, -29.8963, -3.2444, 33.336, -30.291101, -2.6547, 30.040998, -28.447899, -2.6727, 31.969099, -26.7572, -2.6892, 33.737698, -29.8963, -3.2444, 33.336, -28.447899, -2.6727, 31.969099, -31.7465, -3.2247999, 31.3933, -32.027798, -2.6376998, 28.2244, -30.291101, -2.6547, 30.040998, -29.8963, -3.2444, 33.336, -31.7465, -3.2247999, 31.3933, -30.291101, -2.6547, 30.040998, -26.7572, -2.6892, 33.737698, -28.188799, -3.2624998, 35.128998, -29.8963, -3.2444, 33.336, -28.188799, -3.2624998, 35.128998, -26.7572, -2.6892, 33.737698, -26.7572, -3.2777, 36.6322, -29.5455, -3.8058, 36.4474, -29.8963, -3.2444, 33.336, -28.188799, -3.2624998, 35.128998, -26.7572, -3.2777, 36.6322, -29.5455, -3.8058, 36.4474, -28.188799, -3.2624998, 35.128998, -26.7572, -3.2777, 36.6322, -28.1288, -3.8291998, 37.976498, -29.5455, -3.8058, 36.4474, -28.1288, -3.8291998, 37.976498, -26.7572, -3.2777, 36.6322, -26.7572, -3.8518999, 39.456898, -29.5455, -3.8058, 36.4474, -31.247301, -3.7775998, 34.6107, -29.8963, -3.2444, 33.336, -31.247301, -3.7775998, 34.6107, -31.7465, -3.2247999, 31.3933, -29.8963, -3.2444, 33.336, -31.247301, -3.7775998, 34.6107, -33.0801, -3.7473, 32.6325, -31.7465, -3.2247999, 31.3933, -33.0801, -3.7473, 32.6325, -33.4894, -3.2064, 29.5632, -31.7465, -3.2247999, 31.3933, -31.7465, -3.2247999, 31.3933, -33.4894, -3.2064, 29.5632, -32.027798, -2.6376998, 28.2244, -33.4894, -3.2064, 29.5632, -33.4304, -2.6239998, 26.7572, -32.027798, -2.6376998, 28.2244, -33.0801, -3.7473, 32.6325, -34.806297, -3.7187998, 30.769499, -33.4894, -3.2064, 29.5632, -33.4894, -3.2064, 29.5632, -34.9099, -3.1913, 28.0716, -33.4304, -2.6239998, 26.7572, -34.806297, -3.7187998, 30.769499, -34.9099, -3.1913, 28.0716, -33.4894, -3.2064, 29.5632, -34.9099, -3.1913, 28.0716, -36.161697, -3.1780999, 26.7572, -33.4304, -2.6239998, 26.7572, -34.806297, -3.7187998, 30.769499, -36.2241, -3.6952999, 29.2393, -34.9099, -3.1913, 28.0716, -36.2241, -3.6952999, 29.2393, -36.161697, -3.1780999, 26.7572, -34.9099, -3.1913, 28.0716, -36.2241, -3.6952999, 29.2393, -37.4711, -3.6747, 27.8934, -36.161697, -3.1780999, 26.7572, -37.4711, -3.6747, 27.8934, -38.5239, -3.6573, 26.7572, -36.161697, -3.1780999, 26.7572, -50.290497, -8.1437, 26.7572, -47.4473, -7.0596, 26.7572, -49.074398, -8.1437, 28.0697, -62.057194, -10.4337, 26.7572, -58.463497, -9.7343, 26.7572, -60.611, -10.4337, 28.3335, 26.7582, -9.8364, 61.822598, 66.1166, -10.4337, 26.7572, 26.7582, -10.4337, 65.2334, 26.7582, -9.8364, 61.822598, 62.4636, -9.7961, 26.7572, 66.1166, -10.4337, 26.7572, 26.7582, -9.1788, 58.067497, 62.4636, -9.7961, 26.7572, 26.7582, -9.8364, 61.822598, 26.7582, -9.1788, 58.067497, 58.918198, -9.177199, 26.7572, 62.4636, -9.7961, 26.7572, 26.7582, -9.1788, 58.067497, 55.849197, -8.6414995, 26.7572, 58.918198, -9.177199, 26.7572, 55.849197, -8.6414995, 26.7572, 26.7582, -9.1788, 58.067497, 52.997097, -8.1437, 26.7572, 52.997097, -8.1437, 26.7572, 26.7582, -9.1788, 58.067497, 26.7582, -8.1437, 52.156597, 26.7582, -8.1437, 52.156597, 26.7582, -9.1788, 58.067497, 26.7582, -8.658199, 55.0947, 43.234898, -4.8054, 26.7572, 26.7582, -3.8518999, 39.456898, 39.877598, -3.6573, 26.7572, 46.577698, -5.9484997, 26.7572, 26.7582, -3.8518999, 39.456898, 43.234898, -4.8054, 26.7572, 46.577698, -5.9484997, 26.7572, 26.7582, -6.2019997, 46.4111, 26.7582, -3.8518999, 39.456898, 26.7582, -3.8518999, 39.456898, 26.7582, -6.2019997, 46.4111, 26.7582, -4.9595, 42.7343, 26.7582, -6.2019997, 46.4111, 46.577698, -5.9484997, 26.7572, 26.7582, -8.1437, 52.156597, 26.7582, -6.2019997, 46.4111, 26.7582, -8.1437, 52.156597, 26.7582, -7.286, 49.6186, 26.7582, -8.1437, 52.156597, 46.577698, -5.9484997, 26.7572, 49.5743, -6.9732, 26.7572, 26.7582, -8.1437, 52.156597, 49.5743, -6.9732, 26.7572, 52.997097, -8.1437, 26.7572, 29.1145, -2.5956, 26.7572, 26.7582, -3.0839999, 30.2834, 26.7582, -2.7837, 26.6955, 29.1145, -2.5956, 26.7572, 26.7582, -3.3683999, 33.680798, 26.7582, -3.0839999, 30.2834, 33.146698, -3.214, 26.7572, 26.7582, -3.3683999, 33.680798, 29.1145, -2.5956, 26.7572, 33.146698, -3.214, 26.7572, 26.7582, -3.6130998, 36.6041, 26.7582, -3.3683999, 33.680798, 26.7582, -3.6130998, 36.6041, 33.146698, -3.214, 26.7572, 39.877598, -3.6573, 26.7572, 39.877598, -3.6573, 26.7572, 33.146698, -3.214, 26.7572, 36.078598, -3.0746999, 26.7572, 26.7582, -3.6130998, 36.6041, 39.877598, -3.6573, 26.7572, 26.7582, -3.8518999, 39.456898, 26.7573, -1.7447, -30.111198, 29.3928, -1.796, -26.7572, 26.7573, -1.3222, -26.7572, 26.7572, -2.5274, -33.9013, 29.3928, -1.796, -26.7572, 26.7573, -1.7447, -30.111198, 29.3928, -1.796, -26.7572, 26.7572, -2.5274, -33.9013, 26.7572, -3.9166, -40.6287, 26.7572, -3.9166, -40.6287, 26.7572, -2.5274, -33.9013, 26.7572, -3.2101, -37.2077, 29.3928, -1.796, -26.7572, 26.7572, -3.9166, -40.6287, 36.0678, -2.9959998, -26.7572, 36.0678, -2.9959998, -26.7572, 26.7572, -3.9166, -40.6287, 39.877598, -3.6808999, -26.7572, 29.3928, -1.796, -26.7572, 36.0678, -2.9959998, -26.7572, 32.9589, -2.4371, -26.7572, 49.4036, -6.9384, -26.7572, 26.7572, -8.403, -54.500298, 52.997097, -8.1672, -26.7572, 46.2278, -5.8524, -26.7572, 26.7572, -8.403, -54.500298, 49.4036, -6.9384, -26.7572, 26.7572, -8.403, -54.500298, 46.2278, -5.8524, -26.7572, 26.7572, -3.9166, -40.6287, 26.7572, -3.9166, -40.6287, 46.2278, -5.8524, -26.7572, 42.8416, -4.6944, -26.7572, 26.7572, -3.9166, -40.6287, 42.8416, -4.6944, -26.7572, 39.877598, -3.6808999, -26.7572, 26.7572, -8.403, -54.500298, 26.7572, -3.9166, -40.6287, 26.7572, -6.2504, -47.8447, 26.7572, -6.2504, -47.8447, 26.7572, -3.9166, -40.6287, 26.7572, -5.1514, -44.446598, 26.7572, -8.403, -54.500298, 26.7572, -6.2504, -47.8447, 26.7572, -7.3550997, -51.2602, 62.356697, -9.8008995, -26.7572, 26.7572, -10.693, -68.371895, 66.1166, -10.4572, -26.7572, 59.4335, -9.2907, -26.7572, 26.7572, -10.693, -68.371895, 62.356697, -9.8008995, -26.7572, 26.7572, -10.693, -68.371895, 59.4335, -9.2907, -26.7572, 26.7572, -9.312699, -60.011097, 26.7572, -10.693, -68.371895, 26.7572, -9.312699, -60.011097, 26.7572, -9.876599, -63.426796, 26.7572, -9.312699, -60.011097, 59.4335, -9.2907, -26.7572, 56.4827, -8.7755995, -26.7572, 26.7572, -9.312699, -60.011097, 56.4827, -8.7755995, -26.7572, 26.7572, -8.881, -57.3956, 26.7572, -8.881, -57.3956, 56.4827, -8.7755995, -26.7572, 52.997097, -8.1672, -26.7572, 26.7572, -8.881, -57.3956, 52.997097, -8.1672, -26.7572, 26.7572, -8.403, -54.500298, 26.7572, -10.693, -70.330795, 66.1166, -10.4572, -26.7572, 26.7572, -10.693, -68.371895, 26.7572, -10.693, -70.330795, 68.9481, -10.4572, -26.7572, 66.1166, -10.4572, -26.7572, 26.7572, -10.693, -73.1232, 68.9481, -10.4572, -26.7572, 26.7572, -10.693, -70.330795, 26.7572, -10.693, -73.1232, 72.2092, -10.4572, -26.7572, 68.9481, -10.4572, -26.7572, 26.7572, -10.693, -73.1232, 75.6957, -10.4572, -26.7572, 72.2092, -10.4572, -26.7572, 75.6957, -10.4572, -26.7572, 26.7572, -10.693, -73.1232, 26.7572, -10.693, -82.2434, 75.6957, -10.4572, -26.7572, 26.7572, -10.693, -82.2434, 79.236, -10.4572, -26.7572, 26.7572, -10.693, -82.2434, 26.7572, -10.693, -73.1232, 26.7572, -10.693, -79.5579, 26.7572, -10.693, -79.5579, 26.7572, -10.693, -73.1232, 26.7572, -10.693, -76.193, -30.173899, -3.2568, -26.7572, -26.7572, -3.2445998, -30.4851, -26.7572, -3.0929, -26.7572, -30.173899, -3.2568, -26.7572, -26.7572, -3.3862, -33.9664, -26.7572, -3.2445998, -30.4851, -30.173899, -3.2568, -26.7572, -26.7572, -3.5244, -37.3623, -26.7572, -3.3862, -33.9664, -26.7572, -3.5244, -37.3623, -30.173899, -3.2568, -26.7572, -36.0882, -3.5405, -26.7572, -36.0882, -3.5405, -26.7572, -30.173899, -3.2568, -26.7572, -33.2719, -3.4053998, -26.7572, -26.7572, -3.5244, -37.3623, -36.0882, -3.5405, -26.7572, -26.7572, -3.6573, -40.6287, -26.7572, -3.6573, -40.6287, -36.0882, -3.5405, -26.7572, -38.5239, -3.6573, -26.7572, -41.627, -4.8403997, -26.7572, -26.7572, -3.6573, -40.6287, -38.5239, -3.6573, -26.7572, -41.627, -4.8403997, -26.7572, -26.7572, -5.9270997, -47.646698, -26.7572, -3.6573, -40.6287, -26.7572, -3.6573, -40.6287, -26.7572, -5.9270997, -47.646698, -26.7572, -4.8125997, -44.200798, -44.537098, -5.95, -26.7572, -26.7572, -5.9270997, -47.646698, -41.627, -4.8403997, -26.7572, -26.7572, -5.9270997, -47.646698, -44.537098, -5.95, -26.7572, -50.290497, -8.1437, -26.7572, -50.290497, -8.1437, -26.7572, -44.537098, -5.95, -26.7572, -47.4473, -7.0596, -26.7572, -26.7572, -5.9270997, -47.646698, -50.290497, -8.1437, -26.7572, -26.7572, -7.1011, -51.2768, -26.7572, -7.1011, -51.2768, -50.290497, -8.1437, -26.7572, -26.7572, -8.1437, -54.500298, -53.117798, -8.6939, -26.7572, -26.7572, -8.1437, -54.500298, -50.290497, -8.1437, -26.7572, -55.5951, -9.176, -26.7572, -26.7572, -8.1437, -54.500298, -53.117798, -8.6939, -26.7572, -58.463497, -9.7343, -26.7572, -26.7572, -8.1437, -54.500298, -55.5951, -9.176, -26.7572, -26.7572, -8.1437, -54.500298, -58.463497, -9.7343, -26.7572, -26.7572, -9.7125, -64.0031, -26.7572, -9.7125, -64.0031, -58.463497, -9.7343, -26.7572, -62.057194, -10.4337, -26.7572, -26.7572, -9.7125, -64.0031, -62.057194, -10.4337, -26.7572, -26.7572, -10.4337, -68.371895, -26.7572, -8.1437, -54.500298, -26.7572, -9.7125, -64.0031, -26.7572, -9.111199, -60.361, -26.7572, -8.1437, -54.500298, -26.7572, -9.111199, -60.361, -26.7572, -8.6276, -57.4314, -64.963196, -10.4337, -26.7572, -26.7572, -10.4337, -68.371895, -62.057194, -10.4337, -26.7572, -64.963196, -10.4337, -26.7572, -26.7572, -10.4337, -70.9319, -26.7572, -10.4337, -68.371895, -64.963196, -10.4337, -26.7572, -62.057194, -10.4337, -26.7572, -62.057194, -10.4337, -23.412498, -68.2489, -10.4337, -26.7572, -26.7572, -10.4337, -70.9319, -64.963196, -10.4337, -26.7572, -68.2489, -10.4337, -26.7572, -26.7572, -10.4337, -73.7615, -26.7572, -10.4337, -70.9319, -64.963196, -10.4337, -26.7572, -62.057194, -10.4337, -23.412498, -64.963196, -10.4337, -23.412498, -68.2489, -10.4337, -26.7572, -64.963196, -10.4337, -26.7572, -64.963196, -10.4337, -23.412498, -64.963196, -10.4337, -23.412498, -62.057194, -10.4337, -23.412498, -62.057194, -10.4337, -20.174799, -71.2999, -10.4337, -26.7572, -26.7572, -10.4337, -73.7615, -68.2489, -10.4337, -26.7572, -71.2999, -10.4337, -26.7572, -26.7572, -10.4337, -76.6203, -26.7572, -10.4337, -73.7615, -26.7572, -10.4337, -76.6203, -71.2999, -10.4337, -26.7572, -26.7572, -10.4337, -79.0804, -26.7572, -10.4337, -79.0804, -71.2999, -10.4337, -26.7572, -26.7572, -10.4337, -82.2434, -26.7572, -10.4337, -82.2434, -71.2999, -10.4337, -26.7572, -73.8239, -10.4337, -26.7572, -73.8239, -10.4337, -23.412498, -73.8239, -10.4337, -26.7572, -71.2999, -10.4337, -26.7572, -68.2489, -10.4337, -26.7572, -64.963196, -10.4337, -23.412498, -68.2489, -10.4337, -23.412498, -71.2999, -10.4337, -26.7572, -68.2489, -10.4337, -26.7572, -68.2489, -10.4337, -23.412498, -73.8239, -10.4337, -23.412498, -71.2999, -10.4337, -26.7572, -71.2999, -10.4337, -23.412498, -71.2999, -10.4337, -26.7572, -68.2489, -10.4337, -23.412498, -71.2999, -10.4337, -23.412498, -73.8239, -10.4337, -20.0679, -73.8239, -10.4337, -23.412498, -71.2999, -10.4337, -23.412498, -73.8239, -10.4337, -20.0679, -71.2999, -10.4337, -23.412498, -71.2999, -10.4337, -20.0908, -71.2999, -10.4337, -23.412498, -68.2489, -10.4337, -20.118599, -71.2999, -10.4337, -20.0908, -71.2999, -10.4337, -23.412498, -68.2489, -10.4337, -23.412498, -68.2489, -10.4337, -20.118599, -71.2999, -10.4337, -20.0908, -68.2489, -10.4337, -20.118599, -68.2489, -10.4337, -16.7739, -68.2489, -10.4337, -20.118599, -64.963196, -10.4337, -16.803799, -68.2489, -10.4337, -16.7739, -68.2489, -10.4337, -23.412498, -64.963196, -10.4337, -20.1484, -68.2489, -10.4337, -20.118599, -68.2489, -10.4337, -20.118599, -64.963196, -10.4337, -20.1484, -64.963196, -10.4337, -16.803799, -68.2489, -10.4337, -23.412498, -64.963196, -10.4337, -23.412498, -64.963196, -10.4337, -20.1484, -62.057194, -10.4337, -20.174799, -64.963196, -10.4337, -16.803799, -64.963196, -10.4337, -20.1484, -64.963196, -10.4337, -23.412498, -62.057194, -10.4337, -20.174799, -64.963196, -10.4337, -20.1484, -3.3446002, -1.458, -26.7572, -6.6709, -1.7958, -30.683697, -3.3313003, -1.7853999, -30.628498, -3.3446002, -1.458, -26.7572, -6.6893, -1.458, -26.7572, -6.6709, -1.7958, -30.683697, -6.6893, -3.6573, -40.6287, -6.6689, -4.8491, -44.313698, -6.6243997, -3.6573, -40.6287, -6.6689, -4.8491, -44.313698, -3.3446002, -3.6573, -40.6287, -6.6243997, -3.6573, -40.6287, -6.6893, -3.6573, -40.6287, -9.908199, -3.6573, -40.6287, -6.6689, -4.8491, -44.313698, -6.6689, -4.8491, -44.313698, -3.297, -3.6573, -40.6287, -3.3446002, -3.6573, -40.6287, -6.6689, -4.8491, -44.313698, -9.908199, -3.6573, -40.6287, -9.969999, -4.8431, -44.295097, -10.033899, -3.6573, -40.6287, -9.969999, -4.8431, -44.295097, -9.908199, -3.6573, -40.6287, -9.969999, -4.8431, -44.295097, -10.033899, -3.6573, -40.6287, -13.0655, -3.6573, -40.6287, -9.969999, -4.8431, -44.295097, -13.0655, -3.6573, -40.6287, -13.160999, -4.8373, -44.2772, -13.378601, -3.6573, -40.6287, -13.160999, -4.8373, -44.2772, -13.0655, -3.6573, -40.6287, -13.160999, -4.8373, -44.2772, -13.378601, -3.6573, -40.6287, -16.3079, -3.6573, -40.6287, -13.160999, -4.8373, -44.2772, -16.3079, -3.6573, -40.6287, -16.4166, -4.8314, -44.2589, -16.7232, -3.6573, -40.6287, -16.4166, -4.8314, -44.2589, -16.3079, -3.6573, -40.6287, -16.4166, -4.8314, -44.2589, -16.7232, -3.6573, -40.6287, -19.5913, -3.6573, -40.6287, -16.4166, -4.8314, -44.2589, -19.5913, -3.6573, -40.6287, -19.7153, -4.8254, -44.2403, -20.0679, -3.6573, -40.6287, -19.7153, -4.8254, -44.2403, -19.5913, -3.6573, -40.6287, -19.7153, -4.8254, -44.2403, -20.0679, -3.6573, -40.6287, -22.8746, -3.6573, -40.6287, -19.7153, -4.8254, -44.2403, -22.8746, -3.6573, -40.6287, -23.013899, -4.8194, -44.221798, -23.412498, -3.6573, -40.6287, -23.013899, -4.8194, -44.221798, -22.8746, -3.6573, -40.6287, -23.013899, -4.8194, -44.221798, -23.412498, -3.6573, -40.6287, -26.7572, -3.6573, -40.6287, -23.013899, -4.8194, -44.221798, -26.7572, -3.6573, -40.6287, -26.7572, -4.8125997, -44.200798, -26.7572, -4.8125997, -44.200798, -23.1474, -5.9323997, -47.6632, -23.013899, -4.8194, -44.221798, -26.7572, -4.8125997, -44.200798, -26.7572, -5.9270997, -47.646698, -23.1474, -5.9323997, -47.6632, -23.013899, -4.8194, -44.221798, -19.8335, -5.9372997, -47.6783, -19.7153, -4.8254, -44.2403, -23.013899, -4.8194, -44.221798, -23.1474, -5.9323997, -47.6632, -19.8335, -5.9372997, -47.6783, -19.7153, -4.8254, -44.2403, -19.8335, -5.9372997, -47.6783, -16.5194, -5.9421997, -47.6935, -19.7153, -4.8254, -44.2403, -16.5194, -5.9421997, -47.6935, -16.4166, -4.8314, -44.2589, -26.7572, -5.9270997, -47.646698, -23.2876, -7.1015997, -51.2782, -23.1474, -5.9323997, -47.6632, -26.7572, -5.9270997, -47.646698, -26.7572, -7.1011, -51.2768, -23.2876, -7.1015997, -51.2782, -26.7572, -7.1011, -51.2768, -23.412498, -8.1437, -54.500298, -23.2876, -7.1015997, -51.2782, -26.7572, -7.1011, -51.2768, -26.7572, -8.1437, -54.500298, -23.412498, -8.1437, -54.500298, -23.2876, -7.1015997, -51.2782, -23.412498, -8.1437, -54.500298, -20.0679, -8.1437, -54.500298, -23.1474, -5.9323997, -47.6632, -23.2876, -7.1015997, -51.2782, -19.9572, -7.1019998, -51.2795, -23.2876, -7.1015997, -51.2782, -20.0679, -8.1437, -54.500298, -19.9572, -7.1019998, -51.2795, -23.1474, -5.9323997, -47.6632, -19.9572, -7.1019998, -51.2795, -19.8335, -5.9372997, -47.6783, -19.9572, -7.1019998, -51.2795, -20.0679, -8.1437, -54.500298, -16.7232, -8.1437, -54.500298, -19.8335, -5.9372997, -47.6783, -19.9572, -7.1019998, -51.2795, -16.626799, -7.1024, -51.2808, -19.9572, -7.1019998, -51.2795, -16.7232, -8.1437, -54.500298, -16.626799, -7.1024, -51.2808, -19.8335, -5.9372997, -47.6783, -16.626799, -7.1024, -51.2808, -16.5194, -5.9421997, -47.6935, -16.626799, -7.1024, -51.2808, -16.7232, -8.1437, -54.500298, -13.4286, -8.1437, -54.500298, -16.5194, -5.9421997, -47.6935, -16.626799, -7.1024, -51.2808, -13.344299, -7.1029, -51.282097, -16.626799, -7.1024, -51.2808, -13.4286, -8.1437, -54.500298, -13.344299, -7.1029, -51.282097, -16.5194, -5.9421997, -47.6935, -13.344299, -7.1029, -51.282097, -13.250801, -5.947, -47.708397, -16.4166, -4.8314, -44.2589, -16.5194, -5.9421997, -47.6935, -13.250801, -5.947, -47.708397, -16.4166, -4.8314, -44.2589, -13.250801, -5.947, -47.708397, -13.160999, -4.8373, -44.2772, -13.4286, -8.1437, -54.500298, -10.0877, -7.1032996, -51.283398, -13.344299, -7.1029, -51.282097, -13.250801, -5.947, -47.708397, -13.344299, -7.1029, -51.282097, -10.0877, -7.1032996, -51.283398, -10.0877, -7.1032996, -51.283398, -13.4286, -8.1437, -54.500298, -13.378601, -8.1437, -54.500298, -10.0877, -7.1032996, -51.283398, -13.378601, -8.1437, -54.500298, -10.141901, -8.1437, -54.500298, -13.160999, -4.8373, -44.2772, -13.250801, -5.947, -47.708397, -10.0277, -5.9518, -47.723198, -13.250801, -5.947, -47.708397, -10.0877, -7.1032996, -51.283398, -10.0277, -5.9518, -47.723198, -13.160999, -4.8373, -44.2772, -10.0277, -5.9518, -47.723198, -9.969999, -4.8431, -44.295097, -10.141901, -8.1437, -54.500298, -6.7531, -7.1036997, -51.284798, -10.0877, -7.1032996, -51.283398, -10.0277, -5.9518, -47.723198, -10.0877, -7.1032996, -51.283398, -6.7531, -7.1036997, -51.284798, -6.7531, -7.1036997, -51.284798, -10.141901, -8.1437, -54.500298, -10.033899, -8.1437, -54.500298, -6.7531, -7.1036997, -51.284798, -10.033899, -8.1437, -54.500298, -6.7919, -8.1437, -54.500298, -6.6893, -8.1437, -54.500298, -6.7531, -7.1036997, -51.284798, -6.7919, -8.1437, -54.500298, -6.7531, -7.1036997, -51.284798, -6.6893, -8.1437, -54.500298, -3.4199996, -8.1437, -54.500298, -9.969999, -4.8431, -44.295097, -10.0277, -5.9518, -47.723198, -6.7102, -5.9567, -47.7383, -10.0277, -5.9518, -47.723198, -6.7531, -7.1036997, -51.284798, -6.7102, -5.9567, -47.7383, -9.969999, -4.8431, -44.295097, -6.7102, -5.9567, -47.7383, -6.6689, -4.8491, -44.313698, -6.7531, -7.1036997, -51.284798, -3.4199996, -8.1437, -54.500298, -3.3915, -7.1040998, -51.2861, -3.3915, -7.1040998, -51.2861, -6.7102, -5.9567, -47.7383, -6.7531, -7.1036997, -51.284798, -3.3446002, -8.1437, -54.500298, -3.3915, -7.1040998, -51.2861, -3.4199996, -8.1437, -54.500298, -3.3915, -7.1040998, -51.2861, -3.3446002, -8.1437, -54.500298, -0.048199892, -8.1437, -54.500298, -3.3915, -7.1040998, -51.2861, -3.3602, -5.9617, -47.753597, -6.7102, -5.9567, -47.7383, -3.3602, -5.9617, -47.753597, -6.6689, -4.8491, -44.313698, -6.7102, -5.9567, -47.7383, -3.3915, -7.1040998, -51.2861, -0.048199892, -8.1437, -54.500298, -0.03699994, -7.1046, -51.2874, -0.03699994, -7.1046, -51.2874, -3.3602, -5.9617, -47.753597, -3.3915, -7.1040998, -51.2861, 0, -8.1437, -54.500298, -0.03699994, -7.1046, -51.2874, -0.048199892, -8.1437, -54.500298, -0.03699994, -7.1046, -51.2874, 0, -8.1437, -54.500298, 3.3446, -8.1437, -54.500298, -3.3602, -5.9617, -47.753597, -3.3298001, -4.8552, -44.3325, -6.6689, -4.8491, -44.313698, -3.297, -3.6573, -40.6287, -6.6689, -4.8491, -44.313698, -3.3298001, -4.8552, -44.3325, -0.03699994, -7.1046, -51.2874, -0.024800062, -5.9666, -47.768898, -3.3602, -5.9617, -47.753597, -0.024800062, -5.9666, -47.768898, -3.3298001, -4.8552, -44.3325, -3.3602, -5.9617, -47.753597, -0.012900114, -4.8612, -44.351097, -3.297, -3.6573, -40.6287, -3.3298001, -4.8552, -44.3325, -0.024800062, -5.9666, -47.768898, -0.012900114, -4.8612, -44.351097, -3.3298001, -4.8552, -44.3325, -0.012900114, -4.8612, -44.351097, 0, -3.6573, -40.6287, -3.297, -3.6573, -40.6287, 3.3446, -7.105, -51.2888, -0.024800062, -5.9666, -47.768898, -0.03699994, -7.1046, -51.2874, -0.03699994, -7.1046, -51.2874, 3.3446, -8.1437, -54.500298, 3.3446, -7.105, -51.2888, 3.3446, -5.9716, -47.7842, -0.012900114, -4.8612, -44.351097, -0.024800062, -5.9666, -47.768898, 3.3446, -7.105, -51.2888, 3.3446, -5.9716, -47.7842, -0.024800062, -5.9666, -47.768898, 3.3446, -4.8673, -44.37, 0, -3.6573, -40.6287, -0.012900114, -4.8612, -44.351097, 3.3446, -5.9716, -47.7842, 3.3446, -4.8673, -44.37, -0.012900114, -4.8612, -44.351097, 3.3446, -4.8673, -44.37, 3.3446, -3.6573, -40.6287, 0, -3.6573, -40.6287, 3.3446, -8.1437, -54.500298, 6.6893, -7.1053996, -51.2901, 3.3446, -7.105, -51.2888, 6.6893, -7.1053996, -51.2901, 3.3446, -5.9716, -47.7842, 3.3446, -7.105, -51.2888, 3.3446, -8.1437, -54.500298, 6.6893, -8.1437, -54.500298, 6.6893, -7.1053996, -51.2901, 6.6893, -5.9765, -47.7995, 3.3446, -4.8673, -44.37, 3.3446, -5.9716, -47.7842, 6.6893, -7.1053996, -51.2901, 6.6893, -5.9765, -47.7995, 3.3446, -5.9716, -47.7842, 6.6893, -4.8733997, -44.388798, 3.3446, -3.6573, -40.6287, 3.3446, -4.8673, -44.37, 6.6893, -5.9765, -47.7995, 6.6893, -4.8733997, -44.388798, 3.3446, -4.8673, -44.37, 6.6893, -4.8733997, -44.388798, 6.6893, -3.6573, -40.6287, 3.3446, -3.6573, -40.6287, 6.6893, -8.1437, -54.500298, 10.033899, -7.1059, -51.291397, 6.6893, -7.1053996, -51.2901, 10.033899, -7.1059, -51.291397, 6.6893, -5.9765, -47.7995, 6.6893, -7.1053996, -51.2901, 6.6893, -8.1437, -54.500298, 10.033899, -8.1437, -54.500298, 10.033899, -7.1059, -51.291397, 13.378599, -8.1437, -54.500298, 10.033899, -7.1059, -51.291397, 10.033899, -8.1437, -54.500298, 10.033899, -7.1059, -51.291397, 13.378599, -8.1437, -54.500298, 13.444599, -8.1437, -54.500298, 10.033899, -4.8795, -44.4076, 6.6893, -3.6573, -40.6287, 6.6893, -4.8733997, -44.388798, 10.033899, -5.9814, -47.8148, 6.6893, -4.8733997, -44.388798, 6.6893, -5.9765, -47.7995, 10.033899, -7.1059, -51.291397, 10.033899, -5.9814, -47.8148, 6.6893, -5.9765, -47.7995, 10.033899, -5.9814, -47.8148, 10.033899, -4.8795, -44.4076, 6.6893, -4.8733997, -44.388798, 10.033899, -7.1059, -51.291397, 13.444599, -8.1437, -54.500298, 13.429298, -7.1063, -51.292797, 13.429298, -7.1063, -51.292797, 10.033899, -5.9814, -47.8148, 10.033899, -7.1059, -51.291397, 10.033899, -4.8795, -44.4076, 10.033899, -3.6573, -40.6287, 6.6893, -3.6573, -40.6287, 16.7232, -8.1437, -54.500298, 13.429298, -7.1063, -51.292797, 13.444599, -8.1437, -54.500298, 13.429298, -7.1063, -51.292797, 16.7232, -8.1437, -54.500298, 16.8188, -8.1437, -54.500298, 13.412899, -5.9863997, -47.8302, 10.033899, -4.8795, -44.4076, 10.033899, -5.9814, -47.8148, 13.429298, -7.1063, -51.292797, 13.412899, -5.9863997, -47.8302, 10.033899, -5.9814, -47.8148, 13.3967, -4.8856, -44.4265, 10.033899, -3.6573, -40.6287, 10.033899, -4.8795, -44.4076, 13.412899, -5.9863997, -47.8302, 13.3967, -4.8856, -44.4265, 10.033899, -4.8795, -44.4076, 13.3967, -4.8856, -44.4265, 13.378599, -3.6573, -40.6287, 10.033899, -3.6573, -40.6287, 13.429298, -7.1063, -51.292797, 16.8188, -8.1437, -54.500298, 16.7967, -7.1067, -51.294098, 16.7967, -7.1067, -51.294098, 13.412899, -5.9863997, -47.8302, 13.429298, -7.1063, -51.292797, 20.0679, -8.1437, -54.500298, 16.7967, -7.1067, -51.294098, 16.8188, -8.1437, -54.500298, 16.7967, -7.1067, -51.294098, 20.0679, -8.1437, -54.500298, 20.1975, -8.1437, -54.500298, 16.7967, -7.1067, -51.294098, 20.1975, -8.1437, -54.500298, 20.1675, -7.1071997, -51.295498, 16.7967, -7.1067, -51.294098, 16.772999, -5.9914, -47.8456, 13.412899, -5.9863997, -47.8302, 20.1675, -7.1071997, -51.295498, 16.772999, -5.9914, -47.8456, 16.7967, -7.1067, -51.294098, 16.772999, -5.9914, -47.8456, 13.3967, -4.8856, -44.4265, 13.412899, -5.9863997, -47.8302, 20.1675, -7.1071997, -51.295498, 20.135399, -5.9964, -47.861, 16.772999, -5.9914, -47.8456, 16.7495, -4.8917, -44.445297, 13.378599, -3.6573, -40.6287, 13.3967, -4.8856, -44.4265, 16.772999, -5.9914, -47.8456, 16.7495, -4.8917, -44.445297, 13.3967, -4.8856, -44.4265, 20.135399, -5.9964, -47.861, 16.7495, -4.8917, -44.445297, 16.772999, -5.9914, -47.8456, 16.7495, -4.8917, -44.445297, 16.7232, -3.6573, -40.6287, 13.378599, -3.6573, -40.6287, 20.135399, -5.9964, -47.861, 20.103699, -4.8978, -44.4642, 16.7495, -4.8917, -44.445297, 20.103699, -4.8978, -44.4642, 16.7232, -3.6573, -40.6287, 16.7495, -4.8917, -44.445297, 20.103699, -4.8978, -44.4642, 20.0679, -3.6573, -40.6287, 16.7232, -3.6573, -40.6287, 16.7232, 0, 10.033899, 17.4912, 0.0034999999, 7.4080997, 20.0679, 0, 10.033899, 12.824999, 2.8544, 7.9234, 11.2783985, 2.1174998, 3.3446, 12.705599, 1.0321, 4.0927, 12.824999, 2.8544, 7.9234, 11.173899, 3.3332, 6.6554, 11.2783985, 2.1174998, 3.3446, 10.033899, 3.1929998, 0, 13.249599, 2.9931998, -3.4737, 11.278099, 2.3706, 0, 10.033899, 3.1929998, 0, 10.033899, 3.2212, -2.2307, 13.249599, 2.9931998, -3.4737, 20.613499, 1.0832, -3.1922002, 22.5396, -0.014599999, -3.3446002, 20.5402, -0.0411, -1.7683, 20.613499, 1.0832, -3.1922002, 21.09, -0.0218, -4.9179, 22.5396, -0.014599999, -3.3446002, 15.485399, 1.0792, -1.6708999, 14.217899, 2.8779, -3.3838, 15.6619, 2.2603, -2.7998996, 20.613499, 1.0832, -3.1922002, 15.485399, 1.0792, -1.6708999, 15.6619, 2.2603, -2.7998996, 20.613499, 1.0832, -3.1922002, 20.5402, -0.0411, -1.7683, 15.485399, 1.0792, -1.6708999, 20.5402, -0.0411, -1.7683, 23.412498, 0, 0, 20.2292, 0.030499998, -0.05219996, 23.412498, 0, 0, 20.5402, -0.0411, -1.7683, 22.5396, -0.014599999, -3.3446002, 23.412498, 0, 0, 22.5396, -0.014599999, -3.3446002, 23.412498, 0, -3.3446002, 21.09, -0.0218, -4.9179, 23.412498, 0, -6.6893, 22.5396, -0.014599999, -3.3446002, 21.09, -0.0218, -4.9179, 21.2349, 0, -6.6893, 23.412498, 0, -6.6893, 15.485399, 1.0792, -1.6708999, 20.2292, 0.030499998, -0.05219996, 14.261799, 0.9069, 1.1807, 14.217899, 2.8779, -3.3838, 11.278099, 2.3706, 0, 13.249599, 2.9931998, -3.4737, 11.278099, 2.3706, 0, 14.217899, 2.8779, -3.3838, 15.485399, 1.0792, -1.6708999, 11.278099, 2.3706, 0, 15.485399, 1.0792, -1.6708999, 14.261799, 0.9069, 1.1807, 15.485399, 1.0792, -1.6708999, 20.5402, -0.0411, -1.7683, 20.2292, 0.030499998, -0.05219996, 10.033899, 3.3913999, 4.2546997, 11.2783985, 2.1174998, 3.3446, 11.173899, 3.3332, 6.6554, 10.033899, 3.3913999, 4.2546997, 10.033899, 3.1929998, 3.3446, 11.2783985, 2.1174998, 3.3446, 6.6893, 3.3913999, 3.3446, 10.033899, 3.1929998, 3.3446, 10.033899, 3.3913999, 4.2546997, 10.033899, 3.2212, -2.2307, 10.033899, 3.1929998, 0, 6.6893, 3.3913999, 0, 14.0257, 0.0442, 7.3252997, 12.824999, 2.8544, 7.9234, 12.705599, 1.0321, 4.0927, 14.0257, 0.0442, 7.3252997, 13.175798, 2.4743, 8.042, 12.824999, 2.8544, 7.9234, 26.7582, -0.83089995, 6.6893, 26.7582, -1.7623999, 10.033899, 23.412498, -0.35529998, 10.033899, 23.826399, -8.175799, -54.500298, 26.7572, -7.3550997, -51.2602, 23.7466, -7.1323, -51.2931, 23.826399, -8.175799, -54.500298, 26.7572, -8.403, -54.500298, 26.7572, -7.3550997, -51.2602, 26.7572, -7.3550997, -51.2602, 23.6614, -6.0197997, -47.8736, 23.7466, -7.1323, -51.2931, 26.7572, -7.3550997, -51.2602, 26.7572, -6.2504, -47.8447, 23.6614, -6.0197997, -47.8736, 26.7572, -6.2504, -47.8447, 23.577, -4.916, -44.481, 23.6614, -6.0197997, -47.8736, 26.7572, -6.2504, -47.8447, 26.7572, -5.1514, -44.446598, 23.577, -4.916, -44.481, 26.7572, -5.1514, -44.446598, 23.480999, -3.6625998, -40.6287, 23.577, -4.916, -44.481, 26.7572, -5.1514, -44.446598, 26.7572, -3.9166, -40.6287, 23.480999, -3.6625998, -40.6287, 23.808699, -10.464399, -68.371895, 26.7572, -9.876599, -63.426796, 23.8148, -9.6761, -63.593796, 23.808699, -10.464399, -68.371895, 26.7572, -10.693, -68.371895, 26.7572, -9.876599, -63.426796, 26.7572, -9.876599, -63.426796, 23.819199, -9.111899, -60.1742, 23.8148, -9.6761, -63.593796, 26.7572, -9.876599, -63.426796, 26.7572, -9.312699, -60.011097, 23.819199, -9.111899, -60.1742, 26.7572, -9.312699, -60.011097, 23.8225, -8.6786995, -57.548298, 23.819199, -9.111899, -60.1742, 26.7572, -9.312699, -60.011097, 26.7572, -8.881, -57.3956, 23.8225, -8.6786995, -57.548298, 26.7572, -8.881, -57.3956, 23.826399, -8.175799, -54.500298, 23.8225, -8.6786995, -57.548298, 26.7572, -8.881, -57.3956, 26.7572, -8.403, -54.500298, 23.826399, -8.175799, -54.500298, 23.480999, -3.6625998, -40.6287, 26.7572, -3.2101, -37.2077, 23.4646, -2.985, -37.2919, 23.480999, -3.6625998, -40.6287, 26.7572, -3.9166, -40.6287, 26.7572, -3.2101, -37.2077, 23.412498, -8.1437, -54.500298, 20.1675, -7.1071997, -51.295498, 20.1975, -8.1437, -54.500298, 20.1675, -7.1071997, -51.295498, 23.412498, -8.1437, -54.500298, 23.826399, -8.175799, -54.500298, 20.1675, -7.1071997, -51.295498, 23.826399, -8.175799, -54.500298, 23.7466, -7.1323, -51.2931, 23.412498, -10.4337, -68.371895, 20.112099, -9.6529, -63.642, 20.0679, -10.4337, -68.371895, 20.112099, -9.6529, -63.642, 23.412498, -10.4337, -68.371895, 23.808699, -10.464399, -68.371895, 20.112099, -9.6529, -63.642, 23.808699, -10.464399, -68.371895, 23.8148, -9.6761, -63.593796, 23.412498, -3.6573, -40.6287, 20.0679, -2.9615998, -37.315, 20.0679, -3.6573, -40.6287, 20.0679, -2.9615998, -37.315, 23.412498, -3.6573, -40.6287, 23.480999, -3.6625998, -40.6287, 20.0679, -2.9615998, -37.315, 23.480999, -3.6625998, -40.6287, 23.4646, -2.985, -37.2919, 16.7232, -3.6573, -40.6287, 20.0679, -2.9615998, -37.315, 16.7232, -2.9859998, -37.3232, 16.7232, -3.6573, -40.6287, 20.0679, -3.6573, -40.6287, 20.0679, -2.9615998, -37.315, 13.378599, -3.6573, -40.6287, 16.7232, -2.9859998, -37.3232, 13.378599, -2.9889998, -37.3379, 13.378599, -3.6573, -40.6287, 16.7232, -3.6573, -40.6287, 16.7232, -2.9859998, -37.3232, 16.7232, -2.9859998, -37.3232, 13.378599, -2.3234, -34.0607, 13.378599, -2.9889998, -37.3379, 16.7232, -2.9859998, -37.3232, 16.7232, -2.3191, -34.0398, 13.378599, -2.3234, -34.0607, 16.7232, -2.3191, -34.0398, 13.378599, -1.582, -30.410397, 13.378599, -2.3234, -34.0607, 16.7232, -2.3191, -34.0398, 16.7232, -1.5709, -30.3554, 13.378599, -1.582, -30.410397, 10.033899, -3.6573, -40.6287, 13.378599, -2.9889998, -37.3379, 10.033899, -2.9973998, -37.350998, 10.033899, -3.6573, -40.6287, 13.378599, -3.6573, -40.6287, 13.378599, -2.9889998, -37.3379, 6.6893, -3.6573, -40.6287, 10.033899, -2.9973998, -37.350998, 6.6893, -3.0333998, -37.355698, 6.6893, -3.6573, -40.6287, 10.033899, -3.6573, -40.6287, 10.033899, -2.9973998, -37.350998, 3.3446, -3.6573, -40.6287, 6.6893, -3.0333998, -37.355698, 3.3446, -3.0307, -37.3722, 3.3446, -3.6573, -40.6287, 6.6893, -3.6573, -40.6287, 6.6893, -3.0333998, -37.355698, 0, -3.6573, -40.6287, 3.3446, -3.0307, -37.3722, 0, -3.0496, -37.3821, 0, -3.6573, -40.6287, 3.3446, -3.6573, -40.6287, 3.3446, -3.0307, -37.3722, -3.297, -3.6573, -40.6287, 0, -3.0496, -37.3821, -3.3081002, -3.0523, -37.396698, -3.297, -3.6573, -40.6287, 0, -3.6573, -40.6287, 0, -3.0496, -37.3821, -3.3081002, -3.0523, -37.396698, -3.3446002, -3.6573, -40.6287, -3.297, -3.6573, -40.6287, -3.3081002, -3.0523, -37.396698, -6.6243997, -3.6573, -40.6287, -3.3446002, -3.6573, -40.6287, -6.6243997, -3.6573, -40.6287, -3.3081002, -3.0523, -37.396698, -6.6394, -3.0551, -37.4115, -6.6547, -2.4452, -34.1534, -6.6394, -3.0551, -37.4115, -3.3081002, -3.0523, -37.396698, 0, -3.0496, -37.3821, -3.3193002, -2.4413, -34.1324, -3.3081002, -3.0523, -37.396698, -6.6547, -2.4452, -34.1534, -3.3081002, -3.0523, -37.396698, -3.3193002, -2.4413, -34.1324, -6.6709, -1.7958, -30.683697, -6.6547, -2.4452, -34.1534, -3.3193002, -2.4413, -34.1324, 0, -3.0496, -37.3821, 0, -2.4373999, -34.1115, -3.3193002, -2.4413, -34.1324, -6.6709, -1.7958, -30.683697, -3.3193002, -2.4413, -34.1324, -3.3313003, -1.7853999, -30.628498, 0, -2.4373999, -34.1115, -3.3313003, -1.7853999, -30.628498, -3.3193002, -2.4413, -34.1324, 0, -2.4373999, -34.1115, 0, -1.7751, -30.573498, -3.3313003, -1.7853999, -30.628498, -6.6893, -1.458, -26.7572, -9.9988, -2.089, -30.630497, -6.6709, -1.7958, -30.683697, -6.6893, -1.458, -26.7572, -10.033899, -1.8786, -26.7572, -9.9988, -2.089, -30.630497, -10.033899, -1.8786, -26.7572, -13.297701, -2.9966, -30.341599, -9.9988, -2.089, -30.630497, -10.033899, -1.8786, -26.7572, -13.378601, -2.7663999, -26.7572, -13.297701, -2.9966, -30.341599, -13.378601, -2.7663999, -26.7572, -16.6169, -3.2373998, -30.3083, -13.297701, -2.9966, -30.341599, -13.378601, -2.7663999, -26.7572, -16.7232, -3.0929, -26.7572, -16.6169, -3.2373998, -30.3083, -16.7232, -3.0929, -26.7572, -19.9439, -3.2396998, -30.366299, -16.6169, -3.2373998, -30.3083, -19.9439, -3.2396998, -30.366299, -16.5094, -3.3834999, -33.898598, -16.6169, -3.2373998, -30.3083, -16.7232, -3.0929, -26.7572, -20.0679, -3.0929, -26.7572, -19.9439, -3.2396998, -30.366299, -19.9439, -3.2396998, -30.366299, -19.8217, -3.3844, -33.920498, -16.5094, -3.3834999, -33.898598, -20.0679, -3.0929, -26.7572, -23.2703, -3.2421, -30.424297, -19.9439, -3.2396998, -30.366299, -23.2703, -3.2421, -30.424297, -19.8217, -3.3844, -33.920498, -19.9439, -3.2396998, -30.366299, -20.0679, -3.0929, -26.7572, -23.412498, -3.0929, -26.7572, -23.2703, -3.2421, -30.424297, -19.8217, -3.3844, -33.920498, -16.4072, -3.5224, -37.3144, -16.5094, -3.3834999, -33.898598, -23.412498, -3.0929, -26.7572, -26.7572, -3.2445998, -30.4851, -23.2703, -3.2421, -30.424297, -23.412498, -3.0929, -26.7572, -26.7572, -3.0929, -26.7572, -26.7572, -3.2445998, -30.4851, -26.7572, -3.2445998, -30.4851, -23.1339, -3.3852, -33.942398, -23.2703, -3.2421, -30.424297, -23.2703, -3.2421, -30.424297, -23.1339, -3.3852, -33.942398, -19.8217, -3.3844, -33.920498, -26.7572, -3.2445998, -30.4851, -26.7572, -3.3862, -33.9664, -23.1339, -3.3852, -33.942398, -16.4072, -3.5224, -37.3144, -16.7232, -3.6573, -40.6287, -16.3079, -3.6573, -40.6287, -16.4072, -3.5224, -37.3144, -19.5913, -3.6573, -40.6287, -16.7232, -3.6573, -40.6287, -19.8217, -3.3844, -33.920498, -19.7046, -3.5231, -37.3297, -16.4072, -3.5224, -37.3144, -19.5913, -3.6573, -40.6287, -16.4072, -3.5224, -37.3144, -19.7046, -3.5231, -37.3297, -23.1339, -3.3852, -33.942398, -19.7046, -3.5231, -37.3297, -19.8217, -3.3844, -33.920498, -19.7046, -3.5231, -37.3297, -20.0679, -3.6573, -40.6287, -19.5913, -3.6573, -40.6287, -19.7046, -3.5231, -37.3297, -22.8746, -3.6573, -40.6287, -20.0679, -3.6573, -40.6287, -23.1339, -3.3852, -33.942398, -23.0019, -3.5237, -37.3449, -19.7046, -3.5231, -37.3297, -22.8746, -3.6573, -40.6287, -19.7046, -3.5231, -37.3297, -23.0019, -3.5237, -37.3449, -26.7572, -3.3862, -33.9664, -23.0019, -3.5237, -37.3449, -23.1339, -3.3852, -33.942398, -23.0019, -3.5237, -37.3449, -23.412498, -3.6573, -40.6287, -22.8746, -3.6573, -40.6287, -26.7572, -3.3862, -33.9664, -26.7572, -3.5244, -37.3623, -23.0019, -3.5237, -37.3449, -23.0019, -3.5237, -37.3449, -26.7572, -3.6573, -40.6287, -23.412498, -3.6573, -40.6287, -26.7572, -3.6573, -40.6287, -23.0019, -3.5237, -37.3449, -26.7572, -3.5244, -37.3623, -13.297701, -2.9966, -30.341599, -9.9673, -2.6348999, -34.110798, -9.9988, -2.089, -30.630497, -13.297701, -2.9966, -30.341599, -13.2167, -3.2271, -33.93, -9.9673, -2.6348999, -34.110798, 6.6893, -1.7218, -30.475399, 3.3446, -0.98829997, -26.7572, 3.3446, -1.7156, -30.536999, 6.6893, -1.7218, -30.475399, 6.6893, -1.0129, -26.7572, 3.3446, -0.98829997, -26.7572, 26.7573, -1.7447, -30.111198, 23.412498, -1.1158999, -26.7572, 23.429699, -1.5537, -30.243198, 26.7573, -1.7447, -30.111198, 26.7573, -1.3222, -26.7572, 23.412498, -1.1158999, -26.7572, -9.9988, -2.089, -30.630497, -6.6547, -2.4452, -34.1534, -6.6709, -1.7958, -30.683697, -9.9988, -2.089, -30.630497, -9.9673, -2.6348999, -34.110798, -6.6547, -2.4452, -34.1534, 10.033899, -1.61, -30.458897, 6.6893, -1.0129, -26.7572, 6.6893, -1.7218, -30.475399, 10.033899, -1.61, -30.458897, 10.033899, -0.8648, -26.7572, 6.6893, -1.0129, -26.7572, 3.3446, -1.7156, -30.536999, 0, -1.0608, -26.7572, 0, -1.7751, -30.573498, 3.3446, -1.7156, -30.536999, 3.3446, -0.98829997, -26.7572, 0, -1.0608, -26.7572, 23.429699, -1.5537, -30.243198, 20.0679, -1.1922, -26.7572, 20.0679, -1.4942, -30.325497, 23.429699, -1.5537, -30.243198, 23.412498, -1.1158999, -26.7572, 20.0679, -1.1922, -26.7572, 16.7232, -1.5709, -30.3554, 16.7232, -1.3382, -26.7572, 13.378599, -1.582, -30.410397, 13.378599, -1.582, -30.410397, 10.033899, -0.8648, -26.7572, 10.033899, -1.61, -30.458897, 13.378599, -1.582, -30.410397, 13.378599, -0.8401, -26.7572, 10.033899, -0.8648, -26.7572, 0, -1.7751, -30.573498, -3.3446002, -1.458, -26.7572, -3.3313003, -1.7853999, -30.628498, 0, -1.7751, -30.573498, 0, -1.0608, -26.7572, -3.3446002, -1.458, -26.7572, 20.0679, -1.4942, -30.325497, 16.7232, -1.3382, -26.7572, 16.7232, -1.5709, -30.3554, 20.0679, -1.4942, -30.325497, 20.0679, -1.1922, -26.7572, 16.7232, -1.3382, -26.7572, -16.6169, -3.2373998, -30.3083, -13.2167, -3.2271, -33.93, -13.297701, -2.9966, -30.341599, -16.6169, -3.2373998, -30.3083, -16.5094, -3.3834999, -33.898598, -13.2167, -3.2271, -33.93, 6.6893, -2.4083, -34.0766, 3.3446, -1.7156, -30.536999, 3.3446, -2.4013999, -34.1014, 6.6893, -2.4083, -34.0766, 6.6893, -1.7218, -30.475399, 3.3446, -1.7156, -30.536999, 26.7572, -2.5274, -33.9013, 23.429699, -1.5537, -30.243198, 23.4483, -2.3157, -33.9959, 26.7572, -2.5274, -33.9013, 26.7573, -1.7447, -30.111198, 23.429699, -1.5537, -30.243198, 23.4483, -2.3157, -33.9959, 20.0679, -1.4942, -30.325497, 20.0679, -2.2726, -34.0333, 23.4483, -2.3157, -33.9959, 23.429699, -1.5537, -30.243198, 20.0679, -1.4942, -30.325497, -9.9673, -2.6348999, -34.110798, -6.6394, -3.0551, -37.4115, -6.6547, -2.4452, -34.1534, -9.9673, -2.6348999, -34.110798, -9.9375, -3.1504998, -37.3978, -6.6394, -3.0551, -37.4115, 3.3446, -2.4013999, -34.1014, 0, -1.7751, -30.573498, 0, -2.4373999, -34.1115, 3.3446, -2.4013999, -34.1014, 3.3446, -1.7156, -30.536999, 0, -1.7751, -30.573498, -16.5094, -3.3834999, -33.898598, -13.1401005, -3.445, -37.3228, -13.2167, -3.2271, -33.93, -16.5094, -3.3834999, -33.898598, -16.4072, -3.5224, -37.3144, -13.1401005, -3.445, -37.3228, 10.033899, -2.3385, -34.078, 6.6893, -1.7218, -30.475399, 6.6893, -2.4083, -34.0766, 10.033899, -2.3385, -34.078, 10.033899, -1.61, -30.458897, 6.6893, -1.7218, -30.475399, 13.378599, -2.3234, -34.0607, 10.033899, -1.61, -30.458897, 10.033899, -2.3385, -34.078, 13.378599, -2.3234, -34.0607, 13.378599, -1.582, -30.410397, 10.033899, -1.61, -30.458897, 20.0679, -2.2726, -34.0333, 16.7232, -1.5709, -30.3554, 16.7232, -2.3191, -34.0398, 20.0679, -2.2726, -34.0333, 20.0679, -1.4942, -30.325497, 16.7232, -1.5709, -30.3554, -13.2167, -3.2271, -33.93, -9.9375, -3.1504998, -37.3978, -9.9673, -2.6348999, -34.110798, -13.2167, -3.2271, -33.93, -13.1401005, -3.445, -37.3228, -9.9375, -3.1504998, -37.3978, -16.3079, -3.6573, -40.6287, -13.1401005, -3.445, -37.3228, -16.4072, -3.5224, -37.3144, -13.1401005, -3.445, -37.3228, -16.3079, -3.6573, -40.6287, -13.378601, -3.6573, -40.6287, -13.1401005, -3.445, -37.3228, -13.378601, -3.6573, -40.6287, -13.0655, -3.6573, -40.6287, -13.0655, -3.6573, -40.6287, -9.9375, -3.1504998, -37.3978, -13.1401005, -3.445, -37.3228, -9.9375, -3.1504998, -37.3978, -13.0655, -3.6573, -40.6287, -10.033899, -3.6573, -40.6287, -9.9375, -3.1504998, -37.3978, -10.033899, -3.6573, -40.6287, -9.908199, -3.6573, -40.6287, 6.6893, -3.0333998, -37.355698, 3.3446, -2.4013999, -34.1014, 3.3446, -3.0307, -37.3722, 6.6893, -3.0333998, -37.355698, 6.6893, -2.4083, -34.0766, 3.3446, -2.4013999, -34.1014, 10.033899, -2.9973998, -37.350998, 6.6893, -2.4083, -34.0766, 6.6893, -3.0333998, -37.355698, 10.033899, -2.9973998, -37.350998, 10.033899, -2.3385, -34.078, 6.6893, -2.4083, -34.0766, 3.3446, -3.0307, -37.3722, 0, -2.4373999, -34.1115, 0, -3.0496, -37.3821, 3.3446, -3.0307, -37.3722, 3.3446, -2.4013999, -34.1014, 0, -2.4373999, -34.1115, 23.4646, -2.985, -37.2919, 20.0679, -2.2726, -34.0333, 20.0679, -2.9615998, -37.315, 23.4646, -2.985, -37.2919, 23.4483, -2.3157, -33.9959, 20.0679, -2.2726, -34.0333, 26.7572, -3.2101, -37.2077, 23.4483, -2.3157, -33.9959, 23.4646, -2.985, -37.2919, 26.7572, -3.2101, -37.2077, 26.7572, -2.5274, -33.9013, 23.4483, -2.3157, -33.9959, 13.378599, -2.9889998, -37.3379, 10.033899, -2.3385, -34.078, 10.033899, -2.9973998, -37.350998, 13.378599, -2.9889998, -37.3379, 13.378599, -2.3234, -34.0607, 10.033899, -2.3385, -34.078, 20.0679, -2.9615998, -37.315, 16.7232, -2.3191, -34.0398, 16.7232, -2.9859998, -37.3232, 20.0679, -2.9615998, -37.315, 20.0679, -2.2726, -34.0333, 16.7232, -2.3191, -34.0398, -9.908199, -3.6573, -40.6287, -6.6394, -3.0551, -37.4115, -9.9375, -3.1504998, -37.3978, -6.6394, -3.0551, -37.4115, -9.908199, -3.6573, -40.6287, -6.6893, -3.6573, -40.6287, -6.6394, -3.0551, -37.4115, -6.6893, -3.6573, -40.6287, -6.6243997, -3.6573, -40.6287, 23.480999, -3.6625998, -40.6287, 20.103699, -4.8978, -44.4642, 23.577, -4.916, -44.481, 20.103699, -4.8978, -44.4642, 23.480999, -3.6625998, -40.6287, 23.412498, -3.6573, -40.6287, 20.103699, -4.8978, -44.4642, 23.412498, -3.6573, -40.6287, 20.0679, -3.6573, -40.6287, 23.6614, -6.0197997, -47.8736, 20.103699, -4.8978, -44.4642, 20.135399, -5.9964, -47.861, 23.6614, -6.0197997, -47.8736, 23.577, -4.916, -44.481, 20.103699, -4.8978, -44.4642, 23.7466, -7.1323, -51.2931, 20.135399, -5.9964, -47.861, 20.1675, -7.1071997, -51.295498, 23.7466, -7.1323, -51.2931, 23.6614, -6.0197997, -47.8736, 20.135399, -5.9964, -47.861, 23.826399, -8.175799, -54.500298, 20.1689, -8.6489, -57.5607, 23.8225, -8.6786995, -57.548298, 20.1689, -8.6489, -57.5607, 23.826399, -8.175799, -54.500298, 23.412498, -8.1437, -54.500298, 20.1689, -8.6489, -57.5607, 23.412498, -8.1437, -54.500298, 20.1975, -8.1437, -54.500298, 23.819199, -9.111899, -60.1742, 20.1689, -8.6489, -57.5607, 20.1442, -9.085899, -60.207497, 23.819199, -9.111899, -60.1742, 23.8225, -8.6786995, -57.548298, 20.1689, -8.6489, -57.5607, 23.8148, -9.6761, -63.593796, 20.1442, -9.085899, -60.207497, 20.112099, -9.6529, -63.642, 23.8148, -9.6761, -63.593796, 23.819199, -9.111899, -60.1742, 20.1442, -9.085899, -60.207497, -26.7572, -10.4337, -70.9319, -23.4177, -10.4499, -73.721596, -23.4177, -10.4499, -70.8944, -23.4177, -10.4499, -73.721596, -20.0768, -10.466, -70.856895, -23.4177, -10.4499, -70.8944, -26.7572, -10.4337, -70.9319, -26.7572, -10.4337, -73.7615, -23.4177, -10.4499, -73.721596, -23.4177, -10.4499, -73.721596, -20.0768, -10.466, -73.6818, -20.0768, -10.466, -70.856895, -20.0768, -10.466, -73.6818, -16.734499, -10.4822, -70.8193, -20.0768, -10.466, -70.856895, -26.7572, -10.4337, -73.7615, -23.4177, -10.4499, -76.5937, -23.4177, -10.4499, -73.721596, -23.4177, -10.4499, -76.5937, -20.0768, -10.466, -73.6818, -23.4177, -10.4499, -73.721596, -26.7572, -10.4337, -73.7615, -26.7572, -10.4337, -76.6203, -23.4177, -10.4499, -76.5937, -26.7572, -10.4337, -76.6203, -23.4177, -10.4499, -79.1102, -23.4177, -10.4499, -76.5937, -26.7572, -10.4337, -76.6203, -26.7572, -10.4337, -79.0804, -23.4177, -10.4499, -79.1102, -23.4177, -10.4499, -79.1102, -20.0768, -10.466, -76.567, -23.4177, -10.4499, -76.5937, -23.4177, -10.4499, -76.5937, -20.0768, -10.466, -76.567, -20.0768, -10.466, -73.6818, -23.4177, -10.4499, -79.1102, -20.0768, -10.466, -79.14, -20.0768, -10.466, -76.567, -20.0768, -10.466, -73.6818, -16.734499, -10.4822, -73.6419, -16.734499, -10.4822, -70.8193, -20.0768, -10.466, -76.567, -16.734499, -10.4822, -73.6419, -20.0768, -10.466, -73.6818, -16.734499, -10.4822, -73.6419, -13.3908, -10.4984, -70.7818, -16.734499, -10.4822, -70.8193, -20.0768, -10.466, -79.14, -16.734499, -10.4822, -76.5403, -20.0768, -10.466, -76.567, -20.0768, -10.466, -76.567, -16.734499, -10.4822, -76.5403, -16.734499, -10.4822, -73.6419, -20.0768, -10.466, -79.14, -16.734499, -10.4822, -79.1699, -16.734499, -10.4822, -76.5403, -16.734499, -10.4822, -73.6419, -13.3909, -10.4984, -73.602, -13.3908, -10.4984, -70.7818, -16.734499, -10.4822, -76.5403, -13.3909, -10.4984, -73.602, -16.734499, -10.4822, -73.6419, -13.3909, -10.4984, -73.602, -10.045801, -10.5146, -70.7442, -13.3908, -10.4984, -70.7818, -16.734499, -10.4822, -79.1699, -13.3909, -10.4984, -76.513596, -16.734499, -10.4822, -76.5403, -16.734499, -10.4822, -76.5403, -13.3909, -10.4984, -76.513596, -13.3909, -10.4984, -73.602, -16.734499, -10.4822, -79.1699, -13.3909, -10.4984, -79.1997, -13.3909, -10.4984, -76.513596, -13.3909, -10.4984, -73.602, -10.045801, -10.5146, -73.562195, -10.045801, -10.5146, -70.7442, -13.3909, -10.4984, -76.513596, -10.045801, -10.5146, -73.562195, -13.3909, -10.4984, -73.602, -10.045801, -10.5146, -73.562195, -6.6993003, -10.5309, -70.7066, -10.045801, -10.5146, -70.7442, -13.3909, -10.4984, -79.1997, -10.045801, -10.5146, -76.4869, -13.3909, -10.4984, -76.513596, -13.3909, -10.4984, -76.513596, -10.045801, -10.5146, -76.4869, -10.045801, -10.5146, -73.562195, -13.3909, -10.4984, -79.1997, -10.045801, -10.5146, -79.2295, -10.045801, -10.5146, -76.4869, -10.045801, -10.5146, -73.562195, -6.6993003, -10.5309, -73.5222, -6.6993003, -10.5309, -70.7066, -10.045801, -10.5146, -76.4869, -6.6993003, -10.5309, -73.5222, -10.045801, -10.5146, -73.562195, -6.6993003, -10.5309, -73.5222, -3.3514, -10.5471, -70.669, -6.6993003, -10.5309, -70.7066, -10.045801, -10.5146, -79.2295, -6.6993003, -10.5309, -76.4602, -10.045801, -10.5146, -76.4869, -10.045801, -10.5146, -76.4869, -6.6993003, -10.5309, -76.4602, -6.6993003, -10.5309, -73.5222, -10.045801, -10.5146, -79.2295, -6.6993003, -10.5309, -79.2594, -6.6993003, -10.5309, -76.4602, -6.6993003, -10.5309, -73.5222, -3.3514, -10.5471, -73.4823, -3.3514, -10.5471, -70.669, -6.6993003, -10.5309, -76.4602, -3.3514, -10.5471, -73.4823, -6.6993003, -10.5309, -73.5222, -3.3514, -10.5471, -73.4823, -0.0020999908, -10.5633, -70.6314, -3.3514, -10.5471, -70.669, -6.6993003, -10.5309, -79.2594, -3.3514, -10.5471, -76.433395, -6.6993003, -10.5309, -76.4602, -6.6993003, -10.5309, -76.4602, -3.3514, -10.5471, -76.433395, -3.3514, -10.5471, -73.4823, -6.6993003, -10.5309, -79.2594, -3.3514, -10.5471, -79.2893, -3.3514, -10.5471, -76.433395, -3.3514, -10.5471, -73.4823, -0.0020999908, -10.5633, -73.4424, -0.0020999908, -10.5633, -70.6314, -3.3514, -10.5471, -76.433395, -0.0020999908, -10.5633, -73.4424, -3.3514, -10.5471, -73.4823, -0.0020999908, -10.5633, -73.4424, 3.3486998, -10.579599, -70.5937, -0.0020999908, -10.5633, -70.6314, -3.3514, -10.5471, -79.2893, -0.0020999908, -10.5633, -76.4067, -3.3514, -10.5471, -76.433395, -3.3514, -10.5471, -76.433395, -0.0020999908, -10.5633, -76.4067, -0.0020999908, -10.5633, -73.4424, -3.3514, -10.5471, -79.2893, -0.0020999908, -10.5633, -79.3192, -0.0020999908, -10.5633, -76.4067, -0.0020999908, -10.5633, -73.4424, 3.3486998, -10.579599, -73.4024, 3.3486998, -10.579599, -70.5937, -0.0020999908, -10.5633, -76.4067, 3.3486998, -10.579599, -73.4024, -0.0020999908, -10.5633, -73.4424, 3.3486998, -10.579599, -73.4024, 6.7008, -10.595799, -70.5561, 3.3486998, -10.579599, -70.5937, -0.0020999908, -10.5633, -79.3192, 3.3486998, -10.579599, -76.3799, -0.0020999908, -10.5633, -76.4067, -0.0020999908, -10.5633, -76.4067, 3.3486998, -10.579599, -76.3799, 3.3486998, -10.579599, -73.4024, -0.0020999908, -10.5633, -79.3192, 3.3486998, -10.579599, -79.3491, 3.3486998, -10.579599, -76.3799, 3.3486998, -10.579599, -73.4024, 6.7008, -10.595799, -73.3624, 6.7008, -10.595799, -70.5561, 3.3486998, -10.579599, -76.3799, 6.7008, -10.595799, -73.3624, 3.3486998, -10.579599, -73.4024, 6.7008, -10.595799, -73.3624, 10.054299, -10.6119995, -70.518394, 6.7008, -10.595799, -70.5561, 3.3486998, -10.579599, -79.3491, 6.7008, -10.595799, -76.353195, 3.3486998, -10.579599, -76.3799, 3.3486998, -10.579599, -76.3799, 6.7008, -10.595799, -76.353195, 6.7008, -10.595799, -73.3624, 3.3486998, -10.579599, -79.3491, 6.7008, -10.595799, -79.379, 6.7008, -10.595799, -76.353195, 6.7008, -10.595799, -73.3624, 10.054299, -10.6119995, -73.322395, 10.054299, -10.6119995, -70.518394, 6.7008, -10.595799, -76.353195, 10.054299, -10.6119995, -73.322395, 6.7008, -10.595799, -73.3624, 10.054299, -10.6119995, -73.322395, 13.4093, -10.6283, -70.4807, 10.054299, -10.6119995, -70.518394, 6.7008, -10.595799, -79.379, 10.054299, -10.6119995, -76.3264, 6.7008, -10.595799, -76.353195, 6.7008, -10.595799, -76.353195, 10.054299, -10.6119995, -76.3264, 10.054299, -10.6119995, -73.322395, 6.7008, -10.595799, -79.379, 10.054299, -10.6119995, -79.4089, 10.054299, -10.6119995, -76.3264, 10.054299, -10.6119995, -73.322395, 13.4093, -10.6283, -73.282394, 13.4093, -10.6283, -70.4807, 10.054299, -10.6119995, -76.3264, 13.4093, -10.6283, -73.282394, 10.054299, -10.6119995, -73.322395, 13.4093, -10.6283, -73.282394, 16.7656, -10.6446, -70.443, 13.4093, -10.6283, -70.4807, 10.054299, -10.6119995, -79.4089, 13.4093, -10.6283, -76.2996, 10.054299, -10.6119995, -76.3264, 10.054299, -10.6119995, -76.3264, 13.4093, -10.6283, -76.2996, 13.4093, -10.6283, -73.282394, 10.054299, -10.6119995, -79.4089, 13.4093, -10.6283, -79.4388, 13.4093, -10.6283, -76.2996, 13.4093, -10.6283, -73.282394, 16.7656, -10.6446, -73.2424, 16.7656, -10.6446, -70.443, 13.4093, -10.6283, -76.2996, 16.7656, -10.6446, -73.2424, 13.4093, -10.6283, -73.282394, 16.7656, -10.6446, -73.2424, 20.1234, -10.6608, -70.4053, 16.7656, -10.6446, -70.443, 13.4093, -10.6283, -79.4388, 16.7656, -10.6446, -76.2728, 13.4093, -10.6283, -76.2996, 13.4093, -10.6283, -76.2996, 16.7656, -10.6446, -76.2728, 16.7656, -10.6446, -73.2424, 13.4093, -10.6283, -79.4388, 16.7656, -10.6446, -79.468796, 16.7656, -10.6446, -76.2728, 16.7656, -10.6446, -73.2424, 20.1234, -10.6608, -73.2023, 20.1234, -10.6608, -70.4053, 16.7656, -10.6446, -76.2728, 20.1234, -10.6608, -73.2023, 16.7656, -10.6446, -73.2424, 20.1234, -10.6608, -73.2023, 23.812, -10.6786995, -70.3639, 20.1234, -10.6608, -70.4053, 16.7656, -10.6446, -79.468796, 20.1234, -10.6608, -76.245995, 16.7656, -10.6446, -76.2728, 16.7656, -10.6446, -76.2728, 20.1234, -10.6608, -76.245995, 20.1234, -10.6608, -73.2023, 16.7656, -10.6446, -79.468796, 20.1234, -10.6608, -79.498695, 20.1234, -10.6608, -76.245995, 20.1234, -10.6608, -73.2023, 23.7337, -10.6783, -73.1593, 23.812, -10.6786995, -70.3639, 20.1234, -10.6608, -76.245995, 23.7337, -10.6783, -73.1593, 20.1234, -10.6608, -73.2023, 23.7337, -10.6783, -73.1593, 26.7572, -10.693, -70.330795, 23.812, -10.6786995, -70.3639, 23.7337, -10.6783, -73.1593, 26.7572, -10.693, -73.1232, 26.7572, -10.693, -70.330795, 20.1234, -10.6608, -76.245995, 23.6481, -10.677899, -76.2178, 23.7337, -10.6783, -73.1593, 23.6481, -10.677899, -76.2178, 26.7572, -10.693, -73.1232, 23.7337, -10.6783, -73.1593, 20.1234, -10.6608, -79.498695, 23.6481, -10.677899, -76.2178, 20.1234, -10.6608, -76.245995, 23.6481, -10.677899, -76.2178, 26.7572, -10.693, -76.193, 26.7572, -10.693, -73.1232, 20.1234, -10.6608, -79.498695, 23.555399, -10.6775, -79.529396, 23.6481, -10.677899, -76.2178, 23.555399, -10.6775, -79.529396, 26.7572, -10.693, -76.193, 23.6481, -10.677899, -76.2178, 23.555399, -10.6775, -79.529396, 26.7572, -10.693, -79.5579, 26.7572, -10.693, -76.193, -26.7572, -10.4337, -79.0804, -23.412498, -10.4337, -82.2434, -23.4177, -10.4499, -79.1102, -26.7572, -10.4337, -79.0804, -26.7572, -10.4337, -82.2434, -23.412498, -10.4337, -82.2434, 23.412498, -10.4337, -82.2434, 26.7572, -10.693, -79.5579, 23.555399, -10.6775, -79.529396, 23.412498, -10.4337, -82.2434, 26.7572, -10.693, -82.2434, 26.7572, -10.693, -79.5579, 23.812, -10.6786995, -70.3639, 26.7572, -10.693, -68.371895, 23.808699, -10.464399, -68.371895, 23.812, -10.6786995, -70.3639, 26.7572, -10.693, -70.330795, 26.7572, -10.693, -68.371895, 20.0679, -10.4337, -82.2434, 23.555399, -10.6775, -79.529396, 20.1234, -10.6608, -79.498695, 20.0679, -10.4337, -82.2434, 23.412498, -10.4337, -82.2434, 23.555399, -10.6775, -79.529396, 23.812, -10.6786995, -70.3639, 20.0679, -10.4337, -68.371895, 20.1234, -10.6608, -70.4053, 20.0679, -10.4337, -68.371895, 23.812, -10.6786995, -70.3639, 23.808699, -10.464399, -68.371895, 20.0679, -10.4337, -68.371895, 23.808699, -10.464399, -68.371895, 23.412498, -10.4337, -68.371895, 16.7232, -10.4337, -82.2434, 20.1234, -10.6608, -79.498695, 16.7656, -10.6446, -79.468796, 16.7232, -10.4337, -82.2434, 20.0679, -10.4337, -82.2434, 20.1234, -10.6608, -79.498695, 16.7656, -10.6446, -70.443, 20.0679, -10.4337, -68.371895, 16.7232, -10.4337, -68.371895, 16.7656, -10.6446, -70.443, 20.1234, -10.6608, -70.4053, 20.0679, -10.4337, -68.371895, 13.378599, -10.4337, -82.2434, 16.7656, -10.6446, -79.468796, 13.4093, -10.6283, -79.4388, 13.378599, -10.4337, -82.2434, 16.7232, -10.4337, -82.2434, 16.7656, -10.6446, -79.468796, 13.4093, -10.6283, -70.4807, 16.7232, -10.4337, -68.371895, 13.378599, -10.4337, -68.371895, 13.4093, -10.6283, -70.4807, 16.7656, -10.6446, -70.443, 16.7232, -10.4337, -68.371895, 10.033899, -10.4337, -82.2434, 13.4093, -10.6283, -79.4388, 10.054299, -10.6119995, -79.4089, 10.033899, -10.4337, -82.2434, 13.378599, -10.4337, -82.2434, 13.4093, -10.6283, -79.4388, 10.054299, -10.6119995, -70.518394, 13.378599, -10.4337, -68.371895, 10.033899, -10.4337, -68.371895, 10.054299, -10.6119995, -70.518394, 13.4093, -10.6283, -70.4807, 13.378599, -10.4337, -68.371895, 6.6893, -10.4337, -82.2434, 10.054299, -10.6119995, -79.4089, 6.7008, -10.595799, -79.379, 6.6893, -10.4337, -82.2434, 10.033899, -10.4337, -82.2434, 10.054299, -10.6119995, -79.4089, 6.7008, -10.595799, -70.5561, 10.033899, -10.4337, -68.371895, 6.6893, -10.4337, -68.371895, 6.7008, -10.595799, -70.5561, 10.054299, -10.6119995, -70.518394, 10.033899, -10.4337, -68.371895, 3.3446, -10.4337, -82.2434, 6.7008, -10.595799, -79.379, 3.3486998, -10.579599, -79.3491, 3.3446, -10.4337, -82.2434, 6.6893, -10.4337, -82.2434, 6.7008, -10.595799, -79.379, 3.3486998, -10.579599, -70.5937, 6.6893, -10.4337, -68.371895, 3.3446, -10.4337, -68.371895, 3.3486998, -10.579599, -70.5937, 6.7008, -10.595799, -70.5561, 6.6893, -10.4337, -68.371895, 0, -10.4337, -82.2434, 3.3486998, -10.579599, -79.3491, -0.0020999908, -10.5633, -79.3192, 0, -10.4337, -82.2434, 3.3446, -10.4337, -82.2434, 3.3486998, -10.579599, -79.3491, -0.0020999908, -10.5633, -70.6314, 3.3446, -10.4337, -68.371895, 0, -10.4337, -68.371895, -0.0020999908, -10.5633, -70.6314, 3.3486998, -10.579599, -70.5937, 3.3446, -10.4337, -68.371895, -3.3446002, -10.4337, -82.2434, -0.0020999908, -10.5633, -79.3192, -3.3514, -10.5471, -79.2893, -3.3446002, -10.4337, -82.2434, 0, -10.4337, -82.2434, -0.0020999908, -10.5633, -79.3192, -3.3514, -10.5471, -70.669, -6.6893, -10.4337, -68.371895, -6.6993003, -10.5309, -70.7066, -3.3514, -10.5471, -70.669, -3.3446002, -10.4337, -68.371895, -6.6893, -10.4337, -68.371895, -6.6893, -10.4337, -68.371895, -10.045801, -10.5146, -70.7442, -6.6993003, -10.5309, -70.7066, -6.6893, -10.4337, -68.371895, -10.033899, -10.4337, -68.371895, -10.045801, -10.5146, -70.7442, -6.6893, -10.4337, -82.2434, -3.3514, -10.5471, -79.2893, -6.6993003, -10.5309, -79.2594, -6.6893, -10.4337, -82.2434, -3.3446002, -10.4337, -82.2434, -3.3514, -10.5471, -79.2893, -10.033899, -10.4337, -82.2434, -6.6993003, -10.5309, -79.2594, -10.045801, -10.5146, -79.2295, -10.033899, -10.4337, -82.2434, -6.6893, -10.4337, -82.2434, -6.6993003, -10.5309, -79.2594, -10.033899, -10.4337, -68.371895, -13.3908, -10.4984, -70.7818, -10.045801, -10.5146, -70.7442, -10.033899, -10.4337, -68.371895, -13.378601, -10.4337, -68.371895, -13.3908, -10.4984, -70.7818, -13.378601, -10.4337, -82.2434, -10.045801, -10.5146, -79.2295, -13.3909, -10.4984, -79.1997, -13.378601, -10.4337, -82.2434, -10.033899, -10.4337, -82.2434, -10.045801, -10.5146, -79.2295, -13.378601, -10.4337, -68.371895, -16.734499, -10.4822, -70.8193, -13.3908, -10.4984, -70.7818, -13.378601, -10.4337, -68.371895, -16.7232, -10.4337, -68.371895, -16.734499, -10.4822, -70.8193, -16.7232, -10.4337, -68.371895, -20.0768, -10.466, -70.856895, -16.734499, -10.4822, -70.8193, -16.7232, -10.4337, -68.371895, -20.0679, -10.4337, -68.371895, -20.0768, -10.466, -70.856895, -16.7232, -10.4337, -82.2434, -13.3909, -10.4984, -79.1997, -16.734499, -10.4822, -79.1699, -16.7232, -10.4337, -82.2434, -13.378601, -10.4337, -82.2434, -13.3909, -10.4984, -79.1997, -20.0679, -10.4337, -68.371895, -23.4177, -10.4499, -70.8944, -20.0768, -10.466, -70.856895, -20.0679, -10.4337, -68.371895, -23.412498, -10.4337, -68.371895, -23.4177, -10.4499, -70.8944, -20.0679, -10.4337, -82.2434, -16.734499, -10.4822, -79.1699, -20.0768, -10.466, -79.14, -20.0679, -10.4337, -82.2434, -16.7232, -10.4337, -82.2434, -16.734499, -10.4822, -79.1699, -23.412498, -10.4337, -68.371895, -26.7572, -10.4337, -70.9319, -23.4177, -10.4499, -70.8944, -23.412498, -10.4337, -68.371895, -26.7572, -10.4337, -68.371895, -26.7572, -10.4337, -70.9319, -23.412498, -10.4337, -82.2434, -20.0768, -10.466, -79.14, -23.4177, -10.4499, -79.1102, -23.412498, -10.4337, -82.2434, -20.0679, -10.4337, -82.2434, -20.0768, -10.466, -79.14, 13.378599, -1.0746, 20.0679, 13.378599, -2.2719998, 23.3902, 10.033899, -0.501, 23.412498, 20.0679, -1.0746, 13.378601, 16.7232, -1.0746, 16.7232, 16.7232, -1.0746, 13.378601, 16.7232, -1.0746, 16.7232, 15.0508995, -1.0746, 20.0679, 13.378599, -1.0746, 20.0679, 16.7232, -1.0746, 16.7232, 16.7232, -1.8294, 20.0679, 15.0508995, -1.0746, 20.0679, 23.412498, -0.35529998, 10.033899, 23.412498, -1.8294, 13.378601, 20.0679, -1.0746, 13.378601, 10.501598, 0, 20.5087, 6.6893, 0, 20.0679, 10.080299, 0, 20.0679, 10.501598, 0, 20.5087, 6.6893, -0.2814, 23.412498, 6.6893, 0, 20.0679, -20.0679, 0, 13.378601, -16.7232, 0, 13.868401, -16.7232, -0.26209998, 16.7232, -20.0679, 0, 13.378601, -17.213, 0, 13.378601, -16.7232, 0, 13.868401, -16.7232, -0.26209998, 16.7232, -13.378601, 0, 16.7232, -13.378601, 0, 20.0679, -24.056599, 0, 10.033899, -25.9681, -0.2875, 13.378601, -26.0077, -0.2465, 10.033899, -25.9681, -0.2875, 13.378601, -24.056599, 0, 10.033899, -23.412498, 0, 10.6779995, -25.9681, -0.2875, 13.378601, -23.412498, 0, 10.6779995, -23.412498, 0, 13.378601, -23.8078, 0, 6.6893, -26.0077, -0.2465, 10.033899, -25.945799, -0.25779998, 6.6893, -26.0077, -0.2465, 10.033899, -23.8078, 0, 6.6893, -23.489, 0, 9.386499, -26.0077, -0.2465, 10.033899, -23.489, 0, 9.386499, -24.056599, 0, 10.033899, -25.4212, -0.5907, 16.7232, -23.412498, -0.692, 20.0679, -25.174, -0.7391, 20.0679, -25.479, -0.4588, 0.143, -23.412498, 0, 3.3446, -25.869999, -0.2764, 3.3446, -25.479, -0.4588, 0.143, -23.412498, -0.2913, 0.26119995, -23.412498, 0, 3.3446, -25.869999, -0.2764, 3.3446, -23.8078, 0, 6.6893, -25.945799, -0.25779998, 6.6893, -25.869999, -0.2764, 3.3446, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -17.2383, -0.66719997, -19.6364, -20.0679, -0.43429998, -16.7232, -20.0679, -1.7572, -20.0679, -20.0679, -0.43429998, -16.7232, -20.0679, 0, -13.378599, -23.412498, 0, -13.378599, -9.318001, -0.3969, -19.413, -6.6893, -0.9271, -20.0679, -6.6893, 0, -16.7232, -6.6893, 0, -16.7232, -3.3446002, -0.9271, -16.7232, -3.3446002, 0, -13.7127, 16.7232, 0, -20.0679, 20.0679, -0.2837, -20.0679, 20.0679, 0, -16.7232, 13.378599, 0, -23.412498, 16.7232, -0.3105, -23.412498, 16.7232, 0, -20.0679, 20.0679, 0, -16.7232, 23.412498, -0.456, -16.7232, 23.412498, 0, -13.378599, 23.412498, 0, -6.6893, 23.412498, 0, -3.3446002, 22.5396, -0.014599999, -3.3446002, 15.0508995, -0.40039998, 16.7232, 13.378599, 0, 16.7232, 15.0508995, -0.6288, 13.378601, 14.439199, 0, 13.6437, 15.0508995, 0, 10.033899, 15.0508995, -0.6288, 13.378601, 15.0508995, 0, 10.033899, 14.439199, 0, 13.6437, 13.8046, 0, 13.2202, 15.0508995, 0, 10.033899, 13.8046, 0, 13.2202, 13.8046, 0, 10.033899, 3.0140998, 0, -16.7232, 3.3446, -0.3989, -12.9443, 0, 0, -13.378599, -23.412498, -0.692, 20.0679, -25.4212, -0.5907, 16.7232, -23.412498, -0.36479998, 16.7232, -23.412498, -0.692, 20.0679, -20.0679, -0.36479998, 20.0679, -20.0679, -0.5589, 23.412498, -16.7232, -0.36479998, 23.412498, -13.378601, -0.2814, 23.412498, -13.378601, -1.1209999, 26.7572, -20.0679, -0.36479998, 16.7232, -16.7232, -0.26209998, 16.7232, -16.7232, -0.36479998, 20.0679, -16.7232, -0.36479998, 20.0679, -13.378601, 0, 20.0679, -13.378601, -0.2814, 23.412498, -23.412498, -0.7373, 23.412498, -20.0679, -0.5589, 23.412498, -20.0679, -1.1374999, 26.7572, -25.174, -0.7391, 20.0679, -23.412498, -0.692, 20.0679, -23.412498, -0.7373, 23.412498, -20.0679, -0.5589, 23.412498, -16.7232, -0.36479998, 23.412498, -16.7232, -1.1374999, 26.7572, 26.7582, -1.7623999, 10.033899, 26.7582, -1.8294, 13.378601, 23.412498, -1.8294, 13.378601, 13.378599, 0, 16.7232, 14.439199, 0, 13.6437, 15.0508995, -0.6288, 13.378601, 13.378599, 0, 16.7232, 13.700699, 0, 14.602699, 14.439199, 0, 13.6437, 15.0508995, -0.6288, 13.378601, 15.0508995, 0, 10.033899, 16.7232, 0, 10.033899, 15.0508995, -0.40039998, 16.7232, 16.7232, -1.0746, 13.378601, 16.7232, -1.0746, 16.7232, 13.378599, 0, 16.7232, 13.378599, -1.0746, 20.0679, 11.0191, 0, 20.0679, 15.0508995, -0.40039998, 16.7232, 13.378599, -1.0746, 20.0679, 13.378599, 0, 16.7232, 20.0679, -0.2837, -20.0679, 23.412498, -1.0924, -20.0679, 23.412498, -0.456, -16.7232, 26.572199, -0.95549995, -13.659599, 23.412498, 0, -13.378599, 23.412498, -0.456, -16.7232, 13.378599, -0.8401, -26.7572, 16.7232, -1.3382, -26.7572, 16.7232, -0.3105, -23.412498, 16.7232, -0.3105, -23.412498, 20.0679, -0.8222, -23.412498, 20.0679, -0.2837, -20.0679, 16.7232, -1.3382, -26.7572, 13.378599, -0.8401, -26.7572, 13.378599, -1.582, -30.410397, 16.7232, -1.3382, -26.7572, 20.0679, -1.1922, -26.7572, 20.0679, -0.8222, -23.412498, 26.7577, -0.8804, -16.7232, 23.412498, -0.456, -16.7232, 23.412498, -1.0924, -20.0679, 20.0679, -0.8222, -23.412498, 23.412498, -1.0924, -23.412498, 23.412498, -1.0924, -20.0679, 3.3446, -0.3434, -23.412498, 0, -0.9271, -20.0679, 0, -0.9271, -23.412498, -10.033899, -0.9271, -23.412498, -6.6893, -1.3241999, -23.412498, -6.6893, -0.9271, -20.0679, 2.3184, 0, -20.4362, 0, -0.9271, -16.7232, 0, -0.9271, -20.0679, 39.877598, -3.6573, 23.412498, 36.078598, -3.0746999, 26.7572, 36.2121, -2.9357, 23.412498, 39.877598, -3.6573, 23.412498, 39.877598, -3.6573, 26.7572, 36.078598, -3.0746999, 26.7572, 52.997097, -8.1437, 23.412498, 49.5743, -6.9732, 26.7572, 49.5645, -6.9698997, 23.412498, 52.997097, -8.1437, 23.412498, 52.997097, -8.1437, 26.7572, 49.5743, -6.9732, 26.7572, 52.997097, -8.1437, 20.0679, 52.997097, -8.1437, 23.412498, 49.5645, -6.9698997, 23.412498, 49.5743, -6.9732, 26.7572, 46.556797, -5.9413, 23.412498, 49.5645, -6.9698997, 23.412498, 49.5743, -6.9732, 26.7572, 46.577698, -5.9484997, 26.7572, 46.556797, -5.9413, 23.412498, 52.997097, -8.1437, 20.0679, 49.5645, -6.9698997, 23.412498, 49.5547, -6.9665, 20.0679, 52.997097, -8.1437, 16.7232, 52.997097, -8.1437, 20.0679, 49.5547, -6.9665, 20.0679, 52.997097, -8.1437, 16.7232, 49.5547, -6.9665, 20.0679, 49.5449, -6.9631996, 16.7232, 49.5547, -6.9665, 20.0679, 46.5149, -5.927, 16.7232, 49.5449, -6.9631996, 16.7232, 49.5645, -6.9698997, 23.412498, 46.535797, -5.9342, 20.0679, 49.5547, -6.9665, 20.0679, 49.5547, -6.9665, 20.0679, 46.535797, -5.9342, 20.0679, 46.5149, -5.927, 16.7232, 49.5645, -6.9698997, 23.412498, 46.556797, -5.9413, 23.412498, 46.535797, -5.9342, 20.0679, 46.535797, -5.9342, 20.0679, 43.1641, -4.7812, 16.7232, 46.5149, -5.927, 16.7232, 46.535797, -5.9342, 20.0679, 43.1877, -4.7892, 20.0679, 43.1641, -4.7812, 16.7232, 46.556797, -5.9413, 23.412498, 43.1877, -4.7892, 20.0679, 46.535797, -5.9342, 20.0679, 43.1877, -4.7892, 20.0679, 39.877598, -3.6573, 16.7232, 43.1641, -4.7812, 16.7232, 43.1877, -4.7892, 20.0679, 39.877598, -3.6573, 20.0679, 39.877598, -3.6573, 16.7232, 46.556797, -5.9413, 23.412498, 43.2113, -4.7973, 23.412498, 43.1877, -4.7892, 20.0679, 43.2113, -4.7973, 23.412498, 39.877598, -3.6573, 20.0679, 43.1877, -4.7892, 20.0679, 46.577698, -5.9484997, 26.7572, 43.2113, -4.7973, 23.412498, 46.556797, -5.9413, 23.412498, 43.2113, -4.7973, 23.412498, 39.877598, -3.6573, 23.412498, 39.877598, -3.6573, 20.0679, 46.577698, -5.9484997, 26.7572, 43.234898, -4.8054, 26.7572, 43.2113, -4.7973, 23.412498, 43.234898, -4.8054, 26.7572, 39.877598, -3.6573, 23.412498, 43.2113, -4.7973, 23.412498, 43.234898, -4.8054, 26.7572, 39.877598, -3.6573, 26.7572, 39.877598, -3.6573, 23.412498, 39.877598, -3.6573, 20.0679, 36.2121, -2.9357, 23.412498, 36.2075, -2.9348, 20.0679, 39.877598, -3.6573, 20.0679, 39.877598, -3.6573, 23.412498, 36.2121, -2.9357, 23.412498, 39.877598, -3.6573, 16.7232, 39.877598, -3.6573, 20.0679, 36.2075, -2.9348, 20.0679, 39.877598, -3.6573, 16.7232, 36.2075, -2.9348, 20.0679, 36.2028, -2.9338999, 16.7232, 39.877598, -3.7507, 13.378601, 36.2028, -2.9338999, 16.7232, 36.1422, -2.9887998, 13.378601, 39.877598, -3.7507, 13.378601, 39.877598, -3.6573, 16.7232, 36.2028, -2.9338999, 16.7232, 52.997097, -8.1437, 13.378601, 49.5449, -6.9631996, 16.7232, 49.520397, -6.9795, 13.378601, 52.997097, -8.1437, 13.378601, 52.997097, -8.1437, 16.7232, 49.5449, -6.9631996, 16.7232, 39.877598, -3.7507, 10.033899, 36.1422, -2.9887998, 13.378601, 36.153297, -2.972, 10.033899, 39.877598, -3.7507, 10.033899, 39.877598, -3.7507, 13.378601, 36.1422, -2.9887998, 13.378601, 52.997097, -8.1437, 10.033899, 49.520397, -6.9795, 13.378601, 49.510597, -6.9763, 10.033899, 52.997097, -8.1437, 10.033899, 52.997097, -8.1437, 13.378601, 49.520397, -6.9795, 13.378601, 52.997097, -8.1437, 6.6893, 52.997097, -8.1437, 10.033899, 49.510597, -6.9763, 10.033899, 49.520397, -6.9795, 13.378601, 46.4435, -5.9493, 10.033899, 49.510597, -6.9763, 10.033899, 49.520397, -6.9795, 13.378601, 46.4646, -5.9563, 13.378601, 46.4435, -5.9493, 10.033899, 52.997097, -8.1437, 6.6893, 49.510597, -6.9763, 10.033899, 49.500698, -6.973, 6.6893, 52.997097, -8.1437, 3.3446, 52.997097, -8.1437, 6.6893, 49.500698, -6.973, 6.6893, 49.510597, -6.9763, 10.033899, 46.4435, -5.9493, 10.033899, 46.4225, -5.9421997, 6.6893, 49.510597, -6.9763, 10.033899, 46.4225, -5.9421997, 6.6893, 49.500698, -6.973, 6.6893, 46.4646, -5.9563, 13.378601, 43.0695, -4.8195, 10.033899, 46.4435, -5.9493, 10.033899, 46.4646, -5.9563, 13.378601, 43.0932, -4.8275, 13.378601, 43.0695, -4.8195, 10.033899, 43.0932, -4.8275, 13.378601, 39.877598, -3.7507, 10.033899, 43.0695, -4.8195, 10.033899, 43.0932, -4.8275, 13.378601, 39.877598, -3.7507, 13.378601, 39.877598, -3.7507, 10.033899, 43.0695, -4.8195, 10.033899, 39.877598, -3.7507, 10.033899, 39.877598, -3.7507, 6.6893, 46.4435, -5.9493, 10.033899, 43.0695, -4.8195, 10.033899, 43.0458, -4.8115997, 6.6893, 43.0695, -4.8195, 10.033899, 39.877598, -3.7507, 6.6893, 43.0458, -4.8115997, 6.6893, 46.4435, -5.9493, 10.033899, 43.0458, -4.8115997, 6.6893, 46.4225, -5.9421997, 6.6893, 43.0458, -4.8115997, 6.6893, 39.877598, -3.7507, 6.6893, 39.877598, -3.7507, 3.3446, 43.0458, -4.8115997, 6.6893, 39.877598, -3.7507, 3.3446, 43.0221, -4.8036, 3.3446, 46.4225, -5.9421997, 6.6893, 43.0458, -4.8115997, 6.6893, 43.0221, -4.8036, 3.3446, 43.0221, -4.8036, 3.3446, 39.877598, -3.7507, 3.3446, 39.877598, -3.7507, 0, 46.4225, -5.9421997, 6.6893, 43.0221, -4.8036, 3.3446, 46.4015, -5.9351997, 3.3446, 49.500698, -6.973, 6.6893, 46.4225, -5.9421997, 6.6893, 46.4015, -5.9351997, 3.3446, 43.0221, -4.8036, 3.3446, 39.877598, -3.7507, 0, 42.998398, -4.7957, 0, 46.4015, -5.9351997, 3.3446, 43.0221, -4.8036, 3.3446, 42.998398, -4.7957, 0, 42.998398, -4.7957, 0, 39.877598, -3.7507, 0, 39.877598, -3.7507, -3.3446002, 49.500698, -6.973, 6.6893, 46.4015, -5.9351997, 3.3446, 49.4909, -6.9697, 3.3446, 52.997097, -8.1437, 3.3446, 49.500698, -6.973, 6.6893, 49.4909, -6.9697, 3.3446, 52.997097, -8.1437, 0, 52.997097, -8.1437, 3.3446, 49.4909, -6.9697, 3.3446, 49.4909, -6.9697, 3.3446, 46.4015, -5.9351997, 3.3446, 46.380398, -5.9282, 0, 46.4015, -5.9351997, 3.3446, 42.998398, -4.7957, 0, 46.380398, -5.9282, 0, 52.997097, -8.1437, 0, 49.4909, -6.9697, 3.3446, 49.4811, -6.9663997, 0, 49.4909, -6.9697, 3.3446, 46.380398, -5.9282, 0, 49.4811, -6.9663997, 0, 52.997097, -8.1437, -3.3446002, 52.997097, -8.1437, 0, 49.4811, -6.9663997, 0, 42.998398, -4.7957, 0, 39.877598, -3.7507, -3.3446002, 42.974697, -4.7878, -3.3446002, 46.380398, -5.9282, 0, 42.998398, -4.7957, 0, 42.974697, -4.7878, -3.3446002, 42.974697, -4.7878, -3.3446002, 39.877598, -3.7507, -3.3446002, 39.877598, -3.7507, -6.6893, 42.974697, -4.7878, -3.3446002, 39.877598, -3.7507, -6.6893, 42.951, -4.7798, -6.6893, 46.380398, -5.9282, 0, 42.974697, -4.7878, -3.3446002, 46.359398, -5.9210997, -3.3446002, 46.359398, -5.9210997, -3.3446002, 42.974697, -4.7878, -3.3446002, 42.951, -4.7798, -6.6893, 49.4811, -6.9663997, 0, 46.380398, -5.9282, 0, 46.359398, -5.9210997, -3.3446002, 46.359398, -5.9210997, -3.3446002, 42.951, -4.7798, -6.6893, 46.338398, -5.9140997, -6.6893, 52.997097, -8.1437, -3.3446002, 49.4811, -6.9663997, 0, 49.4712, -6.9631, -3.3446002, 49.4811, -6.9663997, 0, 46.359398, -5.9210997, -3.3446002, 49.4712, -6.9631, -3.3446002, 49.4712, -6.9631, -3.3446002, 46.359398, -5.9210997, -3.3446002, 46.338398, -5.9140997, -6.6893, 52.997097, -8.1437, -6.6893, 52.997097, -8.1437, -3.3446002, 49.4712, -6.9631, -3.3446002, 49.4712, -6.9631, -3.3446002, 46.338398, -5.9140997, -6.6893, 49.4614, -6.9598, -6.6893, 52.997097, -8.1437, -6.6893, 49.4712, -6.9631, -3.3446002, 49.4614, -6.9598, -6.6893, 39.877598, -3.7507, 6.6893, 36.153297, -2.972, 10.033899, 36.1898, -2.9299998, 6.6893, 39.877598, -3.7507, 6.6893, 39.877598, -3.7507, 10.033899, 36.153297, -2.972, 10.033899, 39.877598, -3.7507, 3.3446, 36.1898, -2.9299998, 6.6893, 36.2081, -2.9062, 3.3446, 39.877598, -3.7507, 3.3446, 39.877598, -3.7507, 6.6893, 36.1898, -2.9299998, 6.6893, 39.877598, -3.7507, 0, 36.2081, -2.9062, 3.3446, 36.211597, -2.8971, 0, 39.877598, -3.7507, 0, 39.877598, -3.7507, 3.3446, 36.2081, -2.9062, 3.3446, 39.877598, -3.7507, -3.3446002, 36.211597, -2.8971, 0, 36.2533, -2.8497999, -3.3446002, 39.877598, -3.7507, -3.3446002, 39.877598, -3.7507, 0, 36.211597, -2.8971, 0, 39.877598, -3.7507, -6.6893, 36.2533, -2.8497999, -3.3446002, 36.2803, -2.8172998, -6.6893, 39.877598, -3.7507, -6.6893, 39.877598, -3.7507, -3.3446002, 36.2533, -2.8497999, -3.3446002, 52.997097, -8.1437, -10.033899, 49.4614, -6.9598, -6.6893, 49.4666, -6.9364, -10.033899, 52.997097, -8.1437, -10.033899, 52.997097, -8.1437, -6.6893, 49.4614, -6.9598, -6.6893, 39.877598, -3.6573, -10.033899, 36.2803, -2.8172998, -6.6893, 36.3083, -2.7835999, -10.109299, 39.877598, -3.6573, -10.033899, 39.877598, -3.7507, -6.6893, 36.2803, -2.8172998, -6.6893, 39.877598, -3.6573, -13.378599, 36.3083, -2.7835999, -10.109299, 36.1797, -2.9064, -13.456699, 39.877598, -3.6573, -13.378599, 39.877598, -3.6573, -10.033899, 36.3083, -2.7835999, -10.109299, 39.877598, -3.6573, -16.7232, 36.1797, -2.9064, -13.456699, 36.2015, -2.8792, -16.7232, 39.877598, -3.6573, -16.7232, 39.877598, -3.6573, -13.378599, 36.1797, -2.9064, -13.456699, 39.877598, -3.6573, -20.0679, 36.2015, -2.8792, -16.7232, 36.1471, -2.928, -20.0679, 39.877598, -3.6573, -20.0679, 39.877598, -3.6573, -16.7232, 36.2015, -2.8792, -16.7232, 39.877598, -3.6573, -23.412498, 42.8416, -4.6944, -26.7572, 42.881, -4.6844, -23.412498, 39.877598, -3.6573, -23.412498, 39.877598, -3.6808999, -26.7572, 42.8416, -4.6944, -26.7572, 42.8416, -4.6944, -26.7572, 46.2636, -5.8410997, -23.412498, 42.881, -4.6844, -23.412498, 42.8416, -4.6944, -26.7572, 46.2278, -5.8524, -26.7572, 46.2636, -5.8410997, -23.412498, 46.2278, -5.8524, -26.7572, 49.427498, -6.923, -23.412498, 46.2636, -5.8410997, -23.412498, 46.2278, -5.8524, -26.7572, 49.4036, -6.9384, -26.7572, 49.427498, -6.923, -23.412498, 49.4036, -6.9384, -26.7572, 52.997097, -8.1437, -23.412498, 49.427498, -6.923, -23.412498, 49.4036, -6.9384, -26.7572, 52.997097, -8.1672, -26.7572, 52.997097, -8.1437, -23.412498, 52.997097, -8.1437, -23.412498, 56.4827, -8.7755995, -26.7572, 56.4571, -8.7476, -23.412498, 52.997097, -8.1437, -23.412498, 52.997097, -8.1672, -26.7572, 56.4827, -8.7755995, -26.7572, 56.4827, -8.7755995, -26.7572, 59.414497, -9.2638, -23.412498, 56.4571, -8.7476, -23.412498, 56.4827, -8.7755995, -26.7572, 59.4335, -9.2907, -26.7572, 59.414497, -9.2638, -23.412498, 59.4335, -9.2907, -26.7572, 62.3756, -9.7807, -23.412498, 59.414497, -9.2638, -23.412498, 59.4335, -9.2907, -26.7572, 62.356697, -9.8008995, -26.7572, 62.3756, -9.7807, -23.412498, 62.356697, -9.8008995, -26.7572, 66.1166, -10.4337, -23.412498, 62.3756, -9.7807, -23.412498, 62.356697, -9.8008995, -26.7572, 66.1166, -10.4572, -26.7572, 66.1166, -10.4337, -23.412498, 39.877598, -3.6573, -23.412498, 36.1471, -2.928, -20.0679, 36.1424, -2.9269998, -23.412498, 39.877598, -3.6573, -23.412498, 39.877598, -3.6573, -20.0679, 36.1471, -2.928, -20.0679, 36.1471, -2.928, -20.0679, 33.0948, -2.3312, -23.412498, 36.1424, -2.9269998, -23.412498, 36.1471, -2.928, -20.0679, 33.1148, -2.3351, -20.0679, 33.0948, -2.3312, -23.412498, 33.1148, -2.3351, -20.0679, 29.564798, -1.6410999, -23.412498, 33.0948, -2.3312, -23.412498, 33.1148, -2.3351, -20.0679, 29.562698, -1.6407, -20.0679, 29.564798, -1.6410999, -23.412498, 29.562698, -1.6407, -20.0679, 26.7582, -1.0924, -23.412498, 29.564798, -1.6410999, -23.412498, 29.562698, -1.6407, -20.0679, 26.7582, -1.0924, -20.0679, 26.7582, -1.0924, -23.412498, 29.6302, -1.5764999, -13.594999, 26.7577, -0.8804, -16.7232, 29.7123, -1.5057, -16.7232, 29.6302, -1.5764999, -13.594999, 26.572199, -0.95549995, -13.659599, 26.7577, -0.8804, -16.7232, 30.0488, -1.2003, -6.6893, 26.572199, -0.4003, -10.314899, 30.0059, -1.2408999, -10.242399, 30.0488, -1.2003, -6.6893, 26.7577, -0.3464, -6.6893, 26.572199, -0.4003, -10.314899, 29.536499, -2.2303, 13.378601, 26.7582, -1.7623999, 10.033899, 29.586699, -2.188, 10.033899, 29.536499, -2.2303, 13.378601, 26.7582, -1.8294, 13.378601, 26.7582, -1.7623999, 10.033899, 29.8112, -1.4067, 0, 26.7577, -0.4894, -3.3446002, 29.9525, -1.2836, -3.3446002, 29.8112, -1.4067, 0, 26.7577, -0.6956, 0, 26.7577, -0.4894, -3.3446002, 29.550499, -2.2133, 20.0679, 26.7582, -1.8294, 16.7232, 29.552599, -2.2137, 16.7232, 29.550499, -2.2133, 20.0679, 26.7582, -1.8294, 20.0679, 26.7582, -1.8294, 16.7232, 29.3928, -1.796, -26.7572, 33.0948, -2.3312, -23.412498, 29.564798, -1.6410999, -23.412498, 29.3928, -1.796, -26.7572, 32.9589, -2.4371, -26.7572, 33.0948, -2.3312, -23.412498, 29.7136, -1.4886999, 6.6893, 26.7577, -0.73109996, 3.3446, 29.7848, -1.4277999, 3.3446, 29.7136, -1.4886999, 6.6893, 26.7582, -0.83089995, 6.6893, 26.7577, -0.73109996, 3.3446, 29.1145, -2.5956, 26.7572, 26.7582, -2.2719998, 23.3902, 29.548399, -2.2129, 23.412498, 29.1145, -2.5956, 26.7572, 26.7582, -2.7837, 26.6955, 26.7582, -2.2719998, 23.3902, 29.7123, -1.5057, -16.7232, 26.7582, -1.0924, -20.0679, 29.562698, -1.6407, -20.0679, 29.7123, -1.5057, -16.7232, 26.7577, -0.8804, -16.7232, 26.7582, -1.0924, -20.0679, 30.0059, -1.2408999, -10.242399, 26.572199, -0.95549995, -13.659599, 29.6302, -1.5764999, -13.594999, 30.0059, -1.2408999, -10.242399, 26.572199, -0.4003, -10.314899, 26.572199, -0.95549995, -13.659599, 29.586699, -2.188, 10.033899, 26.7582, -0.83089995, 6.6893, 29.7136, -1.4886999, 6.6893, 29.586699, -2.188, 10.033899, 26.7582, -1.7623999, 10.033899, 26.7582, -0.83089995, 6.6893, 29.9525, -1.2836, -3.3446002, 26.7577, -0.3464, -6.6893, 30.0488, -1.2003, -6.6893, 29.9525, -1.2836, -3.3446002, 26.7577, -0.4894, -3.3446002, 26.7577, -0.3464, -6.6893, 29.552599, -2.2137, 16.7232, 26.7582, -1.8294, 13.378601, 29.536499, -2.2303, 13.378601, 29.552599, -2.2137, 16.7232, 26.7582, -1.8294, 16.7232, 26.7582, -1.8294, 13.378601, 29.7848, -1.4277999, 3.3446, 26.7577, -0.6956, 0, 29.8112, -1.4067, 0, 29.7848, -1.4277999, 3.3446, 26.7577, -0.73109996, 3.3446, 26.7577, -0.6956, 0, 29.548399, -2.2129, 23.412498, 26.7582, -1.8294, 20.0679, 29.550499, -2.2133, 20.0679, 29.548399, -2.2129, 23.412498, 26.7582, -2.2719998, 23.3902, 26.7582, -1.8294, 20.0679, 33.1982, -2.3009999, -13.5196, 29.7123, -1.5057, -16.7232, 33.2289, -2.25, -16.7232, 33.1982, -2.3009999, -13.5196, 29.6302, -1.5764999, -13.594999, 29.7123, -1.5057, -16.7232, 33.4719, -2.0886, -6.6893, 30.0059, -1.2408999, -10.242399, 33.4514, -2.0843, -10.169699, 33.4719, -2.0886, -6.6893, 30.0488, -1.2003, -6.6893, 30.0059, -1.2408999, -10.242399, 33.2822, -2.9943, 13.378601, 29.586699, -2.188, 10.033899, 33.2915, -2.9626, 10.033899, 33.2822, -2.9943, 13.378601, 29.536499, -2.2303, 13.378601, 29.586699, -2.188, 10.033899, 33.3661, -2.2345, 0, 29.9525, -1.2836, -3.3446002, 33.4326, -2.1487, -3.3446002, 33.3661, -2.2345, 0, 29.8112, -1.4067, 0, 29.9525, -1.2836, -3.3446002, 33.3628, -2.9636998, 20.0679, 29.552599, -2.2137, 16.7232, 33.3428, -2.9598, 16.7232, 33.3628, -2.9636998, 20.0679, 29.550499, -2.2133, 20.0679, 29.552599, -2.2137, 16.7232, 33.3828, -2.9677, 23.412498, 29.550499, -2.2133, 20.0679, 33.3628, -2.9636998, 20.0679, 33.3828, -2.9677, 23.412498, 29.548399, -2.2129, 23.412498, 29.550499, -2.2133, 20.0679, 33.347797, -2.2975, 6.6893, 29.7848, -1.4277999, 3.3446, 33.370598, -2.2531, 3.3446, 33.347797, -2.2975, 6.6893, 29.7136, -1.4886999, 6.6893, 29.7848, -1.4277999, 3.3446, 33.146698, -3.214, 26.7572, 29.548399, -2.2129, 23.412498, 33.3828, -2.9677, 23.412498, 33.146698, -3.214, 26.7572, 29.1145, -2.5956, 26.7572, 29.548399, -2.2129, 23.412498, 33.2289, -2.25, -16.7232, 29.562698, -1.6407, -20.0679, 33.1148, -2.3351, -20.0679, 33.2289, -2.25, -16.7232, 29.7123, -1.5057, -16.7232, 29.562698, -1.6407, -20.0679, 32.9589, -2.4371, -26.7572, 36.1424, -2.9269998, -23.412498, 33.0948, -2.3312, -23.412498, 32.9589, -2.4371, -26.7572, 36.0678, -2.9959998, -26.7572, 36.1424, -2.9269998, -23.412498, 33.4514, -2.0843, -10.169699, 29.6302, -1.5764999, -13.594999, 33.1982, -2.3009999, -13.5196, 33.4514, -2.0843, -10.169699, 30.0059, -1.2408999, -10.242399, 29.6302, -1.5764999, -13.594999, 33.2915, -2.9626, 10.033899, 29.7136, -1.4886999, 6.6893, 33.347797, -2.2975, 6.6893, 33.2915, -2.9626, 10.033899, 29.586699, -2.188, 10.033899, 29.7136, -1.4886999, 6.6893, 33.4326, -2.1487, -3.3446002, 30.0488, -1.2003, -6.6893, 33.4719, -2.0886, -6.6893, 33.4326, -2.1487, -3.3446002, 29.9525, -1.2836, -3.3446002, 30.0488, -1.2003, -6.6893, 33.3428, -2.9598, 16.7232, 29.536499, -2.2303, 13.378601, 33.2822, -2.9943, 13.378601, 33.3428, -2.9598, 16.7232, 29.552599, -2.2137, 16.7232, 29.536499, -2.2303, 13.378601, 33.370598, -2.2531, 3.3446, 29.8112, -1.4067, 0, 33.3661, -2.2345, 0, 33.370598, -2.2531, 3.3446, 29.7848, -1.4277999, 3.3446, 29.8112, -1.4067, 0, 36.1797, -2.9064, -13.456699, 33.2289, -2.25, -16.7232, 36.2015, -2.8792, -16.7232, 36.1797, -2.9064, -13.456699, 33.1982, -2.3009999, -13.5196, 33.2289, -2.25, -16.7232, 36.2803, -2.8172998, -6.6893, 33.4514, -2.0843, -10.169699, 36.3083, -2.7835999, -10.109299, 36.2803, -2.8172998, -6.6893, 33.4719, -2.0886, -6.6893, 33.4514, -2.0843, -10.169699, 36.1422, -2.9887998, 13.378601, 33.2915, -2.9626, 10.033899, 36.153297, -2.972, 10.033899, 36.1422, -2.9887998, 13.378601, 33.2822, -2.9943, 13.378601, 33.2915, -2.9626, 10.033899, 36.211597, -2.8971, 0, 33.4326, -2.1487, -3.3446002, 36.2533, -2.8497999, -3.3446002, 36.211597, -2.8971, 0, 33.3661, -2.2345, 0, 33.4326, -2.1487, -3.3446002, 36.2075, -2.9348, 20.0679, 33.3428, -2.9598, 16.7232, 36.2028, -2.9338999, 16.7232, 36.2075, -2.9348, 20.0679, 33.3628, -2.9636998, 20.0679, 33.3428, -2.9598, 16.7232, 36.153297, -2.972, 10.033899, 33.347797, -2.2975, 6.6893, 36.1898, -2.9299998, 6.6893, 36.153297, -2.972, 10.033899, 33.2915, -2.9626, 10.033899, 33.347797, -2.2975, 6.6893, 36.1898, -2.9299998, 6.6893, 33.370598, -2.2531, 3.3446, 36.2081, -2.9062, 3.3446, 36.1898, -2.9299998, 6.6893, 33.347797, -2.2975, 6.6893, 33.370598, -2.2531, 3.3446, 36.078598, -3.0746999, 26.7572, 33.3828, -2.9677, 23.412498, 36.2121, -2.9357, 23.412498, 36.078598, -3.0746999, 26.7572, 33.146698, -3.214, 26.7572, 33.3828, -2.9677, 23.412498, 36.2015, -2.8792, -16.7232, 33.1148, -2.3351, -20.0679, 36.1471, -2.928, -20.0679, 36.2015, -2.8792, -16.7232, 33.2289, -2.25, -16.7232, 33.1148, -2.3351, -20.0679, 36.3083, -2.7835999, -10.109299, 33.1982, -2.3009999, -13.5196, 36.1797, -2.9064, -13.456699, 36.3083, -2.7835999, -10.109299, 33.4514, -2.0843, -10.169699, 33.1982, -2.3009999, -13.5196, 36.0678, -2.9959998, -26.7572, 39.877598, -3.6573, -23.412498, 36.1424, -2.9269998, -23.412498, 36.0678, -2.9959998, -26.7572, 39.877598, -3.6808999, -26.7572, 39.877598, -3.6573, -23.412498, 36.2533, -2.8497999, -3.3446002, 33.4719, -2.0886, -6.6893, 36.2803, -2.8172998, -6.6893, 36.2533, -2.8497999, -3.3446002, 33.4326, -2.1487, -3.3446002, 33.4719, -2.0886, -6.6893, 36.2028, -2.9338999, 16.7232, 33.2822, -2.9943, 13.378601, 36.1422, -2.9887998, 13.378601, 36.2028, -2.9338999, 16.7232, 33.3428, -2.9598, 16.7232, 33.2822, -2.9943, 13.378601, 36.2081, -2.9062, 3.3446, 33.3661, -2.2345, 0, 36.211597, -2.8971, 0, 36.2081, -2.9062, 3.3446, 33.370598, -2.2531, 3.3446, 33.3661, -2.2345, 0, 36.2121, -2.9357, 23.412498, 33.3628, -2.9636998, 20.0679, 36.2075, -2.9348, 20.0679, 36.2121, -2.9357, 23.412498, 33.3828, -2.9677, 23.412498, 33.3628, -2.9636998, 20.0679, 42.951, -4.7798, -6.6893, 39.877598, -3.6573, -10.033899, 42.9754, -4.7166, -10.033899, 42.951, -4.7798, -6.6893, 39.877598, -3.7507, -6.6893, 39.877598, -3.6573, -10.033899, 43.1641, -4.7812, 16.7232, 39.877598, -3.7507, 13.378601, 43.0932, -4.8275, 13.378601, 43.1641, -4.7812, 16.7232, 39.877598, -3.6573, 16.7232, 39.877598, -3.7507, 13.378601, 46.338398, -5.9140997, -6.6893, 42.9754, -4.7166, -10.033899, 46.3474, -5.8697, -10.033899, 46.338398, -5.9140997, -6.6893, 42.951, -4.7798, -6.6893, 42.9754, -4.7166, -10.033899, 46.5149, -5.927, 16.7232, 43.0932, -4.8275, 13.378601, 46.4646, -5.9563, 13.378601, 46.5149, -5.927, 16.7232, 43.1641, -4.7812, 16.7232, 43.0932, -4.8275, 13.378601, 49.4614, -6.9598, -6.6893, 46.3474, -5.8697, -10.033899, 49.4666, -6.9364, -10.033899, 49.4614, -6.9598, -6.6893, 46.338398, -5.9140997, -6.6893, 46.3474, -5.8697, -10.033899, 49.5449, -6.9631996, 16.7232, 46.4646, -5.9563, 13.378601, 49.520397, -6.9795, 13.378601, 49.5449, -6.9631996, 16.7232, 46.5149, -5.927, 16.7232, 46.4646, -5.9563, 13.378601, -23.412498, -3.8518999, 39.456898, -26.7572, -3.2777, 36.6322, -23.412498, -3.2477999, 36.6305, -23.412498, -3.8518999, 39.456898, -26.7572, -3.8518999, 39.456898, -26.7572, -3.2777, 36.6322, -16.7232, -3.8518999, 39.456898, -13.378601, -3.243, 36.6252, -13.378601, -3.8518999, 39.456898, -16.7232, -3.8518999, 39.456898, -16.7232, -3.247, 36.6269, -13.378601, -3.243, 36.6252, -13.378601, -1.1209999, 26.7572, -10.033899, -1.8925999, 30.345499, -13.378601, -1.8938999, 30.3512, -10.033899, -1.8925999, 30.345499, -13.378601, -2.619, 33.7235, -13.378601, -1.8938999, 30.3512, -13.378601, -1.1209999, 26.7572, -10.033899, -1.1209999, 26.7572, -10.033899, -1.8925999, 30.345499, -10.033899, -1.8925999, 30.345499, -10.033899, -2.6181998, 33.7199, -13.378601, -2.619, 33.7235, -10.033899, -2.6181998, 33.7199, -13.378601, -3.243, 36.6252, -13.378601, -2.619, 33.7235, -10.033899, -1.1209999, 26.7572, -6.6893, -1.8914, 30.3399, -10.033899, -1.8925999, 30.345499, -6.6893, -1.8914, 30.3399, -10.033899, -2.6181998, 33.7199, -10.033899, -1.8925999, 30.345499, -10.033899, -1.1209999, 26.7572, -6.6893, -1.1209999, 26.7572, -6.6893, -1.8914, 30.3399, -10.033899, -2.6181998, 33.7199, -10.033899, -3.2426, 36.623398, -13.378601, -3.243, 36.6252, -10.033899, -3.2426, 36.623398, -13.378601, -3.8518999, 39.456898, -13.378601, -3.243, 36.6252, -10.033899, -3.2426, 36.623398, -10.033899, -3.8518999, 39.456898, -13.378601, -3.8518999, 39.456898, -6.6893, -2.6174998, 33.7163, -10.033899, -3.2426, 36.623398, -10.033899, -2.6181998, 33.7199, -6.6893, -1.8914, 30.3399, -6.6893, -2.6174998, 33.7163, -10.033899, -2.6181998, 33.7199, -6.6893, -3.2422, 36.6217, -10.033899, -3.8518999, 39.456898, -10.033899, -3.2426, 36.623398, -6.6893, -2.6174998, 33.7163, -6.6893, -3.2422, 36.6217, -10.033899, -3.2426, 36.623398, -6.6893, -3.2422, 36.6217, -6.6893, -3.8518999, 39.456898, -10.033899, -3.8518999, 39.456898, -3.3446002, -1.8901999, 30.3342, -6.6893, -2.6174998, 33.7163, -6.6893, -1.8914, 30.3399, -6.6893, -1.1209999, 26.7572, -3.3446002, -1.8901999, 30.3342, -6.6893, -1.8914, 30.3399, -6.6893, -1.1209999, 26.7572, -3.3446002, -1.1209999, 26.7572, -3.3446002, -1.8901999, 30.3342, -3.3446002, -1.8901999, 30.3342, -3.3446002, -2.6167, 33.7128, -6.6893, -2.6174998, 33.7163, -3.3446002, -2.6167, 33.7128, -6.6893, -3.2422, 36.6217, -6.6893, -2.6174998, 33.7163, -3.3446002, -1.1209999, 26.7572, 0, -1.8889999, 30.3286, -3.3446002, -1.8901999, 30.3342, 0, -1.8889999, 30.3286, -3.3446002, -2.6167, 33.7128, -3.3446002, -1.8901999, 30.3342, -3.3446002, -1.1209999, 26.7572, 0, -1.1209999, 26.7572, 0, -1.8889999, 30.3286, -3.3446002, -3.2417998, 36.6199, -6.6893, -3.8518999, 39.456898, -6.6893, -3.2422, 36.6217, -3.3446002, -2.6167, 33.7128, -3.3446002, -3.2417998, 36.6199, -6.6893, -3.2422, 36.6217, -3.3446002, -3.2417998, 36.6199, -3.3446002, -3.8518999, 39.456898, -6.6893, -3.8518999, 39.456898, 0, -1.8889999, 30.3286, 0, -2.616, 33.709198, -3.3446002, -2.6167, 33.7128, 0, -2.616, 33.709198, -3.3446002, -3.2417998, 36.6199, -3.3446002, -2.6167, 33.7128, 0, -1.1209999, 26.7572, 3.3446, -1.8878, 30.323, 0, -1.8889999, 30.3286, 3.3446, -1.8878, 30.323, 0, -2.616, 33.709198, 0, -1.8889999, 30.3286, 0, -1.1209999, 26.7572, 3.3446, -1.1209999, 26.7572, 3.3446, -1.8878, 30.323, 0, -3.2415, 36.6182, -3.3446002, -3.8518999, 39.456898, -3.3446002, -3.2417998, 36.6199, 0, -2.616, 33.709198, 0, -3.2415, 36.6182, -3.3446002, -3.2417998, 36.6199, 0, -3.2415, 36.6182, 0, -3.8518999, 39.456898, -3.3446002, -3.8518999, 39.456898, 3.3446, -1.8878, 30.323, 3.3446, -2.6152, 33.7057, 0, -2.616, 33.709198, 3.3446, -2.6152, 33.7057, 0, -3.2415, 36.6182, 0, -2.616, 33.709198, 3.3446, -1.1209999, 26.7572, 6.6893, -1.8865999, 30.317299, 3.3446, -1.8878, 30.323, 6.6893, -1.8865999, 30.317299, 3.3446, -2.6152, 33.7057, 3.3446, -1.8878, 30.323, 3.3446, -1.1209999, 26.7572, 6.6893, -1.1209999, 26.7572, 6.6893, -1.8865999, 30.317299, 6.6893, -1.1209999, 26.7572, 10.033899, -1.8853999, 30.311699, 6.6893, -1.8865999, 30.317299, 6.6893, -1.1209999, 26.7572, 10.033899, -1.1209999, 26.7572, 10.033899, -1.8853999, 30.311699, 10.033899, -1.8853999, 30.311699, 6.6893, -2.6144, 33.7021, 6.6893, -1.8865999, 30.317299, 6.6893, -1.8865999, 30.317299, 6.6893, -2.6144, 33.7021, 3.3446, -2.6152, 33.7057, 10.033899, -1.8853999, 30.311699, 10.033899, -2.6137, 33.6986, 6.6893, -2.6144, 33.7021, 3.3446, -2.6152, 33.7057, 3.3446, -3.2410998, 36.616398, 0, -3.2415, 36.6182, 3.3446, -3.2410998, 36.616398, 0, -3.8518999, 39.456898, 0, -3.2415, 36.6182, 6.6893, -2.6144, 33.7021, 3.3446, -3.2410998, 36.616398, 3.3446, -2.6152, 33.7057, 3.3446, -3.2410998, 36.616398, 3.3446, -3.8518999, 39.456898, 0, -3.8518999, 39.456898, 10.033899, -2.6137, 33.6986, 6.6893, -3.2407, 36.6147, 6.6893, -2.6144, 33.7021, 6.6893, -2.6144, 33.7021, 6.6893, -3.2407, 36.6147, 3.3446, -3.2410998, 36.616398, 6.6893, -3.2407, 36.6147, 3.3446, -3.8518999, 39.456898, 3.3446, -3.2410998, 36.616398, 10.033899, -2.6137, 33.6986, 10.033899, -3.2403, 36.6129, 6.6893, -3.2407, 36.6147, 6.6893, -3.2407, 36.6147, 6.6893, -3.8518999, 39.456898, 3.3446, -3.8518999, 39.456898, 10.033899, -3.2403, 36.6129, 6.6893, -3.8518999, 39.456898, 6.6893, -3.2407, 36.6147, 10.033899, -3.2403, 36.6129, 10.033899, -3.8518999, 39.456898, 6.6893, -3.8518999, 39.456898, 10.033899, -1.1209999, 26.7572, 13.378599, -2.6734, 30.306, 10.033899, -1.8853999, 30.311699, 10.033899, -1.1209999, 26.7572, 13.378599, -2.2194998, 26.781, 13.378599, -2.6734, 30.306, 13.378599, -2.2194998, 26.781, 16.7232, -2.6727, 30.3004, 13.378599, -2.6734, 30.306, 16.7232, -2.6727, 30.3004, 13.378599, -3.1099, 33.695, 13.378599, -2.6734, 30.306, 13.378599, -2.2194998, 26.781, 16.7232, -2.2194998, 26.781, 16.7232, -2.6727, 30.3004, 16.7232, -2.6727, 30.3004, 16.7232, -3.1094, 33.691498, 13.378599, -3.1099, 33.695, 16.7232, -3.1094, 33.691498, 13.378599, -3.4854, 36.6112, 13.378599, -3.1099, 33.695, 16.7232, -2.2194998, 26.781, 20.0679, -2.672, 30.294699, 16.7232, -2.6727, 30.3004, 20.0679, -2.672, 30.294699, 16.7232, -3.1094, 33.691498, 16.7232, -2.6727, 30.3004, 16.7232, -2.2194998, 26.781, 20.0679, -2.2194998, 26.781, 20.0679, -2.672, 30.294699, 20.0679, -2.2194998, 26.781, 23.412498, -2.6713, 30.289099, 20.0679, -2.672, 30.294699, 20.0679, -2.2194998, 26.781, 23.412498, -2.2194998, 26.781, 23.412498, -2.6713, 30.289099, 23.412498, -2.6713, 30.289099, 20.0679, -3.109, 33.6879, 20.0679, -2.672, 30.294699, 20.0679, -2.672, 30.294699, 20.0679, -3.109, 33.6879, 16.7232, -3.1094, 33.691498, 23.412498, -2.6713, 30.289099, 23.412498, -3.1085, 33.6844, 20.0679, -3.109, 33.6879, 16.7232, -3.1094, 33.691498, 16.7232, -3.4852, 36.609398, 13.378599, -3.4854, 36.6112, 20.0679, -3.109, 33.6879, 16.7232, -3.4852, 36.609398, 16.7232, -3.1094, 33.691498, 16.7232, -3.4852, 36.609398, 13.378599, -3.8518999, 39.456898, 13.378599, -3.4854, 36.6112, 16.7232, -3.4852, 36.609398, 16.7232, -3.8518999, 39.456898, 13.378599, -3.8518999, 39.456898, 20.0679, -3.109, 33.6879, 20.0679, -3.485, 36.607597, 16.7232, -3.4852, 36.609398, 20.0679, -3.485, 36.607597, 16.7232, -3.8518999, 39.456898, 16.7232, -3.4852, 36.609398, 23.412498, -3.1085, 33.6844, 20.0679, -3.485, 36.607597, 20.0679, -3.109, 33.6879, 20.0679, -3.485, 36.607597, 20.0679, -3.8518999, 39.456898, 16.7232, -3.8518999, 39.456898, 23.412498, -3.1085, 33.6844, 23.412498, -3.4847, 36.6059, 20.0679, -3.485, 36.607597, 23.412498, -3.4847, 36.6059, 20.0679, -3.8518999, 39.456898, 20.0679, -3.485, 36.607597, 23.412498, -3.4847, 36.6059, 23.412498, -3.8518999, 39.456898, 20.0679, -3.8518999, 39.456898, 23.412498, -2.2194998, 26.781, 26.7582, -3.0839999, 30.2834, 23.412498, -2.6713, 30.289099, 23.412498, -2.2194998, 26.781, 26.7582, -2.7837, 26.6955, 26.7582, -3.0839999, 30.2834, -26.7572, -3.2777, 36.6322, -23.412498, -2.6287, 33.7341, -23.412498, -3.2477999, 36.6305, -26.7572, -3.2777, 36.6322, -26.7572, -2.6892, 33.737698, -23.412498, -2.6287, 33.7341, -16.7232, -3.247, 36.6269, -13.378601, -2.619, 33.7235, -13.378601, -3.243, 36.6252, -16.7232, -3.247, 36.6269, -16.7232, -2.6272, 33.726997, -13.378601, -2.619, 33.7235, 26.7582, -3.6130998, 36.6041, 23.412498, -3.8518999, 39.456898, 23.412498, -3.4847, 36.6059, 26.7582, -3.6130998, 36.6041, 26.7582, -3.8518999, 39.456898, 23.412498, -3.8518999, 39.456898, 13.378599, -3.4854, 36.6112, 10.033899, -3.8518999, 39.456898, 10.033899, -3.2403, 36.6129, 13.378599, -3.4854, 36.6112, 13.378599, -3.8518999, 39.456898, 10.033899, -3.8518999, 39.456898, 26.7582, -3.3683999, 33.680798, 23.412498, -3.4847, 36.6059, 23.412498, -3.1085, 33.6844, 26.7582, -3.3683999, 33.680798, 26.7582, -3.6130998, 36.6041, 23.412498, -3.4847, 36.6059, -26.7572, -2.6892, 33.737698, -23.412498, -1.9093, 30.3681, -23.412498, -2.6287, 33.7341, -26.7572, -2.6892, 33.737698, -26.7572, -2.0054, 30.3737, -23.412498, -1.9093, 30.3681, -16.7232, -2.6272, 33.726997, -13.378601, -1.8938999, 30.3512, -13.378601, -2.619, 33.7235, -16.7232, -2.6272, 33.726997, -16.7232, -1.9068999, 30.3568, -13.378601, -1.8938999, 30.3512, 13.378599, -3.1099, 33.695, 10.033899, -3.2403, 36.6129, 10.033899, -2.6137, 33.6986, 13.378599, -3.1099, 33.695, 13.378599, -3.4854, 36.6112, 10.033899, -3.2403, 36.6129, -26.7572, -2.0054, 30.3737, -23.412498, -1.1374999, 26.7572, -23.412498, -1.9093, 30.3681, -26.7572, -2.0054, 30.3737, -26.7572, -1.2701, 26.7572, -23.412498, -1.1374999, 26.7572, 26.7582, -3.0839999, 30.2834, 23.412498, -3.1085, 33.6844, 23.412498, -2.6713, 30.289099, 26.7582, -3.0839999, 30.2834, 26.7582, -3.3683999, 33.680798, 23.412498, -3.1085, 33.6844, 13.378599, -2.6734, 30.306, 10.033899, -2.6137, 33.6986, 10.033899, -1.8853999, 30.311699, 13.378599, -2.6734, 30.306, 13.378599, -3.1099, 33.695, 10.033899, -2.6137, 33.6986, -16.7232, -1.9068999, 30.3568, -13.378601, -1.1209999, 26.7572, -13.378601, -1.8938999, 30.3512, -16.7232, -1.9068999, 30.3568, -16.7232, -1.1374999, 26.7572, -13.378601, -1.1209999, 26.7572, -38.5239, -3.6573, -23.412498, -36.0882, -3.5405, -26.7572, -36.0928, -3.4731998, -23.412498, -38.5239, -3.6573, -23.412498, -38.5239, -3.6573, -26.7572, -36.0882, -3.5405, -26.7572, -38.5239, -3.6573, -20.3888, -36.0928, -3.4731998, -23.412498, -36.0969, -3.2475998, -20.410799, -38.5239, -3.6573, -20.3888, -38.5239, -3.6573, -23.412498, -36.0928, -3.4731998, -23.412498, -38.5239, -3.6573, -20.0679, -36.101498, -3.2484, -17.0662, -38.5239, -3.6573, -17.0441, -36.101498, -3.2484, -17.0662, -38.5239, -3.6573, -20.0679, -38.5239, -3.6573, -20.3888, -36.101498, -3.2484, -17.0662, -38.5239, -3.6573, -20.3888, -36.0969, -3.2475998, -20.410799, -36.0969, -3.2475998, -20.410799, -33.3005, -2.7756, -17.0916, -36.101498, -3.2484, -17.0662, -36.0969, -3.2475998, -20.410799, -33.2906, -2.7739, -20.4364, -33.3005, -2.7756, -17.0916, -33.2906, -2.7739, -20.4364, -30.219301, -2.2554998, -17.1196, -33.3005, -2.7756, -17.0916, -33.2906, -2.7739, -20.4364, -30.2036, -2.2528, -20.4644, -30.219301, -2.2554998, -17.1196, -30.2036, -2.2528, -20.4644, -26.7572, -1.6710999, -17.1511, -30.219301, -2.2554998, -17.1196, -30.2036, -2.2528, -20.4644, -26.7572, -1.6710999, -20.4958, -26.7572, -1.6710999, -17.1511, -38.5239, -3.6573, -16.7232, -36.106598, -2.8727, -13.378599, -38.5239, -3.6573, -13.378599, -36.106598, -2.8727, -13.378599, -38.5239, -3.6573, -16.7232, -38.5239, -3.6573, -17.0441, -36.106598, -2.8727, -13.378599, -38.5239, -3.6573, -17.0441, -36.101498, -3.2484, -17.0662, -38.5239, -3.6573, -10.033899, -36.106598, -2.8727, -13.378599, -36.1112, -2.8741999, -10.033899, -38.5239, -3.6573, -10.033899, -38.5239, -3.6573, -13.378599, -36.106598, -2.8727, -13.378599, -38.5239, -3.6573, -6.6893, -38.5239, -3.6573, -10.033899, -36.1112, -2.8741999, -10.033899, -38.5239, -3.6573, -6.6893, -36.1112, -2.8741999, -10.033899, -36.1158, -2.8757, -6.6893, -36.1112, -2.8741999, -10.033899, -33.3313, -1.9719, -6.6893, -36.1158, -2.8757, -6.6893, -36.106598, -2.8727, -13.378599, -33.3214, -1.9686999, -10.033899, -36.1112, -2.8741999, -10.033899, -36.1112, -2.8741999, -10.033899, -33.3214, -1.9686999, -10.033899, -33.3313, -1.9719, -6.6893, -36.106598, -2.8727, -13.378599, -33.3115, -1.9655, -13.378599, -33.3214, -1.9686999, -10.033899, -33.3214, -1.9686999, -10.033899, -30.2685, -0.9777, -6.6893, -33.3313, -1.9719, -6.6893, -33.3115, -1.9655, -13.378599, -30.252699, -0.9726, -10.033899, -33.3214, -1.9686999, -10.033899, -33.3214, -1.9686999, -10.033899, -30.252699, -0.9726, -10.033899, -30.2685, -0.9777, -6.6893, -30.252699, -0.9726, -10.033899, -29.294899, -0.6617, -6.6893, -30.2685, -0.9777, -6.6893, -33.3115, -1.9655, -13.378599, -30.237001, -0.9675, -13.378599, -30.252699, -0.9726, -10.033899, -30.252699, -0.9726, -10.033899, -29.294899, -0.6617, -10.033899, -29.294899, -0.6617, -6.6893, -30.237001, -0.9675, -13.378599, -29.294899, -0.6617, -10.033899, -30.252699, -0.9726, -10.033899, -30.237001, -0.9675, -13.378599, -29.294899, -0.6617, -13.378599, -29.294899, -0.6617, -10.033899, -38.5239, -3.6573, -3.3446002, -36.1158, -2.8757, -6.6893, -36.1204, -2.9744, -3.3446002, -38.5239, -3.6573, -3.3446002, -38.5239, -3.6573, -6.6893, -36.1158, -2.8757, -6.6893, -38.5239, -3.6573, 0, -36.1204, -2.9744, -3.3446002, -36.125, -2.9757, 0, -38.5239, -3.6573, 0, -38.5239, -3.6573, -3.3446002, -36.1204, -2.9744, -3.3446002, -36.1204, -2.9744, -3.3446002, -33.3512, -2.1875, 0, -36.125, -2.9757, 0, -36.1204, -2.9744, -3.3446002, -33.341198, -2.1847, -3.3446002, -33.3512, -2.1875, 0, -33.341198, -2.1847, -3.3446002, -30.300001, -1.3205999, 0, -33.3512, -2.1875, 0, -33.341198, -2.1847, -3.3446002, -30.2842, -1.3161, -3.3446002, -30.300001, -1.3205999, 0, -30.2842, -1.3161, -3.3446002, -27.9812, -0.6617, 0, -30.300001, -1.3205999, 0, -30.2842, -1.3161, -3.3446002, -27.9812, -0.6617, -3.3446002, -27.9812, -0.6617, 0, -29.294899, -0.6617, 3.3446, -30.3315, -0.9982, 6.6893, -30.315699, -0.9931, 3.3446, -29.294899, -0.6617, 3.3446, -29.294899, -0.6617, 6.6893, -30.3315, -0.9982, 6.6893, -29.294899, -0.6617, 10.033899, -30.3315, -0.9982, 6.6893, -29.294899, -0.6617, 6.6893, -30.3315, -0.9982, 6.6893, -29.294899, -0.6617, 10.033899, -29.294899, -0.6617, 13.378601, -30.3315, -0.9982, 6.6893, -33.3611, -1.9814999, 3.3446, -30.315699, -0.9931, 3.3446, -30.3315, -0.9982, 6.6893, -29.294899, -0.6617, 13.378601, -30.363, -1.0084, 13.378601, -30.3315, -0.9982, 6.6893, -33.371, -1.9848, 6.6893, -33.3611, -1.9814999, 3.3446, -30.363, -1.0084, 13.378601, -33.371, -1.9848, 6.6893, -30.3315, -0.9982, 6.6893, -33.371, -1.9848, 6.6893, -36.1296, -2.8801, 3.3446, -33.3611, -1.9814999, 3.3446, -30.363, -1.0084, 13.378601, -33.3908, -1.9912, 13.378601, -33.371, -1.9848, 6.6893, -33.371, -1.9848, 6.6893, -36.134197, -2.8816, 6.6893, -36.1296, -2.8801, 3.3446, -33.3908, -1.9912, 13.378601, -36.134197, -2.8816, 6.6893, -33.371, -1.9848, 6.6893, -36.134197, -2.8816, 6.6893, -38.5239, -3.6573, 3.3446, -36.1296, -2.8801, 3.3446, -33.3908, -1.9912, 13.378601, -36.1433, -2.8846, 13.378601, -36.134197, -2.8816, 6.6893, -36.134197, -2.8816, 6.6893, -38.5239, -3.6573, 6.6893, -38.5239, -3.6573, 3.3446, -38.5239, -3.6573, 13.378601, -36.134197, -2.8816, 6.6893, -36.1433, -2.8846, 13.378601, -36.134197, -2.8816, 6.6893, -38.5239, -3.6573, 10.033899, -38.5239, -3.6573, 6.6893, -36.134197, -2.8816, 6.6893, -38.5239, -3.6573, 13.378601, -38.5239, -3.6573, 10.033899, -29.294899, -1.0265, 16.7232, -30.394499, -1.8736, 20.0679, -30.378698, -1.3354, 16.7232, -29.294899, -1.0265, 16.7232, -26.7572, -1.0756, 20.0679, -30.394499, -1.8736, 20.0679, -26.7572, -1.0756, 20.0679, -30.4102, -1.8771, 23.412498, -30.394499, -1.8736, 20.0679, -26.7572, -1.0756, 20.0679, -26.7572, -1.0756, 23.412498, -30.4102, -1.8771, 23.412498, -30.4102, -1.8771, 23.412498, -33.4106, -2.5354, 20.0679, -30.394499, -1.8736, 20.0679, -30.4102, -1.8771, 23.412498, -33.420498, -2.5376, 23.412498, -33.4106, -2.5354, 20.0679, -33.420498, -2.5376, 23.412498, -36.1525, -3.1369998, 20.0679, -33.4106, -2.5354, 20.0679, -33.420498, -2.5376, 23.412498, -36.1571, -3.138, 23.412498, -36.1525, -3.1369998, 20.0679, -36.1571, -3.138, 23.412498, -38.5239, -3.6573, 20.0679, -36.1525, -3.1369998, 20.0679, -36.1571, -3.138, 23.412498, -38.5239, -3.6573, 23.412498, -38.5239, -3.6573, 20.0679, -26.7572, -1.0756, 23.412498, -30.426, -2.0144, 26.7572, -30.4102, -1.8771, 23.412498, -26.7572, -1.0756, 23.412498, -26.7572, -1.2701, 26.7572, -30.426, -2.0144, 26.7572, -29.294899, -0.6617, 13.378601, -30.378698, -1.3354, 16.7232, -30.363, -1.0084, 13.378601, -29.294899, -0.6617, 13.378601, -29.294899, -1.0265, 16.7232, -30.378698, -1.3354, 16.7232, -30.173899, -3.2568, -26.7572, -26.7572, -2.7663999, -23.412498, -30.1897, -3.0263, -23.412498, -30.173899, -3.2568, -26.7572, -26.7572, -3.0929, -26.7572, -26.7572, -2.7663999, -23.412498, -30.426, -2.0144, 26.7572, -33.420498, -2.5376, 23.412498, -30.4102, -1.8771, 23.412498, -30.426, -2.0144, 26.7572, -33.4304, -2.6239998, 26.7572, -33.420498, -2.5376, 23.412498, -30.219301, -2.2554998, -17.1196, -29.294899, -0.6617, -13.378599, -30.237001, -0.9675, -13.378599, -30.219301, -2.2554998, -17.1196, -26.7572, -1.6710999, -17.1511, -29.294899, -0.6617, -13.378599, -30.394499, -1.8736, 20.0679, -33.4007, -2.1969, 16.7232, -30.378698, -1.3354, 16.7232, -30.394499, -1.8736, 20.0679, -33.4106, -2.5354, 20.0679, -33.4007, -2.1969, 16.7232, -30.1897, -3.0263, -23.412498, -26.7572, -1.6710999, -20.4958, -30.2036, -2.2528, -20.4644, -30.1897, -3.0263, -23.412498, -26.7572, -2.7663999, -23.412498, -26.7572, -1.6710999, -20.4958, -30.315699, -0.9931, 3.3446, -33.3512, -2.1875, 0, -30.300001, -1.3205999, 0, -30.315699, -0.9931, 3.3446, -33.3611, -1.9814999, 3.3446, -33.3512, -2.1875, 0, -30.2685, -0.9777, -6.6893, -27.9812, -0.6617, -3.3446002, -30.2842, -1.3161, -3.3446002, -30.2685, -0.9777, -6.6893, -29.294899, -0.6617, -6.6893, -27.9812, -0.6617, -3.3446002, -30.378698, -1.3354, 16.7232, -33.3908, -1.9912, 13.378601, -30.363, -1.0084, 13.378601, -30.378698, -1.3354, 16.7232, -33.4007, -2.1969, 16.7232, -33.3908, -1.9912, 13.378601, -33.2719, -3.4053998, -26.7572, -30.1897, -3.0263, -23.412498, -33.2818, -3.2603998, -23.412498, -33.2719, -3.4053998, -26.7572, -30.173899, -3.2568, -26.7572, -30.1897, -3.0263, -23.412498, -33.4007, -2.1969, 16.7232, -36.1433, -2.8846, 13.378601, -33.3908, -1.9912, 13.378601, -33.4007, -2.1969, 16.7232, -36.1479, -2.98, 16.7232, -36.1433, -2.8846, 13.378601, -33.4304, -2.6239998, 26.7572, -36.1571, -3.138, 23.412498, -33.420498, -2.5376, 23.412498, -33.4304, -2.6239998, 26.7572, -36.161697, -3.1780999, 26.7572, -36.1571, -3.138, 23.412498, -33.3005, -2.7756, -17.0916, -30.237001, -0.9675, -13.378599, -33.3115, -1.9655, -13.378599, -33.3005, -2.7756, -17.0916, -30.219301, -2.2554998, -17.1196, -30.237001, -0.9675, -13.378599, -33.2818, -3.2603998, -23.412498, -30.2036, -2.2528, -20.4644, -33.2906, -2.7739, -20.4364, -33.2818, -3.2603998, -23.412498, -30.1897, -3.0263, -23.412498, -30.2036, -2.2528, -20.4644, -33.3611, -1.9814999, 3.3446, -36.125, -2.9757, 0, -33.3512, -2.1875, 0, -33.3611, -1.9814999, 3.3446, -36.1296, -2.8801, 3.3446, -36.125, -2.9757, 0, -33.4106, -2.5354, 20.0679, -36.1479, -2.98, 16.7232, -33.4007, -2.1969, 16.7232, -33.4106, -2.5354, 20.0679, -36.1525, -3.1369998, 20.0679, -36.1479, -2.98, 16.7232, -33.3313, -1.9719, -6.6893, -30.2842, -1.3161, -3.3446002, -33.341198, -2.1847, -3.3446002, -33.3313, -1.9719, -6.6893, -30.2685, -0.9777, -6.6893, -30.2842, -1.3161, -3.3446002, -36.161697, -3.1780999, 26.7572, -38.5239, -3.6573, 23.412498, -36.1571, -3.138, 23.412498, -36.161697, -3.1780999, 26.7572, -38.5239, -3.6573, 26.7572, -38.5239, -3.6573, 23.412498, -36.101498, -3.2484, -17.0662, -33.3115, -1.9655, -13.378599, -36.106598, -2.8727, -13.378599, -36.101498, -3.2484, -17.0662, -33.3005, -2.7756, -17.0916, -33.3115, -1.9655, -13.378599, -36.0928, -3.4731998, -23.412498, -33.2906, -2.7739, -20.4364, -36.0969, -3.2475998, -20.410799, -36.0928, -3.4731998, -23.412498, -33.2818, -3.2603998, -23.412498, -33.2906, -2.7739, -20.4364, -36.1296, -2.8801, 3.3446, -38.5239, -3.6573, 0, -36.125, -2.9757, 0, -36.1296, -2.8801, 3.3446, -38.5239, -3.6573, 3.3446, -38.5239, -3.6573, 0, -36.1158, -2.8757, -6.6893, -33.341198, -2.1847, -3.3446002, -36.1204, -2.9744, -3.3446002, -36.1158, -2.8757, -6.6893, -33.3313, -1.9719, -6.6893, -33.341198, -2.1847, -3.3446002, -36.1525, -3.1369998, 20.0679, -38.5239, -3.6573, 16.7232, -36.1479, -2.98, 16.7232, -36.1525, -3.1369998, 20.0679, -38.5239, -3.6573, 20.0679, -38.5239, -3.6573, 16.7232, -36.0882, -3.5405, -26.7572, -33.2818, -3.2603998, -23.412498, -36.0928, -3.4731998, -23.412498, -36.0882, -3.5405, -26.7572, -33.2719, -3.4053998, -26.7572, -33.2818, -3.2603998, -23.412498, -36.1479, -2.98, 16.7232, -38.5239, -3.6573, 13.378601, -36.1433, -2.8846, 13.378601, -36.1479, -2.98, 16.7232, -38.5239, -3.6573, 16.7232, -38.5239, -3.6573, 13.378601, -41.627, -4.8403997, 26.7572, -38.5239, -3.6573, 26.7572, -40.2616, -4.8514, 28.2242, -44.537098, -5.95, 26.7572, -41.627, -4.8403997, 26.7572, -43.2833, -5.9677997, 28.143099, -47.4473, -7.0596, 26.7572, -44.537098, -5.95, 26.7572, -46.202698, -7.0731997, 28.132399, -53.117798, -8.6939, 26.7572, -50.290497, -8.1437, 26.7572, -51.906998, -8.6923, 28.058699, -55.5951, -9.176, 26.7572, -53.117798, -8.6939, 26.7572, -54.5561, -9.1761, 27.885199, -58.463497, -9.7343, 26.7572, -55.5951, -9.176, 26.7572, -57.2836, -9.7381, 28.062199, -33.0801, -3.7473, 32.6325, -32.8819, -4.9104, 36.1535, -34.7309, -4.8956, 34.1667, -33.0801, -3.7473, 32.6325, -31.247301, -3.7775998, 34.6107, -32.8819, -4.9104, 36.1535, -34.7309, -4.8956, 34.1667, -34.5837, -6.0910997, 37.759598, -36.416798, -6.0650997, 35.7334, -34.7309, -4.8956, 34.1667, -32.8819, -4.9104, 36.1535, -34.5837, -6.0910997, 37.759598, -36.416798, -6.0650997, 35.7334, -36.1511, -7.1832, 39.2388, -37.9957, -7.1629996, 37.2007, -36.416798, -6.0650997, 35.7334, -34.5837, -6.0910997, 37.759598, -36.1511, -7.1832, 39.2388, -37.9957, -7.1629996, 37.2007, -37.5247, -8.1437, 40.5352, -39.4027, -8.1437, 38.5083, -37.9957, -7.1629996, 37.2007, -36.1511, -7.1832, 39.2388, -37.5247, -8.1437, 40.5352, -39.4027, -8.1437, 38.5083, -39.0032, -8.6748, 41.930298, -40.895798, -8.6774, 39.8957, -39.4027, -8.1437, 38.5083, -37.5247, -8.1437, 40.5352, -39.0032, -8.6748, 41.930298, -40.895798, -8.6774, 39.8957, -40.403297, -9.1775, 43.2515, -42.2959, -9.1772995, 41.196598, -40.895798, -8.6774, 39.8957, -39.0032, -8.6748, 41.930298, -40.403297, -9.1775, 43.2515, -42.2959, -9.1772995, 41.196598, -42.1009, -9.787, 44.8533, -43.9867, -9.7809, 42.7677, -42.2959, -9.1772995, 41.196598, -40.403297, -9.1775, 43.2515, -42.1009, -9.787, 44.8533, -43.9867, -9.7809, 42.7677, -43.8985, -10.4337, 46.549698, -45.8121, -10.4337, 44.463898, -43.9867, -9.7809, 42.7677, -42.1009, -9.787, 44.8533, -43.8985, -10.4337, 46.549698, -31.247301, -3.7775998, 34.6107, -31.153502, -4.9242997, 38.010498, -32.8819, -4.9104, 36.1535, -31.247301, -3.7775998, 34.6107, -29.5455, -3.8058, 36.4474, -31.153502, -4.9242997, 38.010498, -32.8819, -4.9104, 36.1535, -32.857998, -6.1155996, 39.6672, -34.5837, -6.0910997, 37.759598, -32.8819, -4.9104, 36.1535, -31.153502, -4.9242997, 38.010498, -32.857998, -6.1155996, 39.6672, -34.5837, -6.0910997, 37.759598, -34.403698, -7.2022996, 41.169598, -36.1511, -7.1832, 39.2388, -34.5837, -6.0910997, 37.759598, -32.857998, -6.1155996, 39.6672, -34.403698, -7.2022996, 41.169598, -36.1511, -7.1832, 39.2388, -35.7366, -8.1437, 42.4652, -37.5247, -8.1437, 40.5352, -36.1511, -7.1832, 39.2388, -34.403698, -7.2022996, 41.169598, -35.7366, -8.1437, 42.4652, -37.5247, -8.1437, 40.5352, -37.1909, -8.672299, 43.878498, -39.0032, -8.6748, 41.930298, -37.5247, -8.1437, 40.5352, -35.7366, -8.1437, 42.4652, -37.1909, -8.672299, 43.878498, -42.1009, -9.787, 44.8533, -42.032497, -10.4337, 48.5836, -43.8985, -10.4337, 46.549698, -42.1009, -9.787, 44.8533, -40.2735, -9.7929, 46.8743, -42.032497, -10.4337, 48.5836, -39.0032, -8.6748, 41.930298, -38.5812, -9.1777, 45.2297, -40.403297, -9.1775, 43.2515, -39.0032, -8.6748, 41.930298, -37.1909, -8.672299, 43.878498, -38.5812, -9.1777, 45.2297, -40.403297, -9.1775, 43.2515, -40.2735, -9.7929, 46.8743, -42.1009, -9.787, 44.8533, -40.403297, -9.1775, 43.2515, -38.5812, -9.1777, 45.2297, -40.2735, -9.7929, 46.8743, -40.2735, -9.7929, 46.8743, -40.572998, -10.4337, 50.1744, -42.032497, -10.4337, 48.5836, -40.2735, -9.7929, 46.8743, -38.8322, -9.7975, 48.4683, -40.572998, -10.4337, 50.1744, -32.857998, -6.1155996, 39.6672, -32.985, -7.2179, 42.7371, -34.403698, -7.2022996, 41.169598, -32.857998, -6.1155996, 39.6672, -31.445799, -6.1355996, 41.2283, -32.985, -7.2179, 42.7371, -38.5812, -9.1777, 45.2297, -38.8322, -9.7975, 48.4683, -40.2735, -9.7929, 46.8743, -38.5812, -9.1777, 45.2297, -37.1326, -9.1778, 46.802498, -38.8322, -9.7975, 48.4683, -34.403698, -7.2022996, 41.169598, -34.2949, -8.1437, 44.0212, -35.7366, -8.1437, 42.4652, -34.403698, -7.2022996, 41.169598, -32.985, -7.2179, 42.7371, -34.2949, -8.1437, 44.0212, -35.7366, -8.1437, 42.4652, -35.7403, -8.6704, 45.4379, -37.1909, -8.672299, 43.878498, -35.7366, -8.1437, 42.4652, -34.2949, -8.1437, 44.0212, -35.7403, -8.6704, 45.4379, -29.5455, -3.8058, 36.4474, -29.727, -4.9357, 39.5433, -31.153502, -4.9242997, 38.010498, -29.5455, -3.8058, 36.4474, -28.1288, -3.8291998, 37.976498, -29.727, -4.9357, 39.5433, -37.1909, -8.672299, 43.878498, -37.1326, -9.1778, 46.802498, -38.5812, -9.1777, 45.2297, -37.1909, -8.672299, 43.878498, -35.7403, -8.6704, 45.4379, -37.1326, -9.1778, 46.802498, -31.153502, -4.9242997, 38.010498, -31.445799, -6.1355996, 41.2283, -32.857998, -6.1155996, 39.6672, -31.153502, -4.9242997, 38.010498, -29.727, -4.9357, 39.5433, -31.445799, -6.1355996, 41.2283, -28.1288, -3.8291998, 37.976498, -28.3464, -4.9467, 41.0267, -29.727, -4.9357, 39.5433, -28.1288, -3.8291998, 37.976498, -26.7572, -3.8518999, 39.456898, -28.3464, -4.9467, 41.0267, -29.727, -4.9357, 39.5433, -30.0795, -6.1549, 42.738598, -31.445799, -6.1355996, 41.2283, -29.727, -4.9357, 39.5433, -28.3464, -4.9467, 41.0267, -30.0795, -6.1549, 42.738598, -38.8322, -9.7975, 48.4683, -39.1638, -10.4337, 51.7104, -40.572998, -10.4337, 50.1744, -38.8322, -9.7975, 48.4683, -37.44, -9.802, 50.008, -39.1638, -10.4337, 51.7104, -31.445799, -6.1355996, 41.2283, -31.612902, -7.2328997, 44.2532, -32.985, -7.2179, 42.7371, -31.445799, -6.1355996, 41.2283, -30.0795, -6.1549, 42.738598, -31.612902, -7.2328997, 44.2532, -37.1326, -9.1778, 46.802498, -37.44, -9.802, 50.008, -38.8322, -9.7975, 48.4683, -37.1326, -9.1778, 46.802498, -35.7329, -9.177899, 48.322197, -37.44, -9.802, 50.008, -32.985, -7.2179, 42.7371, -32.901, -8.1437, 45.5256, -34.2949, -8.1437, 44.0212, -32.985, -7.2179, 42.7371, -31.612902, -7.2328997, 44.2532, -32.901, -8.1437, 45.5256, -34.2949, -8.1437, 44.0212, -34.3383, -8.6685, 46.945, -35.7403, -8.6704, 45.4379, -34.2949, -8.1437, 44.0212, -32.901, -8.1437, 45.5256, -34.3383, -8.6685, 46.945, -35.7403, -8.6704, 45.4379, -35.7329, -9.177899, 48.322197, -37.1326, -9.1778, 46.802498, -35.7403, -8.6704, 45.4379, -34.3383, -8.6685, 46.945, -35.7329, -9.177899, 48.322197, -34.3383, -8.6685, 46.945, -34.0797, -9.1781, 50.1171, -35.7329, -9.177899, 48.322197, -34.3383, -8.6685, 46.945, -32.6904, -8.6662, 48.7166, -34.0797, -9.1781, 50.1171, -26.7572, -3.8518999, 39.456898, -26.7572, -4.9595, 42.7343, -28.3464, -4.9467, 41.0267, -28.3464, -4.9467, 41.0267, -28.496698, -6.1773996, 44.4883, -30.0795, -6.1549, 42.738598, -28.3464, -4.9467, 41.0267, -26.7572, -4.9595, 42.7343, -28.496698, -6.1773996, 44.4883, -37.44, -9.802, 50.008, -37.4804, -10.4337, 53.5453, -39.1638, -10.4337, 51.7104, -37.44, -9.802, 50.008, -35.786, -9.8073, 51.8372, -37.4804, -10.4337, 53.5453, -30.0795, -6.1549, 42.738598, -30.0148, -7.2503, 46.0191, -31.612902, -7.2328997, 44.2532, -30.0795, -6.1549, 42.738598, -28.496698, -6.1773996, 44.4883, -30.0148, -7.2503, 46.0191, -32.901, -8.1437, 45.5256, -32.6904, -8.6662, 48.7166, -34.3383, -8.6685, 46.945, -32.901, -8.1437, 45.5256, -31.270601, -8.1437, 47.285297, -32.6904, -8.6662, 48.7166, -35.7329, -9.177899, 48.322197, -35.786, -9.8073, 51.8372, -37.44, -9.802, 50.008, -35.7329, -9.177899, 48.322197, -34.0797, -9.1781, 50.1171, -35.786, -9.8073, 51.8372, -31.612902, -7.2328997, 44.2532, -31.270601, -8.1437, 47.285297, -32.901, -8.1437, 45.5256, -31.612902, -7.2328997, 44.2532, -30.0148, -7.2503, 46.0191, -31.270601, -8.1437, 47.285297, -26.7572, -4.9595, 42.7343, -26.7572, -6.2019997, 46.4111, -28.496698, -6.1773996, 44.4883, -35.786, -9.8073, 51.8372, -35.735397, -10.4337, 55.4473, -37.4804, -10.4337, 53.5453, -35.786, -9.8073, 51.8372, -34.0528, -9.8129, 53.753998, -35.735397, -10.4337, 55.4473, -28.496698, -6.1773996, 44.4883, -28.2762, -7.2693996, 47.940197, -30.0148, -7.2503, 46.0191, -28.496698, -6.1773996, 44.4883, -26.7572, -6.2019997, 46.4111, -28.2762, -7.2693996, 47.940197, -32.6904, -8.6662, 48.7166, -32.3284, -9.1783, 52.0186, -34.0797, -9.1781, 50.1171, -32.6904, -8.6662, 48.7166, -30.9289, -8.663899, 50.6101, -32.3284, -9.1783, 52.0186, -31.270601, -8.1437, 47.285297, -30.9289, -8.663899, 50.6101, -32.6904, -8.6662, 48.7166, -31.270601, -8.1437, 47.285297, -29.511599, -8.1437, 49.1838, -30.9289, -8.663899, 50.6101, -34.0797, -9.1781, 50.1171, -34.0528, -9.8129, 53.753998, -35.786, -9.8073, 51.8372, -34.0797, -9.1781, 50.1171, -32.3284, -9.1783, 52.0186, -34.0528, -9.8129, 53.753998, -30.0148, -7.2503, 46.0191, -29.511599, -8.1437, 49.1838, -31.270601, -8.1437, 47.285297, -30.0148, -7.2503, 46.0191, -28.2762, -7.2693996, 47.940197, -29.511599, -8.1437, 49.1838, -28.2762, -7.2693996, 47.940197, -27.9641, -8.1437, 50.853897, -29.511599, -8.1437, 49.1838, -28.2762, -7.2693996, 47.940197, -26.7572, -7.286, 49.6186, -27.9641, -8.1437, 50.853897, -34.0528, -9.8129, 53.753998, -34.1461, -10.4337, 57.1796, -35.735397, -10.4337, 55.4473, -34.0528, -9.8129, 53.753998, -32.4888, -9.8179, 55.4837, -34.1461, -10.4337, 57.1796, -26.7572, -6.2019997, 46.4111, -26.7572, -7.286, 49.6186, -28.2762, -7.2693996, 47.940197, -30.9289, -8.663899, 50.6101, -30.7632, -9.1784, 53.718, -32.3284, -9.1783, 52.0186, -30.9289, -8.663899, 50.6101, -29.3669, -8.6617, 52.2893, -30.7632, -9.1784, 53.718, -29.511599, -8.1437, 49.1838, -29.3669, -8.6617, 52.2893, -30.9289, -8.663899, 50.6101, -29.511599, -8.1437, 49.1838, -27.9641, -8.1437, 50.853897, -29.3669, -8.6617, 52.2893, -32.3284, -9.1783, 52.0186, -32.4888, -9.8179, 55.4837, -34.0528, -9.8129, 53.753998, -32.3284, -9.1783, 52.0186, -30.7632, -9.1784, 53.718, -32.4888, -9.8179, 55.4837, -29.3669, -8.6617, 52.2893, -29.525799, -9.1785, 55.061497, -30.7632, -9.1784, 53.718, -29.3669, -8.6617, 52.2893, -28.140299, -8.6601, 53.6078, -29.525799, -9.1785, 55.061497, -27.9641, -8.1437, 50.853897, -28.140299, -8.6601, 53.6078, -29.3669, -8.6617, 52.2893, -27.9641, -8.1437, 50.853897, -26.7572, -8.1437, 52.156597, -28.140299, -8.6601, 53.6078, -30.7632, -9.1784, 53.718, -31.2423, -9.821899, 56.862297, -32.4888, -9.8179, 55.4837, -30.7632, -9.1784, 53.718, -29.525799, -9.1785, 55.061497, -31.2423, -9.821899, 56.862297, -26.7572, -7.286, 49.6186, -26.7572, -8.1437, 52.156597, -27.9641, -8.1437, 50.853897, -32.4888, -9.8179, 55.4837, -32.870197, -10.4337, 58.570297, -34.1461, -10.4337, 57.1796, -32.4888, -9.8179, 55.4837, -31.2423, -9.821899, 56.862297, -32.870197, -10.4337, 58.570297, -28.140299, -8.6601, 53.6078, -28.1322, -9.1786995, 56.5746, -29.525799, -9.1785, 55.061497, -28.140299, -8.6601, 53.6078, -26.7572, -8.658199, 55.0947, -28.1322, -9.1786995, 56.5746, -26.7572, -8.1437, 52.156597, -26.7572, -8.658199, 55.0947, -28.140299, -8.6601, 53.6078, -29.525799, -9.1785, 55.061497, -29.8403, -9.8265, 58.4128, -31.2423, -9.821899, 56.862297, -29.525799, -9.1785, 55.061497, -28.1322, -9.1786995, 56.5746, -29.8403, -9.8265, 58.4128, -31.2423, -9.821899, 56.862297, -31.437601, -10.4337, 60.131798, -32.870197, -10.4337, 58.570297, -31.2423, -9.821899, 56.862297, -29.8403, -9.8265, 58.4128, -31.437601, -10.4337, 60.131798, -26.7572, -8.658199, 55.0947, -26.7572, -9.1788, 58.067497, -28.1322, -9.1786995, 56.5746, -28.1322, -9.1786995, 56.5746, -28.4674, -9.8309, 59.9312, -29.8403, -9.8265, 58.4128, -28.1322, -9.1786995, 56.5746, -26.7572, -9.1788, 58.067497, -28.4674, -9.8309, 59.9312, -29.8403, -9.8265, 58.4128, -30.044802, -10.4337, 61.649998, -31.437601, -10.4337, 60.131798, -29.8403, -9.8265, 58.4128, -28.4674, -9.8309, 59.9312, -30.044802, -10.4337, 61.649998, -28.4674, -9.8309, 59.9312, -28.2834, -10.4337, 63.569798, -30.044802, -10.4337, 61.649998, -28.4674, -9.8309, 59.9312, -26.7572, -9.8364, 61.822598, -28.2834, -10.4337, 63.569798, -26.7572, -9.1788, 58.067497, -26.7572, -9.8364, 61.822598, -28.4674, -9.8309, 59.9312, -26.7572, -9.8364, 61.822598, -26.7572, -10.4337, 65.2334, -28.2834, -10.4337, 63.569798, -39.7387, -7.144, 35.2747, -39.4027, -8.1437, 38.5083, -41.179398, -8.1437, 36.5907, -39.7387, -7.144, 35.2747, -37.9957, -7.1629996, 37.2007, -39.4027, -8.1437, 38.5083, -34.806297, -3.7187998, 30.769499, -34.7309, -4.8956, 34.1667, -36.474598, -4.8817, 32.293198, -34.806297, -3.7187998, 30.769499, -33.0801, -3.7473, 32.6325, -34.7309, -4.8956, 34.1667, -45.7693, -9.7752, 40.7963, -45.8121, -10.4337, 44.463898, -47.620697, -10.4337, 42.4927, -45.7693, -9.7752, 40.7963, -43.9867, -9.7809, 42.7677, -45.8121, -10.4337, 44.463898, -36.474598, -4.8817, 32.293198, -36.416798, -6.0650997, 35.7334, -38.147, -6.0406, 33.8208, -36.474598, -4.8817, 32.293198, -34.7309, -4.8956, 34.1667, -36.416798, -6.0650997, 35.7334, -38.147, -6.0406, 33.8208, -37.9957, -7.1629996, 37.2007, -39.7387, -7.144, 35.2747, -38.147, -6.0406, 33.8208, -36.416798, -6.0650997, 35.7334, -37.9957, -7.1629996, 37.2007, -41.179398, -8.1437, 36.5907, -40.895798, -8.6774, 39.8957, -42.6861, -8.6798, 37.9712, -41.179398, -8.1437, 36.5907, -39.4027, -8.1437, 38.5083, -40.895798, -8.6774, 39.8957, -42.6861, -8.6798, 37.9712, -42.2959, -9.1772995, 41.196598, -44.0855, -9.1771, 39.253498, -42.6861, -8.6798, 37.9712, -40.895798, -8.6774, 39.8957, -42.2959, -9.1772995, 41.196598, -44.0855, -9.1771, 39.253498, -43.9867, -9.7809, 42.7677, -45.7693, -9.7752, 40.7963, -44.0855, -9.1771, 39.253498, -42.2959, -9.1772995, 41.196598, -43.9867, -9.7809, 42.7677, -41.209297, -7.1278996, 33.6499, -41.179398, -8.1437, 36.5907, -42.6902, -8.1437, 34.960197, -41.209297, -7.1278996, 33.6499, -39.7387, -7.144, 35.2747, -41.179398, -8.1437, 36.5907, -36.2241, -3.6952999, 29.2393, -36.474598, -4.8817, 32.293198, -37.9203, -4.8701, 30.739899, -36.2241, -3.6952999, 29.2393, -34.806297, -3.7187998, 30.769499, -36.474598, -4.8817, 32.293198, -47.322998, -9.7702, 39.078, -47.620697, -10.4337, 42.4927, -49.212498, -10.4337, 40.7576, -47.322998, -9.7702, 39.078, -45.7693, -9.7752, 40.7963, -47.620697, -10.4337, 42.4927, -37.9203, -4.8701, 30.739899, -38.147, -6.0406, 33.8208, -39.5943, -6.0200996, 32.221, -37.9203, -4.8701, 30.739899, -36.474598, -4.8817, 32.293198, -38.147, -6.0406, 33.8208, -39.5943, -6.0200996, 32.221, -39.7387, -7.144, 35.2747, -41.209297, -7.1278996, 33.6499, -39.5943, -6.0200996, 32.221, -38.147, -6.0406, 33.8208, -39.7387, -7.144, 35.2747, -42.6902, -8.1437, 34.960197, -42.6861, -8.6798, 37.9712, -44.2212, -8.6819, 36.321, -42.6902, -8.1437, 34.960197, -41.179398, -8.1437, 36.5907, -42.6861, -8.6798, 37.9712, -44.2212, -8.6819, 36.321, -44.0855, -9.1771, 39.253498, -45.6317, -9.177, 37.5747, -44.2212, -8.6819, 36.321, -42.6861, -8.6798, 37.9712, -44.0855, -9.1771, 39.253498, -45.6317, -9.177, 37.5747, -45.7693, -9.7752, 40.7963, -47.322998, -9.7702, 39.078, -45.6317, -9.177, 37.5747, -44.0855, -9.1771, 39.253498, -45.7693, -9.7752, 40.7963, -42.4946, -7.1138, 32.2296, -42.6902, -8.1437, 34.960197, -44.0089, -8.1437, 33.5369, -42.4946, -7.1138, 32.2296, -41.209297, -7.1278996, 33.6499, -42.6902, -8.1437, 34.960197, -37.4711, -3.6747, 27.8934, -37.9203, -4.8701, 30.739899, -39.1893, -4.8599997, 29.3764, -37.4711, -3.6747, 27.8934, -36.2241, -3.6952999, 29.2393, -37.9203, -4.8701, 30.739899, -48.6723, -9.7657995, 37.585697, -49.212498, -10.4337, 40.7576, -50.5928, -10.4337, 39.2532, -48.6723, -9.7657995, 37.585697, -47.322998, -9.7702, 39.078, -49.212498, -10.4337, 40.7576, -39.1893, -4.8599997, 29.3764, -39.5943, -6.0200996, 32.221, -40.8617, -6.0021, 30.82, -39.1893, -4.8599997, 29.3764, -37.9203, -4.8701, 30.739899, -39.5943, -6.0200996, 32.221, -40.8617, -6.0021, 30.82, -41.209297, -7.1278996, 33.6499, -42.4946, -7.1138, 32.2296, -40.8617, -6.0021, 30.82, -39.5943, -6.0200996, 32.221, -41.209297, -7.1278996, 33.6499, -44.0089, -8.1437, 33.5369, -44.2212, -8.6819, 36.321, -45.559097, -8.6837, 34.882698, -44.0089, -8.1437, 33.5369, -42.6902, -8.1437, 34.960197, -44.2212, -8.6819, 36.321, -45.559097, -8.6837, 34.882698, -45.6317, -9.177, 37.5747, -46.9771, -9.1769, 36.114, -45.559097, -8.6837, 34.882698, -44.2212, -8.6819, 36.321, -45.6317, -9.177, 37.5747, -46.9771, -9.1769, 36.114, -47.322998, -9.7702, 39.078, -48.6723, -9.7657995, 37.585697, -46.9771, -9.1769, 36.114, -45.6317, -9.177, 37.5747, -47.322998, -9.7702, 39.078, -43.5821, -7.1018996, 31.028, -44.0089, -8.1437, 33.5369, -45.125797, -8.1437, 32.3315, -43.5821, -7.1018996, 31.028, -42.4946, -7.1138, 32.2296, -44.0089, -8.1437, 33.5369, -38.5239, -3.6573, 26.7572, -39.1893, -4.8599997, 29.3764, -40.2616, -4.8514, 28.2242, -38.5239, -3.6573, 26.7572, -37.4711, -3.6747, 27.8934, -39.1893, -4.8599997, 29.3764, -49.816998, -9.7621, 36.319798, -50.5928, -10.4337, 39.2532, -51.764698, -10.4337, 37.9757, -49.816998, -9.7621, 36.319798, -48.6723, -9.7657995, 37.585697, -50.5928, -10.4337, 39.2532, -40.2616, -4.8514, 28.2242, -40.8617, -6.0021, 30.82, -41.9332, -5.9869, 29.6356, -40.2616, -4.8514, 28.2242, -39.1893, -4.8599997, 29.3764, -40.8617, -6.0021, 30.82, -41.9332, -5.9869, 29.6356, -42.4946, -7.1138, 32.2296, -43.5821, -7.1018996, 31.028, -41.9332, -5.9869, 29.6356, -40.8617, -6.0021, 30.82, -42.4946, -7.1138, 32.2296, -45.125797, -8.1437, 32.3315, -45.559097, -8.6837, 34.882698, -46.692997, -8.6852, 33.6638, -45.125797, -8.1437, 32.3315, -44.0089, -8.1437, 33.5369, -45.559097, -8.6837, 34.882698, -46.692997, -8.6852, 33.6638, -46.9771, -9.1769, 36.114, -48.118, -9.1768, 34.875298, -46.692997, -8.6852, 33.6638, -45.559097, -8.6837, 34.882698, -46.9771, -9.1769, 36.114, -48.118, -9.1768, 34.875298, -48.6723, -9.7657995, 37.585697, -49.816998, -9.7621, 36.319798, -48.118, -9.1768, 34.875298, -46.9771, -9.1769, 36.114, -48.6723, -9.7657995, 37.585697, -44.939, -7.0871, 29.5287, -45.125797, -8.1437, 32.3315, -46.506897, -8.1437, 30.840899, -44.939, -7.0871, 29.5287, -43.5821, -7.1018996, 31.028, -45.125797, -8.1437, 32.3315, -51.1965, -9.7577, 34.794, -51.764698, -10.4337, 37.9757, -53.163, -10.4337, 36.4517, -51.1965, -9.7577, 34.794, -49.816998, -9.7621, 36.319798, -51.764698, -10.4337, 37.9757, -41.627, -4.8403997, 26.7572, -41.9332, -5.9869, 29.6356, -43.2833, -5.9677997, 28.143099, -41.627, -4.8403997, 26.7572, -40.2616, -4.8514, 28.2242, -41.9332, -5.9869, 29.6356, -43.2833, -5.9677997, 28.143099, -43.5821, -7.1018996, 31.028, -44.939, -7.0871, 29.5287, -43.2833, -5.9677997, 28.143099, -41.9332, -5.9869, 29.6356, -43.5821, -7.1018996, 31.028, -46.506897, -8.1437, 30.840899, -46.692997, -8.6852, 33.6638, -48.083, -8.687099, 32.1695, -46.506897, -8.1437, 30.840899, -45.125797, -8.1437, 32.3315, -46.692997, -8.6852, 33.6638, -48.083, -8.687099, 32.1695, -48.118, -9.1768, 34.875298, -49.5056, -9.1765995, 33.3687, -48.083, -8.687099, 32.1695, -46.692997, -8.6852, 33.6638, -48.118, -9.1768, 34.875298, -49.5056, -9.1765995, 33.3687, -49.816998, -9.7621, 36.319798, -51.1965, -9.7577, 34.794, -49.5056, -9.1765995, 33.3687, -48.118, -9.1768, 34.875298, -49.816998, -9.7621, 36.319798, -46.202698, -7.0731997, 28.132399, -46.506897, -8.1437, 30.840899, -47.7968, -8.1437, 29.4487, -46.202698, -7.0731997, 28.132399, -44.939, -7.0871, 29.5287, -46.506897, -8.1437, 30.840899, -52.4946, -9.7535, 33.3584, -53.163, -10.4337, 36.4517, -54.4828, -10.4337, 35.0131, -52.4946, -9.7535, 33.3584, -51.1965, -9.7577, 34.794, -53.163, -10.4337, 36.4517, -44.537098, -5.95, 26.7572, -44.939, -7.0871, 29.5287, -46.202698, -7.0731997, 28.132399, -44.537098, -5.95, 26.7572, -43.2833, -5.9677997, 28.143099, -44.939, -7.0871, 29.5287, -47.7968, -8.1437, 29.4487, -48.083, -8.687099, 32.1695, -49.384697, -8.6889, 30.770199, -47.7968, -8.1437, 29.4487, -46.506897, -8.1437, 30.840899, -48.083, -8.687099, 32.1695, -49.384697, -8.6889, 30.770199, -49.5056, -9.1765995, 33.3687, -50.808, -9.176499, 31.954699, -49.384697, -8.6889, 30.770199, -48.083, -8.687099, 32.1695, -49.5056, -9.1765995, 33.3687, -50.808, -9.176499, 31.954699, -51.1965, -9.7577, 34.794, -52.4946, -9.7535, 33.3584, -50.808, -9.176499, 31.954699, -49.5056, -9.1765995, 33.3687, -51.1965, -9.7577, 34.794, -47.4473, -7.0596, 26.7572, -47.7968, -8.1437, 29.4487, -49.074398, -8.1437, 28.0697, -47.4473, -7.0596, 26.7572, -46.202698, -7.0731997, 28.132399, -47.7968, -8.1437, 29.4487, -53.7992, -9.7493, 31.915699, -54.4828, -10.4337, 35.0131, -55.8173, -10.4337, 33.5585, -53.7992, -9.7493, 31.915699, -52.4946, -9.7535, 33.3584, -54.4828, -10.4337, 35.0131, -49.074398, -8.1437, 28.0697, -49.384697, -8.6889, 30.770199, -50.6806, -8.690599, 29.3771, -49.074398, -8.1437, 28.0697, -47.7968, -8.1437, 29.4487, -49.384697, -8.6889, 30.770199, -50.6806, -8.690599, 29.3771, -50.808, -9.176499, 31.954699, -52.1102, -9.1764, 30.5408, -50.6806, -8.690599, 29.3771, -49.384697, -8.6889, 30.770199, -50.808, -9.176499, 31.954699, -52.1102, -9.1764, 30.5408, -52.4946, -9.7535, 33.3584, -53.7992, -9.7493, 31.915699, -52.1102, -9.1764, 30.5408, -50.808, -9.176499, 31.954699, -52.4946, -9.7535, 33.3584, -50.290497, -8.1437, 26.7572, -50.6806, -8.690599, 29.3771, -51.906998, -8.6923, 28.058699, -50.290497, -8.1437, 26.7572, -49.074398, -8.1437, 28.0697, -50.6806, -8.690599, 29.3771, -51.906998, -8.6923, 28.058699, -52.1102, -9.1764, 30.5408, -53.3363, -9.1763, 29.2095, -51.906998, -8.6923, 28.058699, -50.6806, -8.690599, 29.3771, -52.1102, -9.1764, 30.5408, -55.0202, -9.745399, 30.5653, -55.8173, -10.4337, 33.5585, -57.058, -10.4337, 32.2062, -55.0202, -9.745399, 30.5653, -53.7992, -9.7493, 31.915699, -55.8173, -10.4337, 33.5585, -53.3363, -9.1763, 29.2095, -53.7992, -9.7493, 31.915699, -55.0202, -9.745399, 30.5653, -53.3363, -9.1763, 29.2095, -52.1102, -9.1764, 30.5408, -53.7992, -9.7493, 31.915699, -53.117798, -8.6939, 26.7572, -53.3363, -9.1763, 29.2095, -54.5561, -9.1761, 27.885199, -53.117798, -8.6939, 26.7572, -51.906998, -8.6923, 28.058699, -53.3363, -9.1763, 29.2095, -56.245598, -9.7414, 29.2101, -57.058, -10.4337, 32.2062, -58.316597, -10.4337, 30.834398, -56.245598, -9.7414, 29.2101, -55.0202, -9.745399, 30.5653, -57.058, -10.4337, 32.2062, -54.5561, -9.1761, 27.885199, -55.0202, -9.745399, 30.5653, -56.245598, -9.7414, 29.2101, -54.5561, -9.1761, 27.885199, -53.3363, -9.1763, 29.2095, -55.0202, -9.745399, 30.5653, -57.2836, -9.7381, 28.062199, -58.316597, -10.4337, 30.834398, -59.3761, -10.4337, 29.679499, -57.2836, -9.7381, 28.062199, -56.245598, -9.7414, 29.2101, -58.316597, -10.4337, 30.834398, -55.5951, -9.176, 26.7572, -56.245598, -9.7414, 29.2101, -57.2836, -9.7381, 28.062199, -55.5951, -9.176, 26.7572, -54.5561, -9.1761, 27.885199, -56.245598, -9.7414, 29.2101, -58.463497, -9.7343, 26.7572, -59.3761, -10.4337, 29.679499, -60.611, -10.4337, 28.3335, -58.463497, -9.7343, 26.7572, -57.2836, -9.7381, 28.062199, -59.3761, -10.4337, 29.679499, 20.0676, 0.0021, 3.3446, 23.4123, 0, -0.000100016594, 23.4123, 0, 3.3446, 20.0676, 0.0021, 3.3446, 20.229, 0.030399999, -0.05219996, 23.4123, 0, -0.000100016594, 6.6893, 3.3913999, 3.3446, 10.0336, 3.1929998, -0.000100016594, 10.0336, 3.1929998, 3.3446, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, -0.000100016594, 10.0336, 3.1929998, -0.000100016594, 23.4123, 0, 3.3446, 26.7582, -0.83089995, 6.6893, 23.4123, 0, 6.6893, 23.4123, 0, 3.3446, 26.7575, -0.7312, 3.3446, 26.7582, -0.83089995, 6.6893, 23.4123, 0, 6.6893, 26.7582, -0.83089995, 6.6893, 23.4123, -0.35529998, 10.033899, 20.0676, 0.0021, 3.3446, 20.0676, 0, 10.0338, 17.491, 0.0034999999, 7.408, 17.491, 0.0034999999, 7.408, 14.0257, 0.0442, 7.3251996, 20.0676, 0.0021, 3.3446, 20.0676, 0, 10.0338, 23.4123, 0, 6.6893, 23.4123, -0.35529998, 10.033899, 20.0676, 0.0021, 3.3446, 23.4123, 0, 3.3446, 23.4123, 0, 6.6893, 12.7055, 1.032, 4.0926, 11.277899, 2.3706, -0.000100016594, 14.261799, 0.9069, 1.1804999, 12.7055, 1.032, 4.0926, 11.2782, 2.1174, 3.3446, 11.277899, 2.3706, -0.000100016594, 10.0336, 3.1929998, 3.3446, 11.277899, 2.3706, -0.000100016594, 11.2782, 2.1174, 3.3446, 10.0336, 3.1929998, 3.3446, 10.0336, 3.1929998, -0.000100016594, 11.277899, 2.3706, -0.000100016594, 20.229, 0.030399999, -0.05219996, 20.0676, 0.0021, 3.3446, 14.261799, 0.9069, 1.1804999, 14.0257, 0.0442, 7.3251996, 14.261799, 0.9069, 1.1804999, 20.0676, 0.0021, 3.3446, 12.7055, 1.032, 4.0926, 14.261799, 0.9069, 1.1804999, 14.0257, 0.0442, 7.3251996, 26.7575, -0.7312, 3.3446, 23.4123, 0, 3.3446, 23.4123, 0, -0.000100016594, 20.0676, 0, 10.0338, 20.0676, 0.0021, 3.3446, 23.4123, 0, 6.6893, 17.253021, -20.068901, 7.9789844, 17.227175, -19.735691, 9.261204, 17.026821, -20.231876, 8.073181, 17.253021, -20.068901, 7.9789844, 17.434578, -19.605183, 9.12792, 17.227175, -19.735691, 9.261204, 17.434578, -19.605183, 9.12792, 17.571707, -18.471199, 10.41548, 17.227175, -19.735691, 9.261204, 17.434578, -19.605183, 9.12792, 17.629164, -18.471626, 10.358021, 17.571707, -18.471199, 10.41548, 17.227175, -19.735691, 9.261204, 17.571707, -18.471199, 10.41548, 17.608658, -18.470558, 10.487461, 17.026821, -20.231876, 8.073181, 17.227175, -19.735691, 9.261204, 17.33739, -19.946938, 9.406449, 17.227175, -19.735691, 9.261204, 17.608658, -18.470558, 10.487461, 17.33739, -19.946938, 9.406449, 17.026821, -20.231876, 8.073181, 17.33739, -19.946938, 9.406449, 17.131271, -20.495667, 8.150929, 17.33739, -19.946938, 9.406449, 17.608658, -18.470558, 10.487461, 17.688543, -18.470558, 10.474859, 17.131271, -20.495667, 8.150929, 17.33739, -19.946938, 9.406449, 17.61293, -19.946938, 9.362875, 17.33739, -19.946938, 9.406449, 17.688543, -18.470558, 10.474859, 17.61293, -19.946938, 9.362875, 17.131271, -20.495667, 8.150929, 17.61293, -19.946938, 9.362875, 17.421762, -20.495667, 8.105006, 17.61293, -19.946938, 9.362875, 17.688543, -18.470558, 10.474859, 17.701359, -18.471199, 10.394974, 17.421762, -20.495667, 8.105006, 17.61293, -19.946938, 9.362875, 17.67295, -19.735691, 9.190718, 17.61293, -19.946938, 9.362875, 17.701359, -18.471199, 10.394974, 17.67295, -19.735691, 9.190718, 17.67295, -19.735691, 9.190718, 17.701359, -18.471199, 10.394974, 17.629164, -18.471626, 10.358021, 17.421762, -20.495667, 8.105006, 17.67295, -19.735691, 9.190718, 17.497162, -20.231876, 7.998849, 17.67295, -19.735691, 9.190718, 17.629164, -18.471626, 10.358021, 17.434578, -19.605183, 9.12792, 17.497162, -20.231876, 7.998849, 17.67295, -19.735691, 9.190718, 17.434578, -19.605183, 9.12792, 17.497162, -20.231876, 7.998849, 17.434578, -19.605183, 9.12792, 17.253021, -20.068901, 7.9789844, 16.28308, -22.030998, 7.9881697, 17.28207, -19.97385, 8.213941, 16.7107, -22.030998, 8.612513, 16.28308, -22.030998, 7.9881697, 17.120804, -19.944588, 7.9881687, 17.28207, -19.97385, 8.213941, 17.120804, -19.944588, 7.9881687, 17.36345, -17.907732, 8.087917, 17.28207, -19.97385, 8.213941, 17.120804, -19.944588, 7.9881687, 17.291254, -17.900043, 7.9881682, 17.36345, -17.907732, 8.087917, 17.28207, -19.97385, 8.213941, 17.36345, -17.907732, 8.087917, 17.480074, -17.92012, 8.049685, 16.7107, -22.030998, 8.612513, 17.28207, -19.97385, 8.213941, 17.543299, -20.021269, 8.127647, 17.28207, -19.97385, 8.213941, 17.480074, -17.92012, 8.049685, 17.543299, -20.021269, 8.127647, 16.7107, -22.030998, 8.612513, 17.543299, -20.021269, 8.127647, 17.402325, -22.030998, 8.373924, 17.543299, -20.021269, 8.127647, 17.480074, -17.92012, 8.049685, 17.480074, -17.92012, 7.926439, 17.402325, -22.030998, 8.373924, 17.543299, -20.021269, 8.127647, 17.543299, -20.02127, 7.8486905, 17.543299, -20.021269, 8.127647, 17.480074, -17.92012, 7.926439, 17.543299, -20.02127, 7.8486905, 17.402325, -22.030998, 8.373924, 17.543299, -20.02127, 7.8486905, 17.402325, -22.030998, 7.602201, 17.543299, -20.02127, 7.8486905, 17.480074, -17.92012, 7.926439, 17.36345, -17.907732, 7.8884187, 17.402325, -22.030998, 7.602201, 17.543299, -20.02127, 7.8486905, 17.28207, -19.97385, 7.7623973, 17.543299, -20.02127, 7.8486905, 17.36345, -17.907732, 7.8884187, 17.28207, -19.97385, 7.7623973, 17.28207, -19.97385, 7.7623973, 17.36345, -17.907732, 7.8884187, 17.291254, -17.900043, 7.9881682, 17.402325, -22.030998, 7.602201, 17.28207, -19.97385, 7.7623973, 16.7107, -22.030998, 7.363827, 17.28207, -19.97385, 7.7623973, 17.291254, -17.900043, 7.9881682, 17.120804, -19.944588, 7.9881687, 16.7107, -22.030998, 7.363827, 17.28207, -19.97385, 7.7623973, 17.120804, -19.944588, 7.9881687, 16.7107, -22.030998, 7.363827, 17.120804, -19.944588, 7.9881687, 16.28308, -22.030998, 7.9881697, 17.194283, -19.802975, 7.602841, 18.323994, -19.463997, 6.9496627, 17.395275, -19.968939, 7.750009, 17.194283, -19.802975, 7.602841, 18.09865, -19.331139, 6.83902, 18.323994, -19.463997, 6.9496627, 18.09865, -19.331139, 6.83902, 19.147194, -18.177078, 6.041237, 18.323994, -19.463997, 6.9496627, 18.09865, -19.331139, 6.83902, 19.067095, -18.177505, 6.0213723, 19.147194, -18.177078, 6.041237, 18.323994, -19.463997, 6.9496627, 19.147194, -18.177078, 6.041237, 19.190767, -18.176437, 5.9711766, 17.395275, -19.968939, 7.750009, 18.323994, -19.463997, 6.9496627, 18.392344, -19.679089, 6.777077, 18.323994, -19.463997, 6.9496627, 19.190767, -18.176437, 5.9711766, 18.392344, -19.679089, 6.777077, 17.395275, -19.968939, 7.750009, 18.392344, -19.679089, 6.777077, 17.408092, -20.237429, 7.618006, 18.392344, -19.679089, 6.777077, 19.190767, -18.176437, 5.9711766, 19.137367, -18.176437, 5.908166, 17.408092, -20.237429, 7.618006, 18.392344, -19.679089, 6.777077, 18.209078, -19.679089, 6.560063, 18.392344, -19.679089, 6.777077, 19.137367, -18.176437, 5.908166, 18.209078, -19.679089, 6.560063, 17.408092, -20.237429, 7.618006, 18.209078, -19.679089, 6.560063, 17.214787, -20.237429, 7.389458, 18.209078, -19.679089, 6.560063, 19.137367, -18.176437, 5.908166, 19.061113, -18.177078, 5.9391375, 17.214787, -20.237429, 7.389458, 18.209078, -19.679089, 6.560063, 18.027735, -19.463997, 6.5985103, 18.209078, -19.679089, 6.560063, 19.061113, -18.177078, 5.9391375, 18.027735, -19.463997, 6.5985103, 18.027735, -19.463997, 6.5985103, 19.061113, -18.177078, 5.9391375, 19.067095, -18.177505, 6.0213723, 17.214787, -20.237429, 7.389458, 18.027735, -19.463997, 6.5985103, 17.082785, -19.968939, 7.379846, 18.027735, -19.463997, 6.5985103, 19.067095, -18.177505, 6.0213723, 18.09865, -19.331139, 6.83902, 17.082785, -19.968939, 7.379846, 18.027735, -19.463997, 6.5985103, 18.09865, -19.331139, 6.83902, 17.082785, -19.968939, 7.379846, 18.09865, -19.331139, 6.83902, 17.194283, -19.802975, 7.602841, 19.916355, -18.10766, 4.50975, 18.669378, -17.361996, 4.594761, 18.997677, -18.38085, 4.7551727, 18.997677, -18.38085, 4.7551727, 18.669378, -17.361996, 4.594761, 17.938025, -17.356228, 5.7366476, 18.997677, -18.38085, 4.7551727, 17.938025, -17.356228, 5.7366476, 18.45151, -18.10766, 5.7464733, 19.916355, -18.10766, 4.50975, 19.861246, -17.356228, 4.1126738, 18.669378, -17.361996, 4.594761, 20.759848, -18.352869, 5.1768117, 19.861246, -17.356228, 4.1126738, 19.916355, -18.10766, 4.50975, 18.669378, -17.361996, 4.594761, 18.361372, -16.59625, 5.750745, 17.938025, -17.356228, 5.7366476, 20.759848, -18.352869, 5.1768117, 20.69171, -17.339567, 4.9839334, 19.861246, -17.356228, 4.1126738, 20.916626, -18.072844, 6.2943482, 20.69171, -17.339567, 4.9839334, 20.759848, -18.352869, 5.1768117, 19.861246, -17.356228, 4.1126738, 18.80138, -16.33182, 5.093936, 18.669378, -17.361996, 4.594761, 18.669378, -17.361996, 4.594761, 18.80138, -16.33182, 5.093936, 18.361372, -16.59625, 5.750745, 20.69171, -17.339567, 4.9839334, 19.58528, -16.596252, 4.7173653, 19.861246, -17.356228, 4.1126738, 19.861246, -17.356228, 4.1126738, 19.58528, -16.596252, 4.7173653, 18.80138, -16.33182, 5.093936, 20.916626, -18.072844, 6.2943482, 20.971521, -17.322693, 6.3201933, 20.69171, -17.339567, 4.9839334, 20.971521, -17.322693, 6.3201933, 20.916626, -18.072844, 6.2943482, 20.598795, -18.324886, 7.2226386, 20.69171, -17.339567, 4.9839334, 20.07292, -16.326265, 5.255842, 19.58528, -16.596252, 4.7173653, 20.971521, -17.322693, 6.3201933, 20.07292, -16.326265, 5.255842, 20.69171, -17.339567, 4.9839334, 19.58528, -16.596252, 4.7173653, 19.312944, -15.988569, 5.813969, 18.80138, -16.33182, 5.093936, 19.58528, -16.596252, 4.7173653, 20.07292, -16.326265, 5.255842, 19.312944, -15.988569, 5.813969, 20.971521, -17.322693, 6.3201933, 20.39054, -16.581512, 6.067722, 20.07292, -16.326265, 5.255842, 20.971521, -17.322693, 6.3201933, 20.598795, -18.324886, 7.2226386, 20.51122, -17.317139, 7.2331047, 20.39054, -16.581512, 6.067722, 20.971521, -17.322693, 6.3201933, 20.51122, -17.317139, 7.2331047, 20.51122, -17.317139, 7.2331047, 20.598795, -18.324886, 7.2226386, 19.692719, -18.072844, 7.327728, 20.07292, -16.326265, 5.255842, 20.39054, -16.581512, 6.067722, 19.963133, -16.320711, 6.583985, 20.39054, -16.581512, 6.067722, 20.51122, -17.317139, 7.2331047, 19.963133, -16.320711, 6.583985, 20.07292, -16.326265, 5.255842, 19.963133, -16.320711, 6.583985, 19.312944, -15.988569, 5.813969, 20.51122, -17.317139, 7.2331047, 19.692719, -18.072844, 7.327728, 19.590406, -17.322693, 7.4862165, 19.963133, -16.320711, 6.583985, 20.51122, -17.317139, 7.2331047, 19.590406, -17.322693, 7.4862165, 18.836624, -18.352867, 6.800999, 19.590406, -17.322693, 7.4862165, 19.692719, -18.072844, 7.327728, 19.312944, -15.988569, 5.813969, 19.963133, -16.320711, 6.583985, 19.407354, -16.581512, 6.8975444, 19.963133, -16.320711, 6.583985, 19.590406, -17.322693, 7.4862165, 19.407354, -16.581512, 6.8975444, 18.836624, -18.352867, 6.800999, 18.488676, -17.339567, 6.8439317, 19.590406, -17.322693, 7.4862165, 18.488676, -17.339567, 6.8439317, 19.407354, -16.581512, 6.8975444, 19.590406, -17.322693, 7.4862165, 18.45151, -18.10766, 5.7464733, 18.488676, -17.339567, 6.8439317, 18.836624, -18.352867, 6.800999, 18.45151, -18.10766, 5.7464733, 17.938025, -17.356228, 5.7366476, 18.488676, -17.339567, 6.8439317, 18.488676, -17.339567, 6.8439317, 18.691593, -16.326265, 6.4220786, 19.407354, -16.581512, 6.8975444, 19.312944, -15.988569, 5.813969, 19.407354, -16.581512, 6.8975444, 18.691593, -16.326265, 6.4220786, 17.938025, -17.356228, 5.7366476, 18.691593, -16.326265, 6.4220786, 18.488676, -17.339567, 6.8439317, 18.80138, -16.33182, 5.093936, 19.312944, -15.988569, 5.813969, 18.691593, -16.326265, 6.4220786, 17.938025, -17.356228, 5.7366476, 18.361372, -16.59625, 5.750745, 18.691593, -16.326265, 6.4220786, 18.80138, -16.33182, 5.093936, 18.691593, -16.326265, 6.4220786, 18.361372, -16.59625, 5.750745, 20.916626, -18.072844, 6.2943482, 19.867655, -18.690779, 6.0138965, 20.598795, -18.324886, 7.2226386, 20.916626, -18.072844, 6.2943482, 20.759848, -18.352869, 5.1768117, 19.867655, -18.690779, 6.0138965, 20.598795, -18.324886, 7.2226386, 19.867655, -18.690779, 6.0138965, 18.836624, -18.352867, 6.800999, 20.598795, -18.324886, 7.2226386, 18.836624, -18.352867, 6.800999, 19.692719, -18.072844, 7.327728, 19.867655, -18.690779, 6.0138965, 18.45151, -18.10766, 5.7464733, 18.836624, -18.352867, 6.800999, 19.867655, -18.690779, 6.0138965, 18.997677, -18.38085, 4.7551727, 18.45151, -18.10766, 5.7464733, 20.759848, -18.352869, 5.1768117, 18.997677, -18.38085, 4.7551727, 19.867655, -18.690779, 6.0138965, 20.759848, -18.352869, 5.1768117, 19.916355, -18.10766, 4.50975, 18.997677, -18.38085, 4.7551727, 15.432323, -17.738777, 11.597307, 17.151134, -15.819399, 12.648415, 17.029385, -18.463295, 12.383129, 17.029385, -18.463295, 12.383129, 17.151134, -15.819399, 12.648415, 19.233913, -15.752116, 11.624861, 17.029385, -18.463295, 12.383129, 19.233913, -15.752116, 11.624861, 18.862041, -17.774446, 11.419596, 15.432323, -17.738777, 11.597307, 15.174299, -15.860622, 12.197086, 17.151134, -15.819399, 12.648415, 14.790466, -18.280245, 9.459635, 15.174299, -15.860622, 12.197086, 15.432323, -17.738777, 11.597307, 17.151134, -15.819399, 12.648415, 18.632425, -13.811806, 11.039606, 19.233913, -15.752116, 11.624861, 14.790466, -18.280245, 9.459635, 14.663164, -15.672873, 9.612782, 15.174299, -15.860622, 12.197086, 15.765963, -17.546755, 7.471905, 14.663164, -15.672873, 9.612782, 14.790466, -18.280245, 9.459635, 15.174299, -15.860622, 12.197086, 17.378614, -13.149656, 11.438818, 17.151134, -15.819399, 12.648415, 17.151134, -15.819399, 12.648415, 17.378614, -13.149656, 11.438818, 18.632425, -13.811806, 11.039606, 14.663164, -15.672873, 9.612782, 16.05389, -13.860292, 11.293999, 15.174299, -15.860622, 12.197086, 15.174299, -15.860622, 12.197086, 16.05389, -13.860292, 11.293999, 17.378614, -13.149656, 11.438818, 15.765963, -17.546755, 7.471905, 15.501957, -15.53254, 7.0716248, 14.663164, -15.672873, 9.612782, 15.501957, -15.53254, 7.0716248, 15.765963, -17.546755, 7.471905, 17.558678, -18.333218, 6.7520857, 14.663164, -15.672873, 9.612782, 15.88579, -13.113132, 9.809076, 16.05389, -13.860292, 11.293999, 15.501957, -15.53254, 7.0716248, 15.88579, -13.113132, 9.809076, 14.663164, -15.672873, 9.612782, 16.05389, -13.860292, 11.293999, 17.454443, -12.259815, 9.87465, 17.378614, -13.149656, 11.438818, 16.05389, -13.860292, 11.293999, 15.88579, -13.113132, 9.809076, 17.454443, -12.259815, 9.87465, 15.501957, -15.53254, 7.0716248, 16.199562, -13.714193, 8.2419195, 15.88579, -13.113132, 9.809076, 15.501957, -15.53254, 7.0716248, 17.558678, -18.333218, 6.7520857, 17.574484, -15.715379, 6.749949, 16.199562, -13.714193, 8.2419195, 15.501957, -15.53254, 7.0716248, 17.574484, -15.715379, 6.749949, 17.574484, -15.715379, 6.749949, 17.558678, -18.333218, 6.7520857, 19.186922, -17.80478, 7.9181085, 15.88579, -13.113132, 9.809076, 16.199562, -13.714193, 8.2419195, 17.48456, -13.123599, 8.222482, 16.199562, -13.714193, 8.2419195, 17.574484, -15.715379, 6.749949, 17.48456, -13.123599, 8.222482, 15.88579, -13.113132, 9.809076, 17.48456, -13.123599, 8.222482, 17.454443, -12.259815, 9.87465, 17.574484, -15.715379, 6.749949, 19.186922, -17.80478, 7.9181085, 19.541065, -15.924276, 7.9029427, 17.48456, -13.123599, 8.222482, 17.574484, -15.715379, 6.749949, 19.541065, -15.924276, 7.9029427, 19.797594, -18.516268, 9.675581, 19.541065, -15.924276, 7.9029427, 19.186922, -17.80478, 7.9181085, 17.454443, -12.259815, 9.87465, 17.48456, -13.123599, 8.222482, 18.768915, -13.888488, 8.611227, 17.48456, -13.123599, 8.222482, 19.541065, -15.924276, 7.9029427, 18.768915, -13.888488, 8.611227, 19.797594, -18.516268, 9.675581, 20.062454, -15.861905, 9.785582, 19.541065, -15.924276, 7.9029427, 20.062454, -15.861905, 9.785582, 18.768915, -13.888488, 8.611227, 19.541065, -15.924276, 7.9029427, 18.862041, -17.774446, 11.419596, 20.062454, -15.861905, 9.785582, 19.797594, -18.516268, 9.675581, 18.862041, -17.774446, 11.419596, 19.233913, -15.752116, 11.624861, 20.062454, -15.861905, 9.785582, 20.062454, -15.861905, 9.785582, 18.977385, -13.160124, 9.852436, 18.768915, -13.888488, 8.611227, 17.454443, -12.259815, 9.87465, 18.768915, -13.888488, 8.611227, 18.977385, -13.160124, 9.852436, 19.233913, -15.752116, 11.624861, 18.977385, -13.160124, 9.852436, 20.062454, -15.861905, 9.785582, 17.378614, -13.149656, 11.438818, 17.454443, -12.259815, 9.87465, 18.977385, -13.160124, 9.852436, 19.233913, -15.752116, 11.624861, 18.632425, -13.811806, 11.039606, 18.977385, -13.160124, 9.852436, 17.378614, -13.149656, 11.438818, 18.977385, -13.160124, 9.852436, 18.632425, -13.811806, 11.039606, 15.765963, -17.546755, 7.471905, 17.271177, -19.275177, 9.523714, 17.558678, -18.333218, 6.7520857, 15.765963, -17.546755, 7.471905, 14.790466, -18.280245, 9.459635, 17.271177, -19.275177, 9.523714, 17.558678, -18.333218, 6.7520857, 17.271177, -19.275177, 9.523714, 19.797594, -18.516268, 9.675581, 17.558678, -18.333218, 6.7520857, 19.797594, -18.516268, 9.675581, 19.186922, -17.80478, 7.9181085, 17.271177, -19.275177, 9.523714, 18.862041, -17.774446, 11.419596, 19.797594, -18.516268, 9.675581, 17.271177, -19.275177, 9.523714, 17.029385, -18.463295, 12.383129, 18.862041, -17.774446, 11.419596, 14.790466, -18.280245, 9.459635, 17.029385, -18.463295, 12.383129, 17.271177, -19.275177, 9.523714, 14.790466, -18.280245, 9.459635, 15.432323, -17.738777, 11.597307, 17.029385, -18.463295, 12.383129, 6.450511, 5.6386614, 17.721281, 5.116558, 6.049283, 18.568323, 6.1998763, 5.437825, 17.553066, 6.450511, 5.6386614, 17.721281, 5.394739, 6.2101097, 18.690458, 5.116558, 6.049283, 18.568323, 5.394739, 6.2101097, 18.690458, 4.166137, 7.607539, 19.708622, 5.116558, 6.049283, 18.568323, 5.394739, 6.2101097, 18.690458, 4.2640667, 7.6070127, 19.728611, 4.166137, 7.607539, 19.708622, 5.116558, 6.049283, 18.568323, 4.166137, 7.607539, 19.708622, 4.117188, 7.608329, 19.795757, 6.1998763, 5.437825, 17.553066, 5.116558, 6.049283, 18.568323, 5.0425253, 5.7889595, 18.780462, 5.116558, 6.049283, 18.568323, 4.117188, 7.608329, 19.795757, 5.0425253, 5.7889595, 18.780462, 6.1998763, 5.437825, 17.553066, 5.0425253, 5.7889595, 18.780462, 6.190998, 5.1124864, 17.713438, 5.0425253, 5.7889595, 18.780462, 4.117188, 7.608329, 19.795757, 4.1845627, 7.608329, 19.869196, 6.190998, 5.1124864, 17.713438, 5.0425253, 5.7889595, 18.780462, 5.2750225, 5.7889595, 19.03369, 5.0425253, 5.7889595, 18.780462, 4.1845627, 7.608329, 19.869196, 5.2750225, 5.7889595, 19.03369, 6.190998, 5.1124864, 17.713438, 5.2750225, 5.7889595, 19.03369, 6.436242, 5.1124864, 17.980635, 5.2750225, 5.7889595, 19.03369, 4.1845627, 7.608329, 19.869196, 4.2755394, 7.607539, 19.827774, 6.436242, 5.1124864, 17.980635, 5.2750225, 5.7889595, 19.03369, 5.492837, 6.049283, 18.978226, 5.2750225, 5.7889595, 19.03369, 4.2755394, 7.607539, 19.827774, 5.492837, 6.049283, 18.978226, 5.492837, 6.049283, 18.978226, 4.2755394, 7.607539, 19.827774, 4.2640667, 7.6070127, 19.728611, 6.436242, 5.1124864, 17.980635, 5.492837, 6.049283, 18.978226, 6.596695, 5.437825, 17.985352, 5.492837, 6.049283, 18.978226, 4.2640667, 7.6070127, 19.728611, 5.394739, 6.2101097, 18.690458, 6.596695, 5.437825, 17.985352, 5.492837, 6.049283, 18.978226, 5.394739, 6.2101097, 18.690458, 6.596695, 5.437825, 17.985352, 5.394739, 6.2101097, 18.690458, 6.450511, 5.6386614, 17.721281, 5.7805123, 3.2207327, 16.731695, 6.2294216, 5.7555304, 17.911285, 5.4306893, 3.2207327, 17.595985, 5.7805123, 3.2207327, 16.731695, 6.351206, 5.7918544, 17.591637, 6.2294216, 5.7555304, 17.911285, 6.351206, 5.7918544, 17.591637, 6.4141273, 8.301909, 17.908752, 6.2294216, 5.7555304, 17.911285, 6.351206, 5.7918544, 17.591637, 6.4673533, 8.311122, 17.766653, 6.4141273, 8.301909, 17.908752, 6.2294216, 5.7555304, 17.911285, 6.4141273, 8.301909, 17.908752, 6.532635, 8.286643, 18.002592, 5.4306893, 3.2207327, 17.595985, 6.2294216, 5.7555304, 17.911285, 6.495885, 5.697096, 18.12049, 6.2294216, 5.7555304, 17.911285, 6.532635, 8.286643, 18.002592, 6.495885, 5.697096, 18.12049, 5.4306893, 3.2207327, 17.595985, 6.495885, 5.697096, 18.12049, 6.146731, 3.2207327, 18.143703, 6.495885, 5.697096, 18.12049, 6.532635, 8.286643, 18.002592, 6.6594005, 8.286643, 17.918467, 6.146731, 3.2207327, 18.143703, 6.495885, 5.697096, 18.12049, 6.7823133, 5.697096, 17.930405, 6.495885, 5.697096, 18.12049, 6.6594005, 8.286643, 17.918467, 6.7823133, 5.697096, 17.930405, 6.146731, 3.2207327, 18.143703, 6.7823133, 5.697096, 17.930405, 6.939343, 3.2207327, 17.617693, 6.7823133, 5.697096, 17.930405, 6.6594005, 8.286643, 17.918467, 6.6189694, 8.301909, 17.77281, 6.939343, 3.2207327, 17.617693, 6.7823133, 5.697096, 17.930405, 6.693058, 5.7555304, 17.603598, 6.7823133, 5.697096, 17.930405, 6.6189694, 8.301909, 17.77281, 6.693058, 5.7555304, 17.603598, 6.693058, 5.7555304, 17.603598, 6.6189694, 8.301909, 17.77281, 6.4673533, 8.311122, 17.766653, 6.939343, 3.2207327, 17.617693, 6.693058, 5.7555304, 17.603598, 6.712817, 3.2207322, 16.745113, 6.693058, 5.7555304, 17.603598, 6.4673533, 8.311122, 17.766653, 6.351206, 5.7918544, 17.591637, 6.712817, 3.2207322, 16.745113, 6.693058, 5.7555304, 17.603598, 6.351206, 5.7918544, 17.591637, 6.712817, 3.2207322, 16.745113, 6.351206, 5.7918544, 17.591637, 5.7805123, 3.2207327, 16.731695, 6.732394, 5.966105, 17.783781, 8.337761, 6.384096, 17.883148, 6.83874, 5.7618475, 18.072077, 6.83874, 5.7618475, 18.072077, 6.302865, 5.6723533, 17.875813, 6.732394, 5.966105, 17.783781, 6.83874, 5.7618475, 18.072077, 6.3696475, 5.543639, 18.05737, 6.302865, 5.6723533, 17.875813, 6.732394, 5.966105, 17.783781, 8.181509, 6.5478187, 17.616283, 8.337761, 6.384096, 17.883148, 8.181509, 6.5478187, 17.616283, 9.801905, 7.9697266, 17.51171, 8.337761, 6.384096, 17.883148, 8.181509, 6.5478187, 17.616283, 9.733292, 7.9692, 17.436882, 9.801905, 7.9697266, 17.51171, 8.337761, 6.384096, 17.883148, 9.801905, 7.9697266, 17.51171, 9.894417, 7.970516, 17.469269, 8.337761, 6.384096, 17.883148, 9.894417, 7.970516, 17.469269, 8.525363, 6.119035, 17.752645, 6.83874, 5.7618475, 18.072077, 8.337761, 6.384096, 17.883148, 8.525363, 6.119035, 17.752645, 8.525363, 6.119035, 17.752645, 9.894417, 7.970516, 17.469269, 9.882729, 7.970516, 17.368353, 6.9416494, 5.4307184, 17.94534, 6.3696475, 5.543639, 18.05737, 6.83874, 5.7618475, 18.072077, 6.83874, 5.7618475, 18.072077, 8.525363, 6.119035, 17.752645, 6.9416494, 5.4307184, 17.94534, 6.9416494, 5.4307184, 17.94534, 6.4345694, 5.33517, 17.977638, 6.3696475, 5.543639, 18.05737, 8.525363, 6.119035, 17.752645, 9.882729, 7.970516, 17.368353, 8.485138, 6.119035, 17.40499, 6.9416494, 5.4307184, 17.94534, 8.525363, 6.119035, 17.752645, 8.485138, 6.119035, 17.40499, 8.485138, 6.119035, 17.40499, 9.882729, 7.970516, 17.368353, 9.783121, 7.9697266, 17.348215, 6.899346, 5.4307184, 17.578844, 6.4345694, 5.33517, 17.977638, 6.9416494, 5.4307184, 17.94534, 6.9416494, 5.4307184, 17.94534, 8.485138, 6.119035, 17.40499, 6.899346, 5.4307184, 17.578844, 6.899346, 5.4307184, 17.578844, 6.4079695, 5.33517, 17.746672, 6.4345694, 5.33517, 17.977638, 8.485138, 6.119035, 17.40499, 9.783121, 7.9697266, 17.348215, 8.2728, 6.384096, 17.320663, 6.899346, 5.4307184, 17.578844, 8.485138, 6.119035, 17.40499, 8.2728, 6.384096, 17.320663, 8.2728, 6.384096, 17.320663, 9.783121, 7.9697266, 17.348215, 9.733292, 7.9692, 17.436882, 8.2728, 6.384096, 17.320663, 9.733292, 7.9692, 17.436882, 8.181509, 6.5478187, 17.616283, 6.899346, 5.4307184, 17.578844, 8.2728, 6.384096, 17.320663, 6.7699003, 5.7618475, 17.478994, 6.7699003, 5.7618475, 17.478994, 6.4079695, 5.33517, 17.746672, 6.899346, 5.4307184, 17.578844, 6.7699003, 5.7618475, 17.478994, 8.2728, 6.384096, 17.320663, 8.181509, 6.5478187, 17.616283, 6.7699003, 5.7618475, 17.478994, 6.326483, 5.543639, 17.683863, 6.4079695, 5.33517, 17.746672, 6.7699003, 5.7618475, 17.478994, 8.181509, 6.5478187, 17.616283, 6.732394, 5.966105, 17.783781, 6.732394, 5.966105, 17.783781, 6.326483, 5.543639, 17.683863, 6.7699003, 5.7618475, 17.478994, 6.732394, 5.966105, 17.783781, 6.302865, 5.6723533, 17.875813, 6.326483, 5.543639, 17.683863, 11.635334, 8.055535, 16.46335, 10.297312, 8.974432, 15.70101, 10.525072, 7.7188787, 16.089432, 10.525072, 7.7188787, 16.089432, 10.297312, 8.974432, 15.70101, 8.768291, 8.9812765, 16.37455, 10.525072, 7.7188787, 16.089432, 8.768291, 8.9812765, 16.37455, 9.2888565, 8.055536, 16.734821, 11.635334, 8.055535, 16.46335, 11.849512, 8.981276, 16.018257, 10.297312, 8.974432, 15.70101, 12.046843, 7.75336, 17.723677, 11.849512, 8.981276, 16.018257, 11.635334, 8.055535, 16.46335, 10.297312, 8.974432, 15.70101, 9.193269, 9.9178095, 16.677896, 8.768291, 8.9812765, 16.37455, 12.046843, 7.75336, 17.723677, 12.1083145, 9.00207, 17.478806, 11.849512, 8.981276, 16.018257, 11.446236, 8.09844, 18.977428, 12.1083145, 9.00207, 17.478806, 12.046843, 7.75336, 17.723677, 11.849512, 8.981276, 16.018257, 10.092733, 10.2439375, 16.303373, 10.297312, 8.974432, 15.70101, 10.297312, 8.974432, 15.70101, 10.092733, 10.2439375, 16.303373, 9.193269, 9.9178095, 16.677896, 12.1083145, 9.00207, 17.478806, 11.154192, 9.917809, 16.450953, 11.849512, 8.981276, 16.018257, 11.849512, 8.981276, 16.018257, 11.154192, 9.917809, 16.450953, 10.092733, 10.2439375, 16.303373, 11.446236, 8.09844, 18.977428, 11.4849415, 9.022602, 19.041462, 12.1083145, 9.00207, 17.478806, 11.4849415, 9.022602, 19.041462, 11.446236, 8.09844, 18.977428, 10.487244, 7.7878428, 19.713997, 12.1083145, 9.00207, 17.478806, 11.287758, 10.250781, 17.336258, 11.154192, 9.917809, 16.450953, 11.4849415, 9.022602, 19.041462, 11.287758, 10.250781, 17.336258, 12.1083145, 9.00207, 17.478806, 11.154192, 9.917809, 16.450953, 10.127245, 10.667193, 17.391205, 10.092733, 10.2439375, 16.303373, 11.154192, 9.917809, 16.450953, 11.287758, 10.250781, 17.336258, 10.127245, 10.667193, 17.391205, 11.4849415, 9.022602, 19.041462, 11.060484, 9.936234, 18.386164, 11.287758, 10.250781, 17.336258, 11.4849415, 9.022602, 19.041462, 10.487244, 7.7878428, 19.713997, 10.390107, 9.029709, 19.664736, 11.060484, 9.936234, 18.386164, 11.4849415, 9.022602, 19.041462, 10.390107, 9.029709, 19.664736, 10.390107, 9.029709, 19.664736, 10.487244, 7.7878428, 19.713997, 9.485313, 8.098441, 19.204372, 11.287758, 10.250781, 17.336258, 11.060484, 9.936234, 18.386164, 10.269789, 10.257888, 18.624687, 11.060484, 9.936234, 18.386164, 10.390107, 9.029709, 19.664736, 10.269789, 10.257888, 18.624687, 11.287758, 10.250781, 17.336258, 10.269789, 10.257888, 18.624687, 10.127245, 10.667193, 17.391205, 10.390107, 9.029709, 19.664736, 9.485313, 8.098441, 19.204372, 9.272093, 9.022602, 19.297224, 10.269789, 10.257888, 18.624687, 10.390107, 9.029709, 19.664736, 9.272093, 9.022602, 19.297224, 8.965256, 7.7533607, 18.079897, 9.272093, 9.022602, 19.297224, 9.485313, 8.098441, 19.204372, 10.127245, 10.667193, 17.391205, 10.269789, 10.257888, 18.624687, 9.485554, 9.936234, 18.568289, 10.269789, 10.257888, 18.624687, 9.272093, 9.022602, 19.297224, 9.485554, 9.936234, 18.568289, 8.965256, 7.7533607, 18.079897, 8.57874, 9.002071, 17.886866, 9.272093, 9.022602, 19.297224, 8.57874, 9.002071, 17.886866, 9.485554, 9.936234, 18.568289, 9.272093, 9.022602, 19.297224, 9.2888565, 8.055536, 16.734821, 8.57874, 9.002071, 17.886866, 8.965256, 7.7533607, 18.079897, 9.2888565, 8.055536, 16.734821, 8.768291, 8.9812765, 16.37455, 8.57874, 9.002071, 17.886866, 8.57874, 9.002071, 17.886866, 9.07469, 10.250781, 17.592167, 9.485554, 9.936234, 18.568289, 10.127245, 10.667193, 17.391205, 9.485554, 9.936234, 18.568289, 9.07469, 10.250781, 17.592167, 8.768291, 8.9812765, 16.37455, 9.07469, 10.250781, 17.592167, 8.57874, 9.002071, 17.886866, 10.092733, 10.2439375, 16.303373, 10.127245, 10.667193, 17.391205, 9.07469, 10.250781, 17.592167, 8.768291, 8.9812765, 16.37455, 9.193269, 9.9178095, 16.677896, 9.07469, 10.250781, 17.592167, 10.092733, 10.2439375, 16.303373, 9.07469, 10.250781, 17.592167, 9.193269, 9.9178095, 16.677896, 11.446236, 8.09844, 18.977428, 10.560175, 7.3369484, 17.97454, 10.487244, 7.7878428, 19.713997, 11.446236, 8.09844, 18.977428, 12.046843, 7.75336, 17.723677, 10.560175, 7.3369484, 17.97454, 10.487244, 7.7878428, 19.713997, 10.560175, 7.3369484, 17.97454, 8.965256, 7.7533607, 18.079897, 10.487244, 7.7878428, 19.713997, 8.965256, 7.7533607, 18.079897, 9.485313, 8.098441, 19.204372, 10.560175, 7.3369484, 17.97454, 9.2888565, 8.055536, 16.734821, 8.965256, 7.7533607, 18.079897, 10.560175, 7.3369484, 17.97454, 10.525072, 7.7188787, 16.089432, 9.2888565, 8.055536, 16.734821, 12.046843, 7.75336, 17.723677, 10.525072, 7.7188787, 16.089432, 10.560175, 7.3369484, 17.97454, 12.046843, 7.75336, 17.723677, 11.635334, 8.055535, 16.46335, 10.525072, 7.7188787, 16.089432, 1.4948497, 8.510117, 18.317255, 1.5868134, 10.875402, 20.798342, 1.7763877, 7.617279, 20.49278, 1.7763877, 7.617279, 20.49278, 1.5868134, 10.875402, 20.798342, 4.0570173, 10.958316, 22.239437, 1.7763877, 7.617279, 20.49278, 4.0570173, 10.958316, 22.239437, 4.0145273, 8.465897, 21.717955, 1.4948497, 8.510117, 18.317255, 0.70318604, 10.8246, 18.46102, 1.5868134, 10.875402, 20.798342, 3.2524045, 7.8428564, 16.201567, 0.70318604, 10.8246, 18.46102, 1.4948497, 8.510117, 18.317255, 1.5868134, 10.875402, 20.798342, 4.248084, 13.349396, 21.22304, 4.0570173, 10.958316, 22.239437, 3.2524045, 7.8428564, 16.201567, 3.0084083, 11.055706, 16.17521, 0.70318604, 10.8246, 18.46102, 5.9580865, 8.746749, 15.848719, 3.0084083, 11.055706, 16.17521, 3.2524045, 7.8428564, 16.201567, 0.70318604, 10.8246, 18.46102, 2.983596, 14.165375, 20.207823, 1.5868134, 10.875402, 20.798342, 1.5868134, 10.875402, 20.798342, 2.983596, 14.165375, 20.207823, 4.248084, 13.349396, 21.22304, 3.0084083, 11.055706, 16.17521, 2.2301898, 13.289908, 18.748863, 0.70318604, 10.8246, 18.46102, 0.70318604, 10.8246, 18.46102, 2.2301898, 13.289908, 18.748863, 2.983596, 14.165375, 20.207823, 5.9580865, 8.746749, 15.848719, 6.1891904, 11.228903, 15.304886, 3.0084083, 11.055706, 16.17521, 6.1891904, 11.228903, 15.304886, 5.9580865, 8.746749, 15.848719, 7.9187675, 7.777577, 17.198952, 3.0084083, 11.055706, 16.17521, 3.6397526, 14.210384, 17.564486, 2.2301898, 13.289908, 18.748863, 6.1891904, 11.228903, 15.304886, 3.6397526, 14.210384, 17.564486, 3.0084083, 11.055706, 16.17521, 2.2301898, 13.289908, 18.748863, 4.6415443, 15.261944, 19.219688, 2.983596, 14.165375, 20.207823, 2.2301898, 13.289908, 18.748863, 3.6397526, 14.210384, 17.564486, 4.6415443, 15.261944, 19.219688, 6.1891904, 11.228903, 15.304886, 5.462911, 13.469423, 16.818632, 3.6397526, 14.210384, 17.564486, 6.1891904, 11.228903, 15.304886, 7.9187675, 7.777577, 17.198952, 7.931513, 11.003588, 17.213871, 5.462911, 13.469423, 16.818632, 6.1891904, 11.228903, 15.304886, 7.931513, 11.003588, 17.213871, 7.931513, 11.003588, 17.213871, 7.9187675, 7.777577, 17.198952, 7.831026, 8.428781, 19.665352, 3.6397526, 14.210384, 17.564486, 5.462911, 13.469423, 16.818632, 6.3584847, 14.197486, 18.1248, 5.462911, 13.469423, 16.818632, 7.931513, 11.003588, 17.213871, 6.3584847, 14.197486, 18.1248, 3.6397526, 14.210384, 17.564486, 6.3584847, 14.197486, 18.1248, 4.6415443, 15.261944, 19.219688, 7.931513, 11.003588, 17.213871, 7.831026, 8.428781, 19.665352, 8.087843, 10.745896, 20.01901, 6.3584847, 14.197486, 18.1248, 7.931513, 11.003588, 17.213871, 8.087843, 10.745896, 20.01901, 6.4428244, 7.5519996, 21.489801, 8.087843, 10.745896, 20.01901, 7.831026, 8.428781, 19.665352, 4.6415443, 15.261944, 19.219688, 6.3584847, 14.197486, 18.1248, 6.8342867, 13.254899, 19.708595, 6.3584847, 14.197486, 18.1248, 8.087843, 10.745896, 20.01901, 6.8342867, 13.254899, 19.708595, 6.4428244, 7.5519996, 21.489801, 6.510137, 10.823021, 21.836857, 8.087843, 10.745896, 20.01901, 6.510137, 10.823021, 21.836857, 6.8342867, 13.254899, 19.708595, 8.087843, 10.745896, 20.01901, 4.0145273, 8.465897, 21.717955, 6.510137, 10.823021, 21.836857, 6.4428244, 7.5519996, 21.489801, 4.0145273, 8.465897, 21.717955, 4.0570173, 10.958316, 22.239437, 6.510137, 10.823021, 21.836857, 6.510137, 10.823021, 21.836857, 5.702328, 14.152213, 20.768137, 6.8342867, 13.254899, 19.708595, 4.6415443, 15.261944, 19.219688, 6.8342867, 13.254899, 19.708595, 5.702328, 14.152213, 20.768137, 4.0570173, 10.958316, 22.239437, 5.702328, 14.152213, 20.768137, 6.510137, 10.823021, 21.836857, 2.983596, 14.165375, 20.207823, 4.6415443, 15.261944, 19.219688, 5.702328, 14.152213, 20.768137, 4.0570173, 10.958316, 22.239437, 4.248084, 13.349396, 21.22304, 5.702328, 14.152213, 20.768137, 2.983596, 14.165375, 20.207823, 5.702328, 14.152213, 20.768137, 4.248084, 13.349396, 21.22304, 5.9580865, 8.746749, 15.848719, 4.877001, 6.61652, 18.79238, 7.9187675, 7.777577, 17.198952, 5.9580865, 8.746749, 15.848719, 3.2524045, 7.8428564, 16.201567, 4.877001, 6.61652, 18.79238, 7.9187675, 7.777577, 17.198952, 4.877001, 6.61652, 18.79238, 6.4428244, 7.5519996, 21.489801, 7.9187675, 7.777577, 17.198952, 6.4428244, 7.5519996, 21.489801, 7.831026, 8.428781, 19.665352, 4.877001, 6.61652, 18.79238, 4.0145273, 8.465897, 21.717955, 6.4428244, 7.5519996, 21.489801, 4.877001, 6.61652, 18.79238, 1.7763877, 7.617279, 20.49278, 4.0145273, 8.465897, 21.717955, 3.2524045, 7.8428564, 16.201567, 1.7763877, 7.617279, 20.49278, 4.877001, 6.61652, 18.79238, 3.2524045, 7.8428564, 16.201567, 1.4948497, 8.510117, 18.317255, 1.7763877, 7.617279, 20.49278, -17.758068, 7.5774293, -6.836121, -20.570257, 8.332888, -7.573155, -17.84524, 7.2079325, -7.384581, -17.758068, 7.5774293, -6.836121, -20.386482, 8.628776, -7.04528, -20.570257, 8.332888, -7.573155, -20.386482, 8.628776, -7.04528, -23.297365, 11.199757, -7.426018, -20.570257, 8.332888, -7.573155, -20.386482, 8.628776, -7.04528, -23.201662, 11.198789, -7.2690005, -23.297365, 11.199757, -7.426018, -20.570257, 8.332888, -7.573155, -23.297365, 11.199757, -7.426018, -23.476107, 11.20121, -7.3828626, -17.84524, 7.2079325, -7.384581, -20.570257, 8.332888, -7.573155, -20.948885, 7.853947, -7.4072576, -20.570257, 8.332888, -7.573155, -23.476107, 11.20121, -7.3828626, -20.948885, 7.853947, -7.4072576, -17.84524, 7.2079325, -7.384581, -20.948885, 7.853947, -7.4072576, -18.072498, 6.6093764, -7.195704, -20.948885, 7.853947, -7.4072576, -23.476107, 11.20121, -7.3828626, -23.490705, 11.20121, -7.200089, -18.072498, 6.6093764, -7.195704, -20.948885, 7.853947, -7.4072576, -20.999002, 7.853947, -6.776777, -20.948885, 7.853947, -7.4072576, -23.490705, 11.20121, -7.200089, -20.999002, 7.853947, -6.776777, -18.072498, 6.6093764, -7.195704, -20.999002, 7.853947, -6.776777, -18.12548, 6.6093764, -6.5305486, -20.999002, 7.853947, -6.776777, -23.490705, 11.20121, -7.200089, -23.320944, 11.199757, -7.129349, -18.12548, 6.6093764, -6.5305486, -20.999002, 7.853947, -6.776777, -20.651468, 8.332888, -6.5526786, -20.999002, 7.853947, -6.776777, -23.320944, 11.199757, -7.129349, -20.651468, 8.332888, -6.5526786, -20.651468, 8.332888, -6.5526786, -23.320944, 11.199757, -7.129349, -23.201662, 11.198789, -7.2690005, -18.12548, 6.6093764, -6.5305486, -20.651468, 8.332888, -6.5526786, -17.930893, 7.2079325, -6.308391, -20.651468, 8.332888, -6.5526786, -23.201662, 11.198789, -7.2690005, -20.386482, 8.628776, -7.04528, -17.930893, 7.2079325, -6.308391, -20.651468, 8.332888, -6.5526786, -20.386482, 8.628776, -7.04528, -17.930893, 7.2079325, -6.308391, -20.386482, 8.628776, -7.04528, -17.758068, 7.5774293, -6.836121, -17.263433, 3.1289406, -8.978432, -18.291029, 7.792444, -6.8961415, -18.866385, 3.1289406, -8.367504, -17.263433, 3.1289406, -8.978432, -17.707705, 7.8592725, -7.132322, -18.291029, 7.792444, -6.8961415, -17.707705, 7.8592725, -7.132322, -18.056295, 12.477256, -6.650377, -18.291029, 7.792444, -6.8961415, -17.707705, 7.8592725, -7.132322, -17.798122, 12.494205, -6.756602, -18.056295, 12.477256, -6.650377, -18.291029, 7.792444, -6.8961415, -18.056295, 12.477256, -6.650377, -18.034353, 12.449168, -6.373137, -18.866385, 3.1289406, -8.367504, -18.291029, 7.792444, -6.8961415, -18.239275, 7.6849365, -6.2750163, -18.291029, 7.792444, -6.8961415, -18.034353, 12.449168, -6.373137, -18.239275, 7.6849365, -6.2750163, -18.866385, 3.1289406, -8.367504, -18.239275, 7.6849365, -6.2750163, -18.707829, 3.1289406, -6.716517, -18.239275, 7.6849365, -6.2750163, -18.034353, 12.449168, -6.373137, -17.762217, 12.449168, -6.3076463, -18.707829, 3.1289406, -6.716517, -18.239275, 7.6849365, -6.2750163, -17.624376, 7.6849365, -6.1270385, -18.239275, 7.6849365, -6.2750163, -17.762217, 12.449168, -6.3076463, -17.624376, 7.6849365, -6.1270385, -18.707829, 3.1289406, -6.716517, -17.624376, 7.6849365, -6.1270385, -17.006262, 3.1289406, -6.3070283, -17.624376, 7.6849365, -6.1270385, -17.762217, 12.449168, -6.3076463, -17.616545, 12.477256, -6.5445485, -17.006262, 3.1289406, -6.3070283, -17.624376, 7.6849365, -6.1270385, -17.295702, 7.792444, -6.656613, -17.624376, 7.6849365, -6.1270385, -17.616545, 12.477256, -6.5445485, -17.295702, 7.792444, -6.656613, -17.295702, 7.792444, -6.656613, -17.616545, 12.477256, -6.5445485, -17.798122, 12.494205, -6.756602, -17.006262, 3.1289406, -6.3070283, -17.295702, 7.792444, -6.656613, -16.113934, 3.1289406, -7.705115, -17.295702, 7.792444, -6.656613, -17.798122, 12.494205, -6.756602, -17.707705, 7.8592725, -7.132322, -16.113934, 3.1289406, -7.705115, -17.295702, 7.792444, -6.656613, -17.707705, 7.8592725, -7.132322, -16.113934, 3.1289406, -7.705115, -17.707705, 7.8592725, -7.132322, -17.263433, 3.1289406, -8.978432, -17.48928, 8.179858, -6.3779426, -15.612703, 8.948875, -4.0898643, -17.744648, 7.804066, -5.8735633, -17.744648, 7.804066, -5.8735633, -18.151243, 7.6394153, -6.841582, -17.48928, 8.179858, -6.3779426, -17.744648, 7.804066, -5.8735633, -18.312298, 7.402608, -6.524203, -18.151243, 7.6394153, -6.841582, -17.48928, 8.179858, -6.3779426, -15.448719, 9.250092, -4.6346655, -15.612703, 8.948875, -4.0898643, -15.448719, 9.250092, -4.6346655, -13.278458, 11.866108, -2.5817409, -15.612703, 8.948875, -4.0898643, -15.448719, 9.250092, -4.6346655, -13.263538, 11.865139, -2.7679238, -13.278458, 11.866108, -2.5817409, -15.612703, 8.948875, -4.0898643, -13.278458, 11.866108, -2.5817409, -13.1054, 11.867559, -2.5102086, -15.612703, 8.948875, -4.0898643, -13.1054, 11.867559, -2.5102086, -15.201872, 8.461218, -4.00046, -17.744648, 7.804066, -5.8735633, -15.612703, 8.948875, -4.0898643, -15.201872, 8.461218, -4.00046, -15.201872, 8.461218, -4.00046, -13.1054, 11.867559, -2.5102086, -12.98403, 11.867559, -2.6523442, -17.444958, 7.1948566, -5.8935885, -18.312298, 7.402608, -6.524203, -17.744648, 7.804066, -5.8735633, -17.744648, 7.804066, -5.8735633, -15.201872, 8.461218, -4.00046, -17.444958, 7.1948566, -5.8935885, -17.444958, 7.1948566, -5.8935885, -18.123537, 7.0190673, -6.536556, -18.312298, 7.402608, -6.524203, -15.201872, 8.461218, -4.00046, -12.98403, 11.867559, -2.6523442, -14.783697, 8.461218, -4.490066, -17.444958, 7.1948566, -5.8935885, -15.201872, 8.461218, -4.00046, -14.783697, 8.461218, -4.490066, -14.783697, 8.461218, -4.490066, -12.98403, 11.867559, -2.6523442, -13.081631, 11.866107, -2.811812, -17.003992, 7.1948566, -6.4095883, -18.123537, 7.0190673, -6.536556, -17.444958, 7.1948566, -5.8935885, -17.444958, 7.1948566, -5.8935885, -14.783697, 8.461218, -4.490066, -17.003992, 7.1948566, -6.4095883, -17.003992, 7.1948566, -6.4095883, -17.845564, 7.0190673, -6.8616605, -18.123537, 7.0190673, -6.536556, -14.783697, 8.461218, -4.490066, -13.081631, 11.866107, -2.811812, -14.935968, 8.948875, -4.8818517, -17.003992, 7.1948566, -6.4095883, -14.783697, 8.461218, -4.490066, -14.935968, 8.948875, -4.8818517, -14.935968, 8.948875, -4.8818517, -13.081631, 11.866107, -2.811812, -13.263538, 11.865139, -2.7679238, -14.935968, 8.948875, -4.8818517, -13.263538, 11.865139, -2.7679238, -15.448719, 9.250092, -4.6346655, -17.003992, 7.1948566, -6.4095883, -14.935968, 8.948875, -4.8818517, -17.031534, 7.8040657, -6.7090955, -17.031534, 7.8040657, -6.7090955, -17.845564, 7.0190673, -6.8616605, -17.003992, 7.1948566, -6.4095883, -17.031534, 7.8040657, -6.7090955, -14.935968, 8.948875, -4.8818517, -15.448719, 9.250092, -4.6346655, -17.031534, 7.8040657, -6.7090955, -17.862965, 7.402608, -7.050143, -17.845564, 7.0190673, -6.8616605, -17.031534, 7.8040657, -6.7090955, -15.448719, 9.250092, -4.6346655, -17.48928, 8.179858, -6.3779426, -17.48928, 8.179858, -6.3779426, -17.862965, 7.402608, -7.050143, -17.031534, 7.8040657, -6.7090955, -17.48928, 8.179858, -6.3779426, -18.151243, 7.6394153, -6.841582, -17.862965, 7.402608, -7.050143, -9.569414, 12.023977, -1.423661, -10.217659, 13.714559, -4.1817064, -10.455915, 11.404599, -3.3882995, -10.455915, 11.404599, -3.3882995, -10.217659, 13.714559, -4.1817064, -13.040308, 13.727151, -5.3989296, -10.455915, 11.404599, -3.3882995, -13.040308, 13.727151, -5.3989296, -12.873928, 12.023979, -4.2461476, -9.569414, 12.023977, -1.423661, -8.701311, 13.727149, -1.6924205, -10.217659, 13.714559, -4.1817064, -10.752665, 11.468038, 0.7093334, -8.701311, 13.727149, -1.6924205, -9.569414, 12.023977, -1.423661, -10.217659, 13.714559, -4.1817064, -12.916918, 15.450177, -4.4462667, -13.040308, 13.727151, -5.3989296, -10.752665, 11.468038, 0.7093334, -10.345652, 13.765407, 0.48551416, -8.701311, 13.727149, -1.6924205, -13.194582, 12.102914, 1.4700174, -10.345652, 13.765407, 0.48551416, -10.752665, 11.468038, 0.7093334, -8.701311, 13.727149, -1.6924205, -11.285702, 16.050184, -3.7030501, -10.217659, 13.714559, -4.1817064, -10.217659, 13.714559, -4.1817064, -11.285702, 16.050184, -3.7030501, -12.916918, 15.450177, -4.4462667, -10.345652, 13.765407, 0.48551416, -10.15527, 15.450176, -2.0876474, -8.701311, 13.727149, -1.6924205, -8.701311, 13.727149, -1.6924205, -10.15527, 15.450176, -2.0876474, -11.285702, 16.050184, -3.7030501, -13.194582, 12.102914, 1.4700174, -13.23241, 13.80318, 1.6023726, -10.345652, 13.765407, 0.48551416, -13.23241, 13.80318, 1.6023726, -13.194582, 12.102914, 1.4700174, -15.388298, 11.531479, 1.0999866, -10.345652, 13.765407, 0.48551416, -11.1811695, 16.062777, -0.7989111, -10.15527, 15.450176, -2.0876474, -13.23241, 13.80318, 1.6023726, -11.1811695, 16.062777, -0.7989111, -10.345652, 13.765407, 0.48551416, -10.15527, 15.450176, -2.0876474, -12.708605, 16.828888, -2.2941785, -11.285702, 16.050184, -3.7030501, -10.15527, 15.450176, -2.0876474, -11.1811695, 16.062777, -0.7989111, -12.708605, 16.828888, -2.2941785, -13.23241, 13.80318, 1.6023726, -12.880806, 15.484076, 0.2096405, -11.1811695, 16.062777, -0.7989111, -13.23241, 13.80318, 1.6023726, -15.388298, 11.531479, 1.0999866, -15.443556, 13.8162565, 0.90737486, -12.880806, 15.484076, 0.2096405, -13.23241, 13.80318, 1.6023726, -15.443556, 13.8162565, 0.90737486, -15.443556, 13.8162565, 0.90737486, -15.388298, 11.531479, 1.0999866, -15.956232, 12.102915, -0.88860226, -11.1811695, 16.062777, -0.7989111, -12.880806, 15.484076, 0.2096405, -14.192514, 16.075851, -0.5573001, -12.880806, 15.484076, 0.2096405, -15.443556, 13.8162565, 0.90737486, -14.192514, 16.075851, -0.5573001, -11.1811695, 16.062777, -0.7989111, -14.192514, 16.075851, -0.5573001, -12.708605, 16.828888, -2.2941785, -15.443556, 13.8162565, 0.90737486, -15.956232, 12.102915, -0.88860226, -16.3484, 13.803182, -1.0596855, -14.192514, 16.075851, -0.5573001, -15.443556, 13.8162565, 0.90737486, -16.3484, 13.803182, -1.0596855, -15.09202, 11.4680395, -2.9977593, -16.3484, 13.803182, -1.0596855, -15.956232, 12.102915, -0.88860226, -12.708605, 16.828888, -2.2941785, -14.192514, 16.075851, -0.5573001, -15.09865, 15.4840765, -1.6848848, -14.192514, 16.075851, -0.5573001, -16.3484, 13.803182, -1.0596855, -15.09865, 15.4840765, -1.6848848, -15.09202, 11.4680395, -2.9977593, -15.31592, 13.7654085, -3.7604294, -16.3484, 13.803182, -1.0596855, -15.31592, 13.7654085, -3.7604294, -15.09865, 15.4840765, -1.6848848, -16.3484, 13.803182, -1.0596855, -12.873928, 12.023979, -4.2461476, -15.31592, 13.7654085, -3.7604294, -15.09202, 11.4680395, -2.9977593, -12.873928, 12.023979, -4.2461476, -13.040308, 13.727151, -5.3989296, -15.31592, 13.7654085, -3.7604294, -15.31592, 13.7654085, -3.7604294, -14.29763, 16.062777, -3.461082, -15.09865, 15.4840765, -1.6848848, -12.708605, 16.828888, -2.2941785, -15.09865, 15.4840765, -1.6848848, -14.29763, 16.062777, -3.461082, -13.040308, 13.727151, -5.3989296, -14.29763, 16.062777, -3.461082, -15.31592, 13.7654085, -3.7604294, -11.285702, 16.050184, -3.7030501, -12.708605, 16.828888, -2.2941785, -14.29763, 16.062777, -3.461082, -13.040308, 13.727151, -5.3989296, -12.916918, 15.450177, -4.4462667, -14.29763, 16.062777, -3.461082, -11.285702, 16.050184, -3.7030501, -14.29763, 16.062777, -3.461082, -12.916918, 15.450177, -4.4462667, -13.194582, 12.102914, 1.4700174, -12.95261, 10.701926, -0.98015237, -15.388298, 11.531479, 1.0999866, -13.194582, 12.102914, 1.4700174, -10.752665, 11.468038, 0.7093334, -12.95261, 10.701926, -0.98015237, -15.388298, 11.531479, 1.0999866, -12.95261, 10.701926, -0.98015237, -15.09202, 11.4680395, -2.9977593, -15.388298, 11.531479, 1.0999866, -15.09202, 11.4680395, -2.9977593, -15.956232, 12.102915, -0.88860226, -12.95261, 10.701926, -0.98015237, -12.873928, 12.023979, -4.2461476, -15.09202, 11.4680395, -2.9977593, -12.95261, 10.701926, -0.98015237, -10.455915, 11.404599, -3.3882995, -12.873928, 12.023979, -4.2461476, -10.752665, 11.468038, 0.7093334, -10.455915, 11.404599, -3.3882995, -12.95261, 10.701926, -0.98015237, -10.752665, 11.468038, 0.7093334, -9.569414, 12.023977, -1.423661, -10.455915, 11.404599, -3.3882995, -24.767572, 12.860314, -12.76874, -27.996283, 17.21195, -9.537579, -27.347048, 11.217676, -9.664753, -27.347048, 11.217676, -9.664753, -27.996283, 17.21195, -9.537579, -26.84493, 17.364494, -4.4035864, -27.347048, 11.217676, -9.664753, -26.84493, 17.364494, -4.4035864, -26.195316, 12.778957, -5.113938, -24.767572, 12.860314, -12.76874, -25.952778, 17.118486, -13.655661, -27.996283, 17.21195, -9.537579, -19.715057, 11.632692, -13.049604, -25.952778, 17.118486, -13.655661, -24.767572, 12.860314, -12.76874, -27.996283, 17.21195, -9.537579, -25.235796, 21.763588, -5.4189744, -26.84493, 17.364494, -4.4035864, -19.715057, 11.632692, -13.049604, -19.985107, 17.543673, -13.411457, -25.952778, 17.118486, -13.655661, -15.851015, 13.295669, -9.844912, -19.985107, 17.543673, -13.411457, -19.715057, 11.632692, -13.049604, -25.952778, 17.118486, -13.655661, -25.451134, 23.26482, -8.394608, -27.996283, 17.21195, -9.537579, -27.996283, 17.21195, -9.537579, -25.451134, 23.26482, -8.394608, -25.235796, 21.763588, -5.4189744, -19.985107, 17.543673, -13.411457, -24.42836, 21.654142, -11.237154, -25.952778, 17.118486, -13.655661, -25.952778, 17.118486, -13.655661, -24.42836, 21.654142, -11.237154, -25.451134, 23.26482, -8.394608, -15.851015, 13.295669, -9.844912, -14.828638, 17.862318, -10.214518, -19.985107, 17.543673, -13.411457, -14.828638, 17.862318, -10.214518, -15.851015, 13.295669, -9.844912, -15.215309, 11.512592, -5.5114245, -19.985107, 17.543673, -13.411457, -21.066832, 23.347628, -10.820686, -24.42836, 21.654142, -11.237154, -14.828638, 17.862318, -10.214518, -21.066832, 23.347628, -10.820686, -19.985107, 17.543673, -13.411457, -24.42836, 21.654142, -11.237154, -22.043024, 25.28228, -7.397609, -25.451134, 23.26482, -8.394608, -24.42836, 21.654142, -11.237154, -21.066832, 23.347628, -10.820686, -22.043024, 25.28228, -7.397609, -14.828638, 17.862318, -10.214518, -17.778353, 21.984411, -9.297602, -21.066832, 23.347628, -10.820686, -14.828638, 17.862318, -10.214518, -15.215309, 11.512592, -5.5114245, -15.219456, 17.447784, -5.475563, -17.778353, 21.984411, -9.297602, -14.828638, 17.862318, -10.214518, -15.219456, 17.447784, -5.475563, -15.219456, 17.447784, -5.475563, -15.215309, 11.512592, -5.5114245, -18.649284, 12.710672, -2.540853, -21.066832, 23.347628, -10.820686, -17.778353, 21.984411, -9.297602, -18.417158, 23.323898, -6.4548, -17.778353, 21.984411, -9.297602, -15.219456, 17.447784, -5.475563, -18.417158, 23.323898, -6.4548, -21.066832, 23.347628, -10.820686, -18.417158, 23.323898, -6.4548, -22.043024, 25.28228, -7.397609, -15.219456, 17.447784, -5.475563, -18.649284, 12.710672, -2.540853, -18.8043, 16.973686, -1.7518203, -18.417158, 23.323898, -6.4548, -15.219456, 17.447784, -5.475563, -18.8043, 16.973686, -1.7518203, -22.846716, 11.097576, -2.1269307, -18.8043, 16.973686, -1.7518203, -18.649284, 12.710672, -2.540853, -22.043024, 25.28228, -7.397609, -18.417158, 23.323898, -6.4548, -19.955843, 21.589733, -3.8300514, -18.417158, 23.323898, -6.4548, -18.8043, 16.973686, -1.7518203, -19.955843, 21.589733, -3.8300514, -22.846716, 11.097576, -2.1269307, -23.23016, 17.115578, -1.6015713, -18.8043, 16.973686, -1.7518203, -23.23016, 17.115578, -1.6015713, -19.955843, 21.589733, -3.8300514, -18.8043, 16.973686, -1.7518203, -26.195316, 12.778957, -5.113938, -23.23016, 17.115578, -1.6015713, -22.846716, 11.097576, -2.1269307, -26.195316, 12.778957, -5.113938, -26.84493, 17.364494, -4.4035864, -23.23016, 17.115578, -1.6015713, -23.23016, 17.115578, -1.6015713, -22.801462, 23.240604, -4.0287204, -19.955843, 21.589733, -3.8300514, -22.043024, 25.28228, -7.397609, -19.955843, 21.589733, -3.8300514, -22.801462, 23.240604, -4.0287204, -26.84493, 17.364494, -4.4035864, -22.801462, 23.240604, -4.0287204, -23.23016, 17.115578, -1.6015713, -25.451134, 23.26482, -8.394608, -22.043024, 25.28228, -7.397609, -22.801462, 23.240604, -4.0287204, -26.84493, 17.364494, -4.4035864, -25.235796, 21.763588, -5.4189744, -22.801462, 23.240604, -4.0287204, -25.451134, 23.26482, -8.394608, -22.801462, 23.240604, -4.0287204, -25.235796, 21.763588, -5.4189744, -15.851015, 13.295669, -9.844912, -21.172243, 9.376487, -7.615418, -15.215309, 11.512592, -5.5114245, -15.851015, 13.295669, -9.844912, -19.715057, 11.632692, -13.049604, -21.172243, 9.376487, -7.615418, -15.215309, 11.512592, -5.5114245, -21.172243, 9.376487, -7.615418, -22.846716, 11.097576, -2.1269307, -15.215309, 11.512592, -5.5114245, -22.846716, 11.097576, -2.1269307, -18.649284, 12.710672, -2.540853, -21.172243, 9.376487, -7.615418, -26.195316, 12.778957, -5.113938, -22.846716, 11.097576, -2.1269307, -21.172243, 9.376487, -7.615418, -27.347048, 11.217676, -9.664753, -26.195316, 12.778957, -5.113938, -19.715057, 11.632692, -13.049604, -27.347048, 11.217676, -9.664753, -21.172243, 9.376487, -7.615418, -19.715057, 11.632692, -13.049604, -24.767572, 12.860314, -12.76874, -27.347048, 11.217676, -9.664753, 15.271845, 5.6313887, -13.373863, 13.937893, 6.0420103, -12.526823, 15.02121, 5.4305525, -13.542078, 15.271845, 5.6313887, -13.373863, 14.216074, 6.202837, -12.404688, 13.937893, 6.0420103, -12.526823, 14.216074, 6.202837, -12.404688, 12.987471, 7.6002665, -11.386524, 13.937893, 6.0420103, -12.526823, 14.216074, 6.202837, -12.404688, 13.085402, 7.59974, -11.366533, 12.987471, 7.6002665, -11.386524, 13.937893, 6.0420103, -12.526823, 12.987471, 7.6002665, -11.386524, 12.938522, 7.601056, -11.299387, 15.02121, 5.4305525, -13.542078, 13.937893, 6.0420103, -12.526823, 13.863859, 5.781687, -12.314682, 13.937893, 6.0420103, -12.526823, 12.938522, 7.601056, -11.299387, 13.863859, 5.781687, -12.314682, 15.02121, 5.4305525, -13.542078, 13.863859, 5.781687, -12.314682, 15.012333, 5.1052136, -13.381706, 13.863859, 5.781687, -12.314682, 12.938522, 7.601056, -11.299387, 13.0058975, 7.601056, -11.22595, 15.012333, 5.1052136, -13.381706, 13.863859, 5.781687, -12.314682, 14.096357, 5.781687, -12.061455, 13.863859, 5.781687, -12.314682, 13.0058975, 7.601056, -11.22595, 14.096357, 5.781687, -12.061455, 15.012333, 5.1052136, -13.381706, 14.096357, 5.781687, -12.061455, 15.257576, 5.1052136, -13.11451, 14.096357, 5.781687, -12.061455, 13.0058975, 7.601056, -11.22595, 13.096874, 7.6002665, -11.26737, 15.257576, 5.1052136, -13.11451, 14.096357, 5.781687, -12.061455, 14.314171, 6.0420103, -12.116919, 14.096357, 5.781687, -12.061455, 13.096874, 7.6002665, -11.26737, 14.314171, 6.0420103, -12.116919, 14.314171, 6.0420103, -12.116919, 13.096874, 7.6002665, -11.26737, 13.085402, 7.59974, -11.366533, 15.257576, 5.1052136, -13.11451, 14.314171, 6.0420103, -12.116919, 15.41803, 5.4305525, -13.109793, 14.314171, 6.0420103, -12.116919, 13.085402, 7.59974, -11.366533, 14.216074, 6.202837, -12.404688, 15.41803, 5.4305525, -13.109793, 14.314171, 6.0420103, -12.116919, 14.216074, 6.202837, -12.404688, 15.41803, 5.4305525, -13.109793, 14.216074, 6.202837, -12.404688, 15.271845, 5.6313887, -13.373863, 14.601847, 3.21346, -14.363449, 15.050756, 5.7482576, -13.183859, 14.252024, 3.21346, -13.499159, 14.601847, 3.21346, -14.363449, 15.172541, 5.7845817, -13.503508, 15.050756, 5.7482576, -13.183859, 15.172541, 5.7845817, -13.503508, 15.235462, 8.294637, -13.186392, 15.050756, 5.7482576, -13.183859, 15.172541, 5.7845817, -13.503508, 15.288689, 8.303849, -13.328491, 15.235462, 8.294637, -13.186392, 15.050756, 5.7482576, -13.183859, 15.235462, 8.294637, -13.186392, 15.35397, 8.27937, -13.092552, 14.252024, 3.21346, -13.499159, 15.050756, 5.7482576, -13.183859, 15.317219, 5.689823, -12.974655, 15.050756, 5.7482576, -13.183859, 15.35397, 8.27937, -13.092552, 15.317219, 5.689823, -12.974655, 14.252024, 3.21346, -13.499159, 15.317219, 5.689823, -12.974655, 14.968065, 3.21346, -12.951441, 15.317219, 5.689823, -12.974655, 15.35397, 8.27937, -13.092552, 15.480734, 8.27937, -13.17668, 14.968065, 3.21346, -12.951441, 15.317219, 5.689823, -12.974655, 15.603647, 5.689823, -13.16474, 15.317219, 5.689823, -12.974655, 15.480734, 8.27937, -13.17668, 15.603647, 5.689823, -13.16474, 14.968065, 3.21346, -12.951441, 15.603647, 5.689823, -13.16474, 15.760677, 3.21346, -13.477451, 15.603647, 5.689823, -13.16474, 15.480734, 8.27937, -13.17668, 15.440304, 8.294637, -13.322334, 15.760677, 3.21346, -13.477451, 15.603647, 5.689823, -13.16474, 15.514393, 5.7482576, -13.491547, 15.603647, 5.689823, -13.16474, 15.440304, 8.294637, -13.322334, 15.514393, 5.7482576, -13.491547, 15.514393, 5.7482576, -13.491547, 15.440304, 8.294637, -13.322334, 15.288689, 8.303849, -13.328491, 15.760677, 3.21346, -13.477451, 15.514393, 5.7482576, -13.491547, 15.534151, 3.2134595, -14.350031, 15.514393, 5.7482576, -13.491547, 15.288689, 8.303849, -13.328491, 15.172541, 5.7845817, -13.503508, 15.534151, 3.2134595, -14.350031, 15.514393, 5.7482576, -13.491547, 15.172541, 5.7845817, -13.503508, 15.534151, 3.2134595, -14.350031, 15.172541, 5.7845817, -13.503508, 14.601847, 3.21346, -14.363449, 15.553728, 5.9588323, -13.311363, 17.159096, 6.3768234, -13.211996, 15.660074, 5.754575, -13.023067, 15.660074, 5.754575, -13.023067, 15.124199, 5.6650805, -13.219332, 15.553728, 5.9588323, -13.311363, 15.660074, 5.754575, -13.023067, 15.190983, 5.5363665, -13.037777, 15.124199, 5.6650805, -13.219332, 15.553728, 5.9588323, -13.311363, 17.002844, 6.540546, -13.478861, 17.159096, 6.3768234, -13.211996, 17.002844, 6.540546, -13.478861, 18.62324, 7.962454, -13.583435, 17.159096, 6.3768234, -13.211996, 17.002844, 6.540546, -13.478861, 18.554626, 7.9619274, -13.658262, 18.62324, 7.962454, -13.583435, 17.159096, 6.3768234, -13.211996, 18.62324, 7.962454, -13.583435, 18.715752, 7.9632435, -13.625875, 17.159096, 6.3768234, -13.211996, 18.715752, 7.9632435, -13.625875, 17.346697, 6.111762, -13.342499, 15.660074, 5.754575, -13.023067, 17.159096, 6.3768234, -13.211996, 17.346697, 6.111762, -13.342499, 17.346697, 6.111762, -13.342499, 18.715752, 7.9632435, -13.625875, 18.704063, 7.9632435, -13.726791, 15.762983, 5.4234457, -13.149805, 15.190983, 5.5363665, -13.037777, 15.660074, 5.754575, -13.023067, 15.660074, 5.754575, -13.023067, 17.346697, 6.111762, -13.342499, 15.762983, 5.4234457, -13.149805, 15.762983, 5.4234457, -13.149805, 15.255903, 5.327897, -13.117506, 15.190983, 5.5363665, -13.037777, 17.346697, 6.111762, -13.342499, 18.704063, 7.9632435, -13.726791, 17.30647, 6.111762, -13.690155, 15.762983, 5.4234457, -13.149805, 17.346697, 6.111762, -13.342499, 17.30647, 6.111762, -13.690155, 17.30647, 6.111762, -13.690155, 18.704063, 7.9632435, -13.726791, 18.604456, 7.962454, -13.746929, 15.72068, 5.4234457, -13.5163, 15.255903, 5.327897, -13.117506, 15.762983, 5.4234457, -13.149805, 15.762983, 5.4234457, -13.149805, 17.30647, 6.111762, -13.690155, 15.72068, 5.4234457, -13.5163, 15.72068, 5.4234457, -13.5163, 15.229303, 5.327897, -13.348473, 15.255903, 5.327897, -13.117506, 17.30647, 6.111762, -13.690155, 18.604456, 7.962454, -13.746929, 17.094133, 6.3768234, -13.774481, 15.72068, 5.4234457, -13.5163, 17.30647, 6.111762, -13.690155, 17.094133, 6.3768234, -13.774481, 17.094133, 6.3768234, -13.774481, 18.604456, 7.962454, -13.746929, 18.554626, 7.9619274, -13.658262, 17.094133, 6.3768234, -13.774481, 18.554626, 7.9619274, -13.658262, 17.002844, 6.540546, -13.478861, 15.72068, 5.4234457, -13.5163, 17.094133, 6.3768234, -13.774481, 15.591234, 5.754575, -13.61615, 15.591234, 5.754575, -13.61615, 15.229303, 5.327897, -13.348473, 15.72068, 5.4234457, -13.5163, 15.591234, 5.754575, -13.61615, 17.094133, 6.3768234, -13.774481, 17.002844, 6.540546, -13.478861, 15.591234, 5.754575, -13.61615, 15.147818, 5.5363665, -13.411282, 15.229303, 5.327897, -13.348473, 15.591234, 5.754575, -13.61615, 17.002844, 6.540546, -13.478861, 15.553728, 5.9588323, -13.311363, 15.553728, 5.9588323, -13.311363, 15.147818, 5.5363665, -13.411282, 15.591234, 5.754575, -13.61615, 15.553728, 5.9588323, -13.311363, 15.124199, 5.6650805, -13.219332, 15.147818, 5.5363665, -13.411282, 20.456669, 8.048263, -14.631794, 19.118647, 8.967159, -15.3941345, 19.346407, 7.711606, -15.0057125, 19.346407, 7.711606, -15.0057125, 19.118647, 8.967159, -15.3941345, 17.589626, 8.974004, -14.720594, 19.346407, 7.711606, -15.0057125, 17.589626, 8.974004, -14.720594, 18.110191, 8.048264, -14.360323, 20.456669, 8.048263, -14.631794, 20.670847, 8.974003, -15.076887, 19.118647, 8.967159, -15.3941345, 20.868176, 7.746087, -13.371468, 20.670847, 8.974003, -15.076887, 20.456669, 8.048263, -14.631794, 19.118647, 8.967159, -15.3941345, 18.014603, 9.910537, -14.417248, 17.589626, 8.974004, -14.720594, 20.868176, 7.746087, -13.371468, 20.929647, 8.994798, -13.616339, 20.670847, 8.974003, -15.076887, 20.26757, 8.091167, -12.117716, 20.929647, 8.994798, -13.616339, 20.868176, 7.746087, -13.371468, 20.670847, 8.974003, -15.076887, 18.914066, 10.236665, -14.791771, 19.118647, 8.967159, -15.3941345, 19.118647, 8.967159, -15.3941345, 18.914066, 10.236665, -14.791771, 18.014603, 9.910537, -14.417248, 20.929647, 8.994798, -13.616339, 19.975527, 9.910536, -14.644192, 20.670847, 8.974003, -15.076887, 20.670847, 8.974003, -15.076887, 19.975527, 9.910536, -14.644192, 18.914066, 10.236665, -14.791771, 20.26757, 8.091167, -12.117716, 20.306274, 9.015329, -12.053684, 20.929647, 8.994798, -13.616339, 20.306274, 9.015329, -12.053684, 20.26757, 8.091167, -12.117716, 19.308578, 7.78057, -11.381147, 20.929647, 8.994798, -13.616339, 20.10909, 10.243508, -13.758886, 19.975527, 9.910536, -14.644192, 20.306274, 9.015329, -12.053684, 20.10909, 10.243508, -13.758886, 20.929647, 8.994798, -13.616339, 19.975527, 9.910536, -14.644192, 18.948578, 10.659921, -13.703939, 18.914066, 10.236665, -14.791771, 19.975527, 9.910536, -14.644192, 20.10909, 10.243508, -13.758886, 18.948578, 10.659921, -13.703939, 20.306274, 9.015329, -12.053684, 19.881817, 9.928962, -12.708981, 20.10909, 10.243508, -13.758886, 20.306274, 9.015329, -12.053684, 19.308578, 7.78057, -11.381147, 19.211441, 9.022436, -11.43041, 19.881817, 9.928962, -12.708981, 20.306274, 9.015329, -12.053684, 19.211441, 9.022436, -11.43041, 19.211441, 9.022436, -11.43041, 19.308578, 7.78057, -11.381147, 18.306646, 8.091168, -11.890772, 20.10909, 10.243508, -13.758886, 19.881817, 9.928962, -12.708981, 19.091122, 10.250615, -12.470457, 19.881817, 9.928962, -12.708981, 19.211441, 9.022436, -11.43041, 19.091122, 10.250615, -12.470457, 20.10909, 10.243508, -13.758886, 19.091122, 10.250615, -12.470457, 18.948578, 10.659921, -13.703939, 19.211441, 9.022436, -11.43041, 18.306646, 8.091168, -11.890772, 18.093428, 9.015329, -11.79792, 19.091122, 10.250615, -12.470457, 19.211441, 9.022436, -11.43041, 18.093428, 9.015329, -11.79792, 17.78659, 7.746088, -13.015247, 18.093428, 9.015329, -11.79792, 18.306646, 8.091168, -11.890772, 18.948578, 10.659921, -13.703939, 19.091122, 10.250615, -12.470457, 18.306887, 9.928962, -12.526855, 19.091122, 10.250615, -12.470457, 18.093428, 9.015329, -11.79792, 18.306887, 9.928962, -12.526855, 17.78659, 7.746088, -13.015247, 17.400074, 8.994799, -13.208279, 18.093428, 9.015329, -11.79792, 17.400074, 8.994799, -13.208279, 18.306887, 9.928962, -12.526855, 18.093428, 9.015329, -11.79792, 18.110191, 8.048264, -14.360323, 17.400074, 8.994799, -13.208279, 17.78659, 7.746088, -13.015247, 18.110191, 8.048264, -14.360323, 17.589626, 8.974004, -14.720594, 17.400074, 8.994799, -13.208279, 17.400074, 8.994799, -13.208279, 17.896025, 10.243508, -13.502977, 18.306887, 9.928962, -12.526855, 18.948578, 10.659921, -13.703939, 18.306887, 9.928962, -12.526855, 17.896025, 10.243508, -13.502977, 17.589626, 8.974004, -14.720594, 17.896025, 10.243508, -13.502977, 17.400074, 8.994799, -13.208279, 18.914066, 10.236665, -14.791771, 18.948578, 10.659921, -13.703939, 17.896025, 10.243508, -13.502977, 17.589626, 8.974004, -14.720594, 18.014603, 9.910537, -14.417248, 17.896025, 10.243508, -13.502977, 18.914066, 10.236665, -14.791771, 17.896025, 10.243508, -13.502977, 18.014603, 9.910537, -14.417248, 20.26757, 8.091167, -12.117716, 19.381508, 7.3296757, -13.120605, 19.308578, 7.78057, -11.381147, 20.26757, 8.091167, -12.117716, 20.868176, 7.746087, -13.371468, 19.381508, 7.3296757, -13.120605, 19.308578, 7.78057, -11.381147, 19.381508, 7.3296757, -13.120605, 17.78659, 7.746088, -13.015247, 19.308578, 7.78057, -11.381147, 17.78659, 7.746088, -13.015247, 18.306646, 8.091168, -11.890772, 19.381508, 7.3296757, -13.120605, 18.110191, 8.048264, -14.360323, 17.78659, 7.746088, -13.015247, 19.381508, 7.3296757, -13.120605, 19.346407, 7.711606, -15.0057125, 18.110191, 8.048264, -14.360323, 20.868176, 7.746087, -13.371468, 19.346407, 7.711606, -15.0057125, 19.381508, 7.3296757, -13.120605, 20.868176, 7.746087, -13.371468, 20.456669, 8.048263, -14.631794, 19.346407, 7.711606, -15.0057125, 10.316184, 8.502844, -12.777889, 10.408148, 10.86813, -10.2968025, 10.597721, 7.6100063, -10.6023655, 10.597721, 7.6100063, -10.6023655, 10.408148, 10.86813, -10.2968025, 12.878351, 10.951043, -8.855707, 10.597721, 7.6100063, -10.6023655, 12.878351, 10.951043, -8.855707, 12.835861, 8.458624, -9.37719, 10.316184, 8.502844, -12.777889, 9.524521, 10.8173275, -12.634127, 10.408148, 10.86813, -10.2968025, 12.073738, 7.8355837, -14.893578, 9.524521, 10.8173275, -12.634127, 10.316184, 8.502844, -12.777889, 10.408148, 10.86813, -10.2968025, 13.069418, 13.342123, -9.872105, 12.878351, 10.951043, -8.855707, 12.073738, 7.8355837, -14.893578, 11.829742, 11.048433, -14.919933, 9.524521, 10.8173275, -12.634127, 14.779421, 8.739476, -15.246426, 11.829742, 11.048433, -14.919933, 12.073738, 7.8355837, -14.893578, 9.524521, 10.8173275, -12.634127, 11.80493, 14.158102, -10.887323, 10.408148, 10.86813, -10.2968025, 10.408148, 10.86813, -10.2968025, 11.80493, 14.158102, -10.887323, 13.069418, 13.342123, -9.872105, 11.829742, 11.048433, -14.919933, 11.051523, 13.282636, -12.346281, 9.524521, 10.8173275, -12.634127, 9.524521, 10.8173275, -12.634127, 11.051523, 13.282636, -12.346281, 11.80493, 14.158102, -10.887323, 14.779421, 8.739476, -15.246426, 15.010525, 11.22163, -15.7902565, 11.829742, 11.048433, -14.919933, 15.010525, 11.22163, -15.7902565, 14.779421, 8.739476, -15.246426, 16.7401, 7.770304, -13.896193, 11.829742, 11.048433, -14.919933, 12.461086, 14.203112, -13.530659, 11.051523, 13.282636, -12.346281, 15.010525, 11.22163, -15.7902565, 12.461086, 14.203112, -13.530659, 11.829742, 11.048433, -14.919933, 11.051523, 13.282636, -12.346281, 13.462879, 15.254671, -11.875458, 11.80493, 14.158102, -10.887323, 11.051523, 13.282636, -12.346281, 12.461086, 14.203112, -13.530659, 13.462879, 15.254671, -11.875458, 15.010525, 11.22163, -15.7902565, 14.284246, 13.462151, -14.276512, 12.461086, 14.203112, -13.530659, 15.010525, 11.22163, -15.7902565, 16.7401, 7.770304, -13.896193, 16.752848, 10.996315, -13.881273, 14.284246, 13.462151, -14.276512, 15.010525, 11.22163, -15.7902565, 16.752848, 10.996315, -13.881273, 16.752848, 10.996315, -13.881273, 16.7401, 7.770304, -13.896193, 16.65236, 8.421508, -11.429792, 12.461086, 14.203112, -13.530659, 14.284246, 13.462151, -14.276512, 15.179819, 14.190213, -12.970345, 14.284246, 13.462151, -14.276512, 16.752848, 10.996315, -13.881273, 15.179819, 14.190213, -12.970345, 12.461086, 14.203112, -13.530659, 15.179819, 14.190213, -12.970345, 13.462879, 15.254671, -11.875458, 16.752848, 10.996315, -13.881273, 16.65236, 8.421508, -11.429792, 16.909176, 10.738624, -11.076134, 15.179819, 14.190213, -12.970345, 16.752848, 10.996315, -13.881273, 16.909176, 10.738624, -11.076134, 15.264158, 7.544727, -9.605343, 16.909176, 10.738624, -11.076134, 16.65236, 8.421508, -11.429792, 13.462879, 15.254671, -11.875458, 15.179819, 14.190213, -12.970345, 15.655621, 13.247626, -11.386549, 15.179819, 14.190213, -12.970345, 16.909176, 10.738624, -11.076134, 15.655621, 13.247626, -11.386549, 15.264158, 7.544727, -9.605343, 15.3314705, 10.815748, -9.258287, 16.909176, 10.738624, -11.076134, 15.3314705, 10.815748, -9.258287, 15.655621, 13.247626, -11.386549, 16.909176, 10.738624, -11.076134, 12.835861, 8.458624, -9.37719, 15.3314705, 10.815748, -9.258287, 15.264158, 7.544727, -9.605343, 12.835861, 8.458624, -9.37719, 12.878351, 10.951043, -8.855707, 15.3314705, 10.815748, -9.258287, 15.3314705, 10.815748, -9.258287, 14.523663, 14.14494, -10.327007, 15.655621, 13.247626, -11.386549, 13.462879, 15.254671, -11.875458, 15.655621, 13.247626, -11.386549, 14.523663, 14.14494, -10.327007, 12.878351, 10.951043, -8.855707, 14.523663, 14.14494, -10.327007, 15.3314705, 10.815748, -9.258287, 11.80493, 14.158102, -10.887323, 13.462879, 15.254671, -11.875458, 14.523663, 14.14494, -10.327007, 12.878351, 10.951043, -8.855707, 13.069418, 13.342123, -9.872105, 14.523663, 14.14494, -10.327007, 11.80493, 14.158102, -10.887323, 14.523663, 14.14494, -10.327007, 13.069418, 13.342123, -9.872105, 14.779421, 8.739476, -15.246426, 13.698336, 6.609247, -12.302765, 16.7401, 7.770304, -13.896193, 14.779421, 8.739476, -15.246426, 12.073738, 7.8355837, -14.893578, 13.698336, 6.609247, -12.302765, 16.7401, 7.770304, -13.896193, 13.698336, 6.609247, -12.302765, 15.264158, 7.544727, -9.605343, 16.7401, 7.770304, -13.896193, 15.264158, 7.544727, -9.605343, 16.65236, 8.421508, -11.429792, 13.698336, 6.609247, -12.302765, 12.835861, 8.458624, -9.37719, 15.264158, 7.544727, -9.605343, 13.698336, 6.609247, -12.302765, 10.597721, 7.6100063, -10.6023655, 12.835861, 8.458624, -9.37719, 12.073738, 7.8355837, -14.893578, 10.597721, 7.6100063, -10.6023655, 13.698336, 6.609247, -12.302765, 12.073738, 7.8355837, -14.893578, 10.316184, 8.502844, -12.777889, 10.597721, 7.6100063, -10.6023655, 0.9335377, -18.661976, -2.173924, 1.8889831, -18.823338, -0.58916974, 1.5114222, -18.399822, -1.4170885, 0.9335377, -18.661976, -2.173924, 0.9335377, -18.661974, 1.0975094, 1.8889831, -18.823338, -0.58916974, 1.8889831, -18.823338, -0.58916974, 0.9335377, -18.661974, 1.0975094, 1.6508096, -18.476696, 0.3862325, -0.9279604, -18.475744, -2.1898937, 0.9335377, -18.661976, -2.173924, -0.001098156, -18.845234, -2.413155, -1.7685199, -18.399822, -0.5382087, 0.9335377, -18.661976, -2.173924, -0.9279604, -18.475744, -2.1898937, 0.9335377, -18.661974, 1.0975094, 0.9335377, -18.661976, -2.173924, -1.7685199, -18.399822, -0.5382087, -1.7685199, -18.399822, -0.5382087, -0.9279604, -18.475744, -2.1898937, -1.6464877, -18.661976, -1.4825313, 0.9335377, -18.661974, 1.0975094, -1.7685199, -18.399822, -0.5382087, -0.95520806, -18.661974, 1.0975094, -0.95520806, -18.661974, 1.0975094, -1.7685199, -18.399822, -0.5382087, -1.6464877, -18.661974, 0.40619445, 0.9335377, -18.661974, 1.0975094, -0.95520806, -18.661974, 1.0975094, -0.010835171, -18.399822, 1.2195511, 1.5114222, -18.399822, -1.4170885, -0.010835171, -14.884386, -0.5382099, 0.9335377, -18.661976, -2.173924, 0.9335377, -18.661976, -2.173924, -0.010835171, -14.884386, -0.5382099, -0.001098156, -18.845234, -2.413155, -0.001098156, -18.845234, -2.413155, -0.010835171, -14.884386, -0.5382099, -0.9279604, -18.475744, -2.1898937, -0.9279604, -18.475744, -2.1898937, -0.010835171, -14.884386, -0.5382099, -1.6464877, -18.661976, -1.4825313, -1.6464877, -18.661976, -1.4825313, -0.010835171, -14.884386, -0.5382099, -1.7685199, -18.399822, -0.5382087, -1.7685199, -18.399822, -0.5382087, -0.010835171, -14.884386, -0.5382099, -1.6464877, -18.661974, 0.40619445, -1.6464877, -18.661974, 0.40619445, -0.010835171, -14.884386, -0.5382099, -0.95520806, -18.661974, 1.0975094, -0.95520806, -18.661974, 1.0975094, -0.010835171, -14.884386, -0.5382099, -0.010835171, -18.399822, 1.2195511, -0.010835171, -18.399822, 1.2195511, -0.010835171, -14.884386, -0.5382099, 0.9335377, -18.661974, 1.0975094, 0.9335377, -18.661974, 1.0975094, -0.010835171, -14.884386, -0.5382099, 1.6508096, -18.476696, 0.3862325, 1.6508096, -18.476696, 0.3862325, -0.010835171, -14.884386, -0.5382099, 1.8889831, -18.823338, -0.58916974, 1.8889831, -18.823338, -0.58916974, -0.010835171, -14.884386, -0.5382099, 1.5114222, -18.399822, -1.4170885, 0.79053855, -17.29707, -2.0162632, 1.2353985, -16.7235, -0.5382092, 1.198884, -17.024681, -1.2366474, 0.79053855, -17.29707, -2.0162632, 0.4979446, -16.83131, 0.34912467, 1.2353985, -16.7235, -0.5382092, 1.2353985, -16.7235, -0.5382092, 0.4979446, -16.83131, 0.34912467, 1.198884, -17.024681, 0.16022909, 0.4979446, -16.83131, 0.34912467, -0.88492775, -17.22686, 1.0175039, -0.010835171, -17.024681, 0.8586674, 0.4979446, -16.83131, 0.34912467, -1.2205544, -17.024681, 0.16022909, -0.88492775, -17.22686, 1.0175039, 0.79053855, -17.29707, -2.0162632, -1.2205544, -17.024681, 0.16022909, 0.4979446, -16.83131, 0.34912467, -1.2205544, -17.024681, 0.16022909, 0.79053855, -17.29707, -2.0162632, -0.6996014, -16.880219, -1.4757214, -0.6996014, -16.880219, -1.4757214, 0.79053855, -17.29707, -2.0162632, -0.010835171, -17.024681, -1.9350855, -1.2205544, -17.024681, 0.16022909, -0.6996014, -16.880219, -1.4757214, -1.2205544, -17.024681, -1.2366474, -1.2205544, -17.024681, 0.16022909, -1.2205544, -17.024681, -1.2366474, -1.5400834, -17.234833, -0.5375037, 1.198884, -17.024681, -1.2366474, -0.010835171, -14.231086, -0.53821015, 0.79053855, -17.29707, -2.0162632, 0.79053855, -17.29707, -2.0162632, -0.010835171, -14.231086, -0.53821015, -0.010835171, -17.024681, -1.9350855, -0.010835171, -17.024681, -1.9350855, -0.010835171, -14.231086, -0.53821015, -0.6996014, -16.880219, -1.4757214, -0.6996014, -16.880219, -1.4757214, -0.010835171, -14.231086, -0.53821015, -1.2205544, -17.024681, -1.2366474, -1.2205544, -17.024681, -1.2366474, -0.010835171, -14.231086, -0.53821015, -1.5400834, -17.234833, -0.5375037, -1.5400834, -17.234833, -0.5375037, -0.010835171, -14.231086, -0.53821015, -1.2205544, -17.024681, 0.16022909, -1.2205544, -17.024681, 0.16022909, -0.010835171, -14.231086, -0.53821015, -0.88492775, -17.22686, 1.0175039, -0.88492775, -17.22686, 1.0175039, -0.010835171, -14.231086, -0.53821015, -0.010835171, -17.024681, 0.8586674, -0.010835171, -17.024681, 0.8586674, -0.010835171, -14.231086, -0.53821015, 0.4979446, -16.83131, 0.34912467, 0.4979446, -16.83131, 0.34912467, -0.010835171, -14.231086, -0.53821015, 1.198884, -17.024681, 0.16022909, 1.198884, -17.024681, 0.16022909, -0.010835171, -14.231086, -0.53821015, 1.2353985, -16.7235, -0.5382092, 1.2353985, -16.7235, -0.5382092, -0.010835171, -14.231086, -0.53821015, 1.198884, -17.024681, -1.2366474, 1.3094504, -20.552734, -2.7694182, 2.5816762, -20.552734, -0.51041746, 2.2008963, -20.875694, -1.8151531, 1.3094504, -20.552734, -2.7694182, 1.2661028, -20.875694, 1.6735137, 2.5816762, -20.552734, -0.51041746, 2.5816762, -20.552734, -0.51041746, 1.2661028, -20.875694, 1.6735137, 2.2298722, -20.624132, 0.7323996, -1.2877731, -20.875696, -2.7499266, 1.3094504, -20.552734, -2.7694182, -0.039105892, -21.061094, -3.1164474, -2.5646338, -20.875694, -0.53820777, 1.3094504, -20.552734, -2.7694182, -1.2877731, -20.875696, -2.7499266, 1.2661028, -20.875694, 1.6735137, 1.3094504, -20.552734, -2.7694182, -2.5646338, -20.875694, -0.53820777, -2.5646338, -20.875694, -0.53820777, -1.2877731, -20.875696, -2.7499266, -2.1870723, -20.562729, -1.6952248, 1.2661028, -20.875694, 1.6735137, -2.5646338, -20.875694, -0.53820777, -1.2877731, -20.875694, 1.6735137, -1.2877731, -20.875694, 1.6735137, -2.5646338, -20.875694, -0.53820777, -2.1694045, -20.59831, 0.7562758, 1.2661028, -20.875694, 1.6735137, -1.2877731, -20.875694, 1.6735137, -0.033530712, -21.08287, 2.0138073, 2.2008963, -20.875694, -1.8151531, -0.010835171, -15.767945, -0.5382097, 1.3094504, -20.552734, -2.7694182, 1.3094504, -20.552734, -2.7694182, -0.010835171, -15.767945, -0.5382097, -0.039105892, -21.061094, -3.1164474, -0.039105892, -21.061094, -3.1164474, -0.010835171, -15.767945, -0.5382097, -1.2877731, -20.875696, -2.7499266, -1.2877731, -20.875696, -2.7499266, -0.010835171, -15.767945, -0.5382097, -2.1870723, -20.562729, -1.6952248, -2.1870723, -20.562729, -1.6952248, -0.010835171, -15.767945, -0.5382097, -2.5646338, -20.875694, -0.53820777, -2.5646338, -20.875694, -0.53820777, -0.010835171, -15.767945, -0.5382097, -2.1694045, -20.59831, 0.7562758, -2.1694045, -20.59831, 0.7562758, -0.010835171, -15.767945, -0.5382097, -1.2877731, -20.875694, 1.6735137, -1.2877731, -20.875694, 1.6735137, -0.010835171, -15.767945, -0.5382097, -0.033530712, -21.08287, 2.0138073, -0.033530712, -21.08287, 2.0138073, -0.010835171, -15.767945, -0.5382097, 1.2661028, -20.875694, 1.6735137, 1.2661028, -20.875694, 1.6735137, -0.010835171, -15.767945, -0.5382097, 2.2298722, -20.624132, 0.7323996, 2.2298722, -20.624132, 0.7323996, -0.010835171, -15.767945, -0.5382097, 2.5816762, -20.552734, -0.51041746, 2.5816762, -20.552734, -0.51041746, -0.010835171, -15.767945, -0.5382097, 2.2008963, -20.875694, -1.8151531, 0.5583327, -21.275528, -0.82769513, 0.37442207, -20.354485, -1.1662683, 0.31780314, -21.275528, -1.0682597, 0.5583327, -21.275528, -0.82769513, 0.65641344, -20.354485, -0.8842943, 0.37442207, -20.354485, -1.1662683, 0.6890812, -22.029617, -0.9031601, 0.5583327, -21.275528, -0.82769513, 0.31780314, -21.275528, -1.0682597, 0.6890812, -22.029617, -0.9031601, 0.31780314, -21.275528, -1.0682597, 0.39326835, -22.029617, -1.1989906, 0.31780314, -21.275528, -1.0682597, -0.010835171, -20.354485, -1.2695241, -0.010835171, -21.275528, -1.1563258, 0.31780314, -21.275528, -1.0682597, 0.37442207, -20.354485, -1.1662683, -0.010835171, -20.354485, -1.2695241, 0.39326835, -22.029617, -1.1989906, 0.31780314, -21.275528, -1.0682597, -0.010835171, -21.275528, -1.1563258, 0.39326835, -22.029617, -1.1989906, -0.010835171, -21.275528, -1.1563258, -0.010835171, -22.029617, -1.307256, -0.010835171, -21.275528, -1.1563258, -0.3960147, -20.354485, -1.1662683, -0.339396, -21.275528, -1.0682597, -0.010835171, -21.275528, -1.1563258, -0.010835171, -20.354485, -1.2695241, -0.3960147, -20.354485, -1.1662683, -0.010835171, -22.029617, -1.307256, -0.010835171, -21.275528, -1.1563258, -0.339396, -21.275528, -1.0682597, -0.010835171, -22.029617, -1.307256, -0.339396, -21.275528, -1.0682597, -0.4148612, -22.029617, -1.1989906, -0.339396, -21.275528, -1.0682597, -0.6780064, -20.354485, -0.8842943, -0.580003, -21.275528, -0.82769513, -0.339396, -21.275528, -1.0682597, -0.3960147, -20.354485, -1.1662683, -0.6780064, -20.354485, -0.8842943, -0.4148612, -22.029617, -1.1989906, -0.339396, -21.275528, -1.0682597, -0.580003, -21.275528, -0.82769513, -0.4148612, -22.029617, -1.1989906, -0.580003, -21.275528, -0.82769513, -0.71067405, -22.029617, -0.9031601, -0.580003, -21.275528, -0.82769513, -0.78126955, -20.354485, -0.49906468, -0.6680341, -21.275528, -0.49906445, -0.580003, -21.275528, -0.82769513, -0.6780064, -20.354485, -0.8842943, -0.78126955, -20.354485, -0.49906468, -0.71067405, -22.029617, -0.9031601, -0.580003, -21.275528, -0.82769513, -0.6680341, -21.275528, -0.49906445, -0.71067405, -22.029617, -0.9031601, -0.6680341, -21.275528, -0.49906445, -0.8189645, -22.029617, -0.49906397, -0.6680341, -21.275528, -0.49906445, -0.6780064, -20.354485, -0.11391497, -0.580003, -21.275528, -0.17051363, -0.6680341, -21.275528, -0.49906445, -0.78126955, -20.354485, -0.49906468, -0.6780064, -20.354485, -0.11391497, -0.8189645, -22.029617, -0.49906397, -0.6680341, -21.275528, -0.49906445, -0.580003, -21.275528, -0.17051363, -0.8189645, -22.029617, -0.49906397, -0.580003, -21.275528, -0.17051363, -0.71067405, -22.029617, -0.09504819, -0.580003, -21.275528, -0.17051363, -0.3960147, -20.354485, 0.16813636, -0.339396, -21.275528, 0.07004857, -0.580003, -21.275528, -0.17051363, -0.6780064, -20.354485, -0.11391497, -0.3960147, -20.354485, 0.16813636, -0.71067405, -22.029617, -0.09504819, -0.580003, -21.275528, -0.17051363, -0.339396, -21.275528, 0.07004857, -0.71067405, -22.029617, -0.09504819, -0.339396, -21.275528, 0.07004857, -0.4148612, -22.029617, 0.2007823, -0.339396, -21.275528, 0.07004857, -0.010835171, -20.354485, 0.27131462, -0.010835171, -21.275528, 0.15811706, -0.339396, -21.275528, 0.07004857, -0.3960147, -20.354485, 0.16813636, -0.010835171, -20.354485, 0.27131462, -0.4148612, -22.029617, 0.2007823, -0.339396, -21.275528, 0.07004857, -0.010835171, -21.275528, 0.15811706, -0.4148612, -22.029617, 0.2007823, -0.010835171, -21.275528, 0.15811706, -0.010835171, -22.029617, 0.3090477, -0.010835171, -21.275528, 0.15811706, 0.37442207, -20.354485, 0.16813636, 0.31780314, -21.275528, 0.07004857, -0.010835171, -21.275528, 0.15811706, -0.010835171, -20.354485, 0.27131462, 0.37442207, -20.354485, 0.16813636, -0.010835171, -22.029617, 0.3090477, -0.010835171, -21.275528, 0.15811706, 0.31780314, -21.275528, 0.07004857, -0.010835171, -22.029617, 0.3090477, 0.31780314, -21.275528, 0.07004857, 0.39326835, -22.029617, 0.2007823, 0.31780314, -21.275528, 0.07004857, 0.65641344, -20.354485, -0.11391497, 0.5583327, -21.275528, -0.17051363, 0.31780314, -21.275528, 0.07004857, 0.37442207, -20.354485, 0.16813636, 0.65641344, -20.354485, -0.11391497, 0.39326835, -22.029617, 0.2007823, 0.31780314, -21.275528, 0.07004857, 0.5583327, -21.275528, -0.17051363, 0.39326835, -22.029617, 0.2007823, 0.5583327, -21.275528, -0.17051363, 0.6890812, -22.029617, -0.09504819, 0.5583327, -21.275528, -0.17051363, 0.759599, -20.354485, -0.49906468, 0.64644134, -21.275528, -0.49906445, 0.5583327, -21.275528, -0.17051363, 0.65641344, -20.354485, -0.11391497, 0.759599, -20.354485, -0.49906468, 0.6890812, -22.029617, -0.09504819, 0.5583327, -21.275528, -0.17051363, 0.64644134, -21.275528, -0.49906445, 0.6890812, -22.029617, -0.09504819, 0.64644134, -21.275528, -0.49906445, 0.7973716, -22.029617, -0.49906397, 0.64644134, -21.275528, -0.49906445, 0.65641344, -20.354485, -0.8842943, 0.5583327, -21.275528, -0.82769513, 0.64644134, -21.275528, -0.49906445, 0.759599, -20.354485, -0.49906468, 0.65641344, -20.354485, -0.8842943, 0.7973716, -22.029617, -0.49906397, 0.64644134, -21.275528, -0.49906445, 0.5583327, -21.275528, -0.82769513, 0.7973716, -22.029617, -0.49906397, 0.5583327, -21.275528, -0.82769513, 0.6890812, -22.029617, -0.9031601, 0.39326835, -22.029617, -1.1989906, -0.010835171, -22.029617, -0.49906397, 0.6890812, -22.029617, -0.9031601, 0.6890812, -22.029617, -0.9031601, -0.010835171, -22.029617, -0.49906397, 0.7973716, -22.029617, -0.49906397, -0.010835171, -22.029617, -1.307256, -0.010835171, -22.029617, -0.49906397, 0.39326835, -22.029617, -1.1989906, 0.7973716, -22.029617, -0.49906397, -0.010835171, -22.029617, -0.49906397, 0.6890812, -22.029617, -0.09504819, -0.4148612, -22.029617, -1.1989906, -0.010835171, -22.029617, -0.49906397, -0.010835171, -22.029617, -1.307256, 0.6890812, -22.029617, -0.09504819, -0.010835171, -22.029617, -0.49906397, 0.39326835, -22.029617, 0.2007823, -0.71067405, -22.029617, -0.9031601, -0.010835171, -22.029617, -0.49906397, -0.4148612, -22.029617, -1.1989906, 0.39326835, -22.029617, 0.2007823, -0.010835171, -22.029617, -0.49906397, -0.010835171, -22.029617, 0.3090477, -0.8189645, -22.029617, -0.49906397, -0.010835171, -22.029617, -0.49906397, -0.71067405, -22.029617, -0.9031601, -0.010835171, -22.029617, 0.3090477, -0.010835171, -22.029617, -0.49906397, -0.4148612, -22.029617, 0.2007823, -0.71067405, -22.029617, -0.09504819, -0.010835171, -22.029617, -0.49906397, -0.8189645, -22.029617, -0.49906397, -0.4148612, -22.029617, 0.2007823, -0.010835171, -22.029617, -0.49906397, -0.71067405, -22.029617, -0.09504819, 9.266797, -17.581049, 9.7409115, 10.985607, -16.33984, 10.792019, 10.863857, -18.049467, 10.526733, 10.863857, -18.049467, 10.526733, 10.985607, -16.33984, 10.792019, 13.068386, -16.296267, 9.768465, 10.863857, -18.049467, 10.526733, 13.068386, -16.296267, 9.768465, 12.696516, -17.604118, 9.563199, 9.266797, -17.581049, 9.7409115, 9.008772, -16.366539, 10.340904, 10.985607, -16.33984, 10.792019, 8.624725, -17.931135, 7.6034517, 9.008772, -16.366539, 10.340904, 9.266797, -17.581049, 9.7409115, 10.985607, -16.33984, 10.792019, 12.4669, -15.041601, 9.18321, 13.068386, -16.296267, 9.768465, 8.624725, -17.931135, 7.6034517, 8.497637, -16.245218, 7.7565994, 9.008772, -16.366539, 10.340904, 9.600649, -17.456738, 5.615509, 8.497637, -16.245218, 7.7565994, 8.624725, -17.931135, 7.6034517, 9.008772, -16.366539, 10.340904, 11.213087, -14.61334, 9.582636, 10.985607, -16.33984, 10.792019, 10.985607, -16.33984, 10.792019, 11.213087, -14.61334, 9.582636, 12.4669, -15.041601, 9.18321, 8.497637, -16.245218, 7.7565994, 9.8885765, -15.072786, 9.437603, 9.008772, -16.366539, 10.340904, 9.008772, -16.366539, 10.340904, 9.8885765, -15.072786, 9.437603, 11.213087, -14.61334, 9.582636, 9.600649, -17.456738, 5.615509, 9.33643, -16.15444, 5.2154427, 8.497637, -16.245218, 7.7565994, 9.33643, -16.15444, 5.2154427, 9.600649, -17.456738, 5.615509, 11.392937, -17.965525, 4.8956895, 8.497637, -16.245218, 7.7565994, 9.720263, -14.589846, 7.952894, 9.8885765, -15.072786, 9.437603, 9.33643, -16.15444, 5.2154427, 9.720263, -14.589846, 7.952894, 8.497637, -16.245218, 7.7565994, 9.8885765, -15.072786, 9.437603, 11.288914, -14.037912, 8.018254, 11.213087, -14.61334, 9.582636, 9.8885765, -15.072786, 9.437603, 9.720263, -14.589846, 7.952894, 11.288914, -14.037912, 8.018254, 9.33643, -16.15444, 5.2154427, 10.034036, -14.978592, 6.3855233, 9.720263, -14.589846, 7.952894, 9.33643, -16.15444, 5.2154427, 11.392937, -17.965525, 4.8956895, 11.408957, -16.27256, 4.8937664, 10.034036, -14.978592, 6.3855233, 9.33643, -16.15444, 5.2154427, 11.408957, -16.27256, 4.8937664, 11.408957, -16.27256, 4.8937664, 11.392937, -17.965525, 4.8956895, 13.021395, -17.62377, 6.061926, 9.720263, -14.589846, 7.952894, 10.034036, -14.978592, 6.3855233, 11.318819, -14.596681, 6.3662996, 10.034036, -14.978592, 6.3855233, 11.408957, -16.27256, 4.8937664, 11.318819, -14.596681, 6.3662996, 9.720263, -14.589846, 7.952894, 11.318819, -14.596681, 6.3662996, 11.288914, -14.037912, 8.018254, 11.408957, -16.27256, 4.8937664, 13.021395, -17.62377, 6.061926, 13.375538, -16.407764, 6.04676, 11.318819, -14.596681, 6.3662996, 11.408957, -16.27256, 4.8937664, 13.375538, -16.407764, 6.04676, 13.632067, -18.083643, 7.819184, 13.375538, -16.407764, 6.04676, 13.021395, -17.62377, 6.061926, 11.288914, -14.037912, 8.018254, 11.318819, -14.596681, 6.3662996, 12.603388, -15.091156, 6.7550454, 11.318819, -14.596681, 6.3662996, 13.375538, -16.407764, 6.04676, 12.603388, -15.091156, 6.7550454, 13.632067, -18.083643, 7.819184, 13.896927, -16.367395, 7.9291854, 13.375538, -16.407764, 6.04676, 13.896927, -16.367395, 7.9291854, 12.603388, -15.091156, 6.7550454, 13.375538, -16.407764, 6.04676, 12.696516, -17.604118, 9.563199, 13.896927, -16.367395, 7.9291854, 13.632067, -18.083643, 7.819184, 12.696516, -17.604118, 9.563199, 13.068386, -16.296267, 9.768465, 13.896927, -16.367395, 7.9291854, 13.896927, -16.367395, 7.9291854, 12.811857, -14.620176, 7.996041, 12.603388, -15.091156, 6.7550454, 11.288914, -14.037912, 8.018254, 12.603388, -15.091156, 6.7550454, 12.811857, -14.620176, 7.996041, 13.068386, -16.296267, 9.768465, 12.811857, -14.620176, 7.996041, 13.896927, -16.367395, 7.9291854, 11.213087, -14.61334, 9.582636, 11.288914, -14.037912, 8.018254, 12.811857, -14.620176, 7.996041, 13.068386, -16.296267, 9.768465, 12.4669, -15.041601, 9.18321, 12.811857, -14.620176, 7.996041, 11.213087, -14.61334, 9.582636, 12.811857, -14.620176, 7.996041, 12.4669, -15.041601, 9.18321, 9.600649, -17.456738, 5.615509, 11.105434, -18.574488, 7.6675305, 11.392937, -17.965525, 4.8956895, 9.600649, -17.456738, 5.615509, 8.624725, -17.931135, 7.6034517, 11.105434, -18.574488, 7.6675305, 11.392937, -17.965525, 4.8956895, 11.105434, -18.574488, 7.6675305, 13.632067, -18.083643, 7.819184, 11.392937, -17.965525, 4.8956895, 13.632067, -18.083643, 7.819184, 13.021395, -17.62377, 6.061926, 11.105434, -18.574488, 7.6675305, 12.696516, -17.604118, 9.563199, 13.632067, -18.083643, 7.819184, 11.105434, -18.574488, 7.6675305, 10.863857, -18.049467, 10.526733, 12.696516, -17.604118, 9.563199, 8.624725, -17.931135, 7.6034517, 10.863857, -18.049467, 10.526733, 11.105434, -18.574488, 7.6675305, 8.624725, -17.931135, 7.6034517, 9.266797, -17.581049, 9.7409115, 10.863857, -18.049467, 10.526733, 7.21563, -18.140032, 8.584928, 8.205864, -17.407396, 9.311369, 8.061045, -18.408522, 8.982858, 8.061045, -18.408522, 8.982858, 8.205864, -17.407396, 9.311369, 9.478685, -17.401842, 8.917924, 8.061045, -18.408522, 8.982858, 9.478685, -17.401842, 8.917924, 9.09955, -18.140032, 8.584928, 7.21563, -18.140032, 8.584928, 7.005238, -17.401842, 8.917924, 8.205864, -17.407396, 9.311369, 7.005238, -18.380968, 7.5489845, 7.005238, -17.401842, 8.917924, 7.21563, -18.140032, 8.584928, 8.205864, -17.407396, 9.311369, 9.169825, -16.65511, 8.638754, 9.478685, -17.401842, 8.917924, 7.005238, -18.380968, 7.5489845, 6.93411, -17.385181, 7.7371626, 7.005238, -17.401842, 8.917924, 7.595832, -18.105644, 6.610869, 6.93411, -17.385181, 7.7371626, 7.005238, -18.380968, 7.5489845, 7.005238, -17.401842, 8.917924, 8.423091, -16.394949, 8.852991, 8.205864, -17.407396, 9.311369, 8.205864, -17.407396, 9.311369, 8.423091, -16.394949, 8.852991, 9.169825, -16.65511, 8.638754, 6.93411, -17.385181, 7.7371626, 7.595832, -16.65511, 8.638754, 7.005238, -17.401842, 8.917924, 7.005238, -17.401842, 8.917924, 7.595832, -16.65511, 8.638754, 8.423091, -16.394949, 8.852991, 7.595832, -18.105644, 6.610869, 7.571055, -17.368736, 6.5566154, 6.93411, -17.385181, 7.7371626, 7.571055, -17.368736, 6.5566154, 7.595832, -18.105644, 6.610869, 8.423091, -18.353416, 6.1153255, 6.93411, -17.385181, 7.7371626, 7.571055, -16.389395, 7.925341, 7.595832, -16.65511, 8.638754, 7.571055, -17.368736, 6.5566154, 7.571055, -16.389395, 7.925341, 6.93411, -17.385181, 7.7371626, 7.595832, -16.65511, 8.638754, 8.495287, -16.05768, 7.988138, 8.423091, -16.394949, 8.852991, 7.595832, -16.65511, 8.638754, 7.571055, -16.389395, 7.925341, 8.495287, -16.05768, 7.988138, 7.571055, -17.368736, 6.5566154, 7.847235, -16.640371, 7.1145287, 7.571055, -16.389395, 7.925341, 7.571055, -17.368736, 6.5566154, 8.423091, -18.353416, 6.1153255, 8.495287, -17.363182, 6.1631703, 7.847235, -16.640371, 7.1145287, 7.571055, -17.368736, 6.5566154, 8.495287, -17.363182, 6.1631703, 8.495287, -17.363182, 6.1631703, 8.423091, -18.353416, 6.1153255, 9.169825, -18.105644, 6.610869, 7.571055, -16.389395, 7.925341, 7.847235, -16.640371, 7.1145287, 8.495287, -16.384056, 6.9979053, 7.847235, -16.640371, 7.1145287, 8.495287, -17.363182, 6.1631703, 8.495287, -16.384056, 6.9979053, 7.571055, -16.389395, 7.925341, 8.495287, -16.384056, 6.9979053, 8.495287, -16.05768, 7.988138, 8.495287, -17.363182, 6.1631703, 9.169825, -18.105644, 6.610869, 9.347322, -17.368736, 6.5566154, 8.495287, -16.384056, 6.9979053, 8.495287, -17.363182, 6.1631703, 9.347322, -17.368736, 6.5566154, 9.478685, -18.380968, 7.5489845, 9.347322, -17.368736, 6.5566154, 9.169825, -18.105644, 6.610869, 8.495287, -16.05768, 7.988138, 8.495287, -16.384056, 6.9979053, 9.1112995, -16.640371, 7.1145287, 8.495287, -16.384056, 6.9979053, 9.347322, -17.368736, 6.5566154, 9.1112995, -16.640371, 7.1145287, 9.478685, -18.380968, 7.5489845, 9.767254, -17.385181, 7.7371626, 9.347322, -17.368736, 6.5566154, 9.767254, -17.385181, 7.7371626, 9.1112995, -16.640371, 7.1145287, 9.347322, -17.368736, 6.5566154, 9.09955, -18.140032, 8.584928, 9.767254, -17.385181, 7.7371626, 9.478685, -18.380968, 7.5489845, 9.09955, -18.140032, 8.584928, 9.478685, -17.401842, 8.917924, 9.767254, -17.385181, 7.7371626, 9.767254, -17.385181, 7.7371626, 9.347322, -16.389395, 7.925341, 9.1112995, -16.640371, 7.1145287, 8.495287, -16.05768, 7.988138, 9.1112995, -16.640371, 7.1145287, 9.347322, -16.389395, 7.925341, 9.478685, -17.401842, 8.917924, 9.347322, -16.389395, 7.925341, 9.767254, -17.385181, 7.7371626, 8.423091, -16.394949, 8.852991, 8.495287, -16.05768, 7.988138, 9.347322, -16.389395, 7.925341, 9.478685, -17.401842, 8.917924, 9.169825, -16.65511, 8.638754, 9.347322, -16.389395, 7.925341, 8.423091, -16.394949, 8.852991, 9.347322, -16.389395, 7.925341, 9.169825, -16.65511, 8.638754, 7.595832, -18.105644, 6.610869, 8.205864, -18.712898, 7.486401, 8.423091, -18.353416, 6.1153255, 7.595832, -18.105644, 6.610869, 7.005238, -18.380968, 7.5489845, 8.205864, -18.712898, 7.486401, 8.423091, -18.353416, 6.1153255, 8.205864, -18.712898, 7.486401, 9.478685, -18.380968, 7.5489845, 8.423091, -18.353416, 6.1153255, 9.478685, -18.380968, 7.5489845, 9.169825, -18.105644, 6.610869, 8.205864, -18.712898, 7.486401, 9.09955, -18.140032, 8.584928, 9.478685, -18.380968, 7.5489845, 8.205864, -18.712898, 7.486401, 8.061045, -18.408522, 8.982858, 9.09955, -18.140032, 8.584928, 7.005238, -18.380968, 7.5489845, 8.061045, -18.408522, 8.982858, 8.205864, -18.712898, 7.486401, 7.005238, -18.380968, 7.5489845, 7.21563, -18.140032, 8.584928, 8.061045, -18.408522, 8.982858, 11.220137, -19.80587, 7.9881396, 9.957781, -19.472874, 7.7623677, 11.16268, -19.969059, 7.7499795, 11.220137, -19.80587, 7.9881396, 10.0568905, -19.342154, 7.988139, 9.957781, -19.472874, 7.7623677, 10.0568905, -19.342154, 7.988139, 8.76399, -18.20817, 7.922565, 9.957781, -19.472874, 7.7623677, 10.0568905, -19.342154, 7.988139, 8.811623, -18.20881, 7.9881387, 8.76399, -18.20817, 7.922565, 9.957781, -19.472874, 7.7623677, 8.76399, -18.20817, 7.922565, 8.686882, -18.207743, 7.9475555, 11.16268, -19.969059, 7.7499795, 9.957781, -19.472874, 7.7623677, 9.797157, -19.68412, 7.848661, 9.957781, -19.472874, 7.7623677, 8.686882, -18.207743, 7.9475555, 9.797157, -19.68412, 7.848661, 11.16268, -19.969059, 7.7499795, 9.797157, -19.68412, 7.848661, 11.069338, -20.23285, 7.8409715, 9.797157, -19.68412, 7.848661, 8.686882, -18.207743, 7.9475555, 8.686882, -18.207743, 8.028723, 11.069338, -20.23285, 7.8409715, 9.797157, -19.68412, 7.848661, 9.797157, -19.68412, 8.127618, 9.797157, -19.68412, 7.848661, 8.686882, -18.207743, 8.028723, 9.797157, -19.68412, 8.127618, 11.069338, -20.23285, 7.8409715, 9.797157, -19.68412, 8.127618, 11.069338, -20.23285, 8.135094, 9.797157, -19.68412, 8.127618, 8.686882, -18.207743, 8.028723, 8.76399, -18.20817, 8.053713, 11.069338, -20.23285, 8.135094, 9.797157, -19.68412, 8.127618, 9.957781, -19.472874, 8.213911, 9.797157, -19.68412, 8.127618, 8.76399, -18.20817, 8.053713, 9.957781, -19.472874, 8.213911, 9.957781, -19.472874, 8.213911, 8.76399, -18.20817, 8.053713, 8.811623, -18.20881, 7.9881387, 11.069338, -20.23285, 8.135094, 9.957781, -19.472874, 8.213911, 11.16268, -19.969059, 8.226086, 9.957781, -19.472874, 8.213911, 8.811623, -18.20881, 7.9881387, 10.0568905, -19.342154, 7.988139, 11.16268, -19.969059, 8.226086, 9.957781, -19.472874, 8.213911, 10.0568905, -19.342154, 7.988139, 11.16268, -19.969059, 8.226086, 10.0568905, -19.342154, 7.988139, 11.220137, -19.80587, 7.9881396, 10.050482, -22.030905, 7.98814, 11.049473, -19.97397, 8.213911, 10.477888, -22.030905, 8.612482, 10.050482, -22.030905, 7.98814, 10.888208, -19.944494, 7.9881396, 11.049473, -19.97397, 8.213911, 10.888208, -19.944494, 7.9881396, 11.13064, -17.907639, 8.087889, 11.049473, -19.97397, 8.213911, 10.888208, -19.944494, 7.9881396, 11.058659, -17.900164, 7.9881387, 11.13064, -17.907639, 8.087889, 11.049473, -19.97397, 8.213911, 11.13064, -17.907639, 8.087889, 11.247263, -17.92024, 8.049868, 10.477888, -22.030905, 8.612482, 11.049473, -19.97397, 8.213911, 11.310488, -20.021389, 8.127618, 11.049473, -19.97397, 8.213911, 11.247263, -17.92024, 8.049868, 11.310488, -20.021389, 8.127618, 10.477888, -22.030905, 8.612482, 11.310488, -20.021389, 8.127618, 11.169514, -22.031118, 8.374109, 11.310488, -20.021389, 8.127618, 11.247263, -17.92024, 8.049868, 11.247263, -17.92024, 7.9264092, 11.169514, -22.031118, 8.374109, 11.310488, -20.021389, 8.127618, 11.310488, -20.021389, 7.848661, 11.310488, -20.021389, 8.127618, 11.247263, -17.92024, 7.9264092, 11.310488, -20.021389, 7.848661, 11.169514, -22.031118, 8.374109, 11.310488, -20.021389, 7.848661, 11.169514, -22.031118, 7.6021714, 11.310488, -20.021389, 7.848661, 11.247263, -17.92024, 7.9264092, 11.13064, -17.907639, 7.888389, 11.169514, -22.031118, 7.6021714, 11.310488, -20.021389, 7.848661, 11.049473, -19.97397, 7.762368, 11.310488, -20.021389, 7.848661, 11.13064, -17.907639, 7.888389, 11.049473, -19.97397, 7.762368, 11.049473, -19.97397, 7.762368, 11.13064, -17.907639, 7.888389, 11.058659, -17.900164, 7.9881387, 11.169514, -22.031118, 7.6021714, 11.049473, -19.97397, 7.762368, 10.477888, -22.030905, 7.3637977, 11.049473, -19.97397, 7.762368, 11.058659, -17.900164, 7.9881387, 10.888208, -19.944494, 7.9881396, 10.477888, -22.030905, 7.3637977, 11.049473, -19.97397, 7.762368, 10.888208, -19.944494, 7.9881396, 10.477888, -22.030905, 7.3637977, 10.888208, -19.944494, 7.9881396, 10.050482, -22.030905, 7.98814, 11.023628, -19.860125, 7.836486, 10.902519, -19.637772, 6.988935, 11.16268, -19.969059, 7.7499795, 11.023628, -19.860125, 7.836486, 10.7801285, -19.55041, 7.099151, 10.902519, -19.637772, 6.988935, 10.7801285, -19.55041, 7.099151, 10.55094, -18.793425, 6.265697, 10.902519, -19.637772, 6.988935, 10.7801285, -19.55041, 7.099151, 10.519327, -18.793852, 6.309698, 10.55094, -18.793425, 6.265697, 10.902519, -19.637772, 6.988935, 10.55094, -18.793425, 6.265697, 10.5189, -18.792997, 6.2221236, 11.16268, -19.969059, 7.7499795, 10.902519, -19.637772, 6.988935, 10.814302, -19.778744, 6.9052052, 10.902519, -19.637772, 6.988935, 10.5189, -18.792997, 6.2221236, 10.814302, -19.778744, 6.9052052, 11.16268, -19.969059, 7.7499795, 10.814302, -19.778744, 6.9052052, 11.085358, -20.145061, 7.710037, 10.814302, -19.778744, 6.9052052, 10.5189, -18.792997, 6.2221236, 10.4674225, -18.792997, 6.238998, 11.085358, -20.145061, 7.710037, 10.814302, -19.778744, 6.9052052, 10.637232, -19.778744, 6.963517, 10.814302, -19.778744, 6.9052052, 10.4674225, -18.792997, 6.238998, 10.637232, -19.778744, 6.963517, 11.085358, -20.145061, 7.710037, 10.637232, -19.778744, 6.963517, 10.898888, -20.145061, 7.771766, 10.637232, -19.778744, 6.963517, 10.4674225, -18.792997, 6.238998, 10.46785, -18.793425, 6.293038, 10.898888, -20.145061, 7.771766, 10.637232, -19.778744, 6.963517, 10.616299, -19.637772, 7.083345, 10.637232, -19.778744, 6.963517, 10.46785, -18.793425, 6.293038, 10.616299, -19.637772, 7.083345, 10.616299, -19.637772, 7.083345, 10.46785, -18.793425, 6.293038, 10.519327, -18.793852, 6.309698, 10.898888, -20.145061, 7.771766, 10.616299, -19.637772, 7.083345, 10.860653, -19.969059, 7.849729, 10.616299, -19.637772, 7.083345, 10.519327, -18.793852, 6.309698, 10.7801285, -19.55041, 7.099151, 10.860653, -19.969059, 7.849729, 10.616299, -19.637772, 7.083345, 10.7801285, -19.55041, 7.099151, 10.860653, -19.969059, 7.849729, 10.7801285, -19.55041, 7.099151, 11.023628, -19.860125, 7.836486, 9.806982, -18.747929, 5.422632, 9.553658, -18.258793, 6.2026863, 9.731583, -18.927135, 6.042062, 9.731583, -18.927135, 6.042062, 9.553658, -18.258793, 6.2026863, 10.069706, -18.254948, 6.927205, 9.731583, -18.927135, 6.042062, 10.069706, -18.254948, 6.927205, 10.201282, -18.747929, 6.6170635, 9.806982, -18.747929, 5.422632, 9.551735, -18.254948, 5.359194, 9.553658, -18.258793, 6.2026863, 10.419577, -18.908766, 5.0725474, 9.551735, -18.254948, 5.359194, 9.806982, -18.747929, 5.422632, 9.553658, -18.258793, 6.2026863, 10.181845, -17.756413, 6.672812, 10.069706, -18.254948, 6.927205, 10.419577, -18.908766, 5.0725474, 10.285225, -18.244055, 5.06678, 9.551735, -18.254948, 5.359194, 11.137901, -18.725073, 5.250473, 10.285225, -18.244055, 5.06678, 10.419577, -18.908766, 5.0725474, 9.551735, -18.254948, 5.359194, 9.889858, -17.582973, 6.244337, 9.553658, -18.258793, 6.2026863, 9.553658, -18.258793, 6.2026863, 9.889858, -17.582973, 6.244337, 10.181845, -17.756413, 6.672812, 10.285225, -18.244055, 5.06678, 9.852264, -17.756414, 5.6751027, 9.551735, -18.254948, 5.359194, 9.551735, -18.254948, 5.359194, 9.852264, -17.756414, 5.6751027, 9.889858, -17.582973, 6.244337, 11.137901, -18.725073, 5.250473, 11.16695, -18.232948, 5.223346, 10.285225, -18.244055, 5.06678, 11.16695, -18.232948, 5.223346, 11.137901, -18.725073, 5.250473, 11.625116, -18.890398, 5.671045, 10.285225, -18.244055, 5.06678, 10.299322, -17.579128, 5.5099926, 9.852264, -17.756414, 5.6751027, 11.16695, -18.232948, 5.223346, 10.299322, -17.579128, 5.5099926, 10.285225, -18.244055, 5.06678, 9.852264, -17.756414, 5.6751027, 10.453112, -17.357628, 6.1091304, 9.889858, -17.582973, 6.244337, 9.852264, -17.756414, 5.6751027, 10.299322, -17.579128, 5.5099926, 10.453112, -17.357628, 6.1091304, 11.16695, -18.232948, 5.223346, 10.871334, -17.746588, 5.5153327, 10.299322, -17.579128, 5.5099926, 11.16695, -18.232948, 5.223346, 11.625116, -18.890398, 5.671045, 11.610165, -18.229317, 5.727007, 10.871334, -17.746588, 5.5153327, 11.16695, -18.232948, 5.223346, 11.610165, -18.229317, 5.727007, 11.610165, -18.229317, 5.727007, 11.625116, -18.890398, 5.671045, 11.467482, -18.725073, 6.2481823, 10.299322, -17.579128, 5.5099926, 10.871334, -17.746588, 5.5153327, 11.080872, -17.575497, 5.9017286, 10.871334, -17.746588, 5.5153327, 11.610165, -18.229317, 5.727007, 11.080872, -17.575497, 5.9017286, 10.299322, -17.579128, 5.5099926, 11.080872, -17.575497, 5.9017286, 10.453112, -17.357628, 6.1091304, 11.610165, -18.229317, 5.727007, 11.467482, -18.725073, 6.2481823, 11.539036, -18.232946, 6.3494267, 11.080872, -17.575497, 5.9017286, 11.610165, -18.229317, 5.727007, 11.539036, -18.232946, 6.3494267, 10.937548, -18.908766, 6.640559, 11.539036, -18.232946, 6.3494267, 11.467482, -18.725073, 6.2481823, 10.453112, -17.357628, 6.1091304, 11.080872, -17.575497, 5.9017286, 11.135979, -17.746588, 6.316746, 11.080872, -17.575497, 5.9017286, 11.539036, -18.232946, 6.3494267, 11.135979, -17.746588, 6.316746, 10.937548, -18.908766, 6.640559, 10.878382, -18.244053, 6.8629127, 11.539036, -18.232946, 6.3494267, 10.878382, -18.244053, 6.8629127, 11.135979, -17.746588, 6.316746, 11.539036, -18.232946, 6.3494267, 10.201282, -18.747929, 6.6170635, 10.878382, -18.244053, 6.8629127, 10.937548, -18.908766, 6.640559, 10.201282, -18.747929, 6.6170635, 10.069706, -18.254948, 6.927205, 10.878382, -18.244053, 6.8629127, 10.878382, -18.244053, 6.8629127, 10.671408, -17.579128, 6.636073, 11.135979, -17.746588, 6.316746, 10.453112, -17.357628, 6.1091304, 11.135979, -17.746588, 6.316746, 10.671408, -17.579128, 6.636073, 10.069706, -18.254948, 6.927205, 10.671408, -17.579128, 6.636073, 10.878382, -18.244053, 6.8629127, 9.889858, -17.582973, 6.244337, 10.453112, -17.357628, 6.1091304, 10.671408, -17.579128, 6.636073, 10.069706, -18.254948, 6.927205, 10.181845, -17.756413, 6.672812, 10.671408, -17.579128, 6.636073, 9.889858, -17.582973, 6.244337, 10.671408, -17.579128, 6.636073, 10.181845, -17.756413, 6.672812, 11.137901, -18.725073, 5.250473, 10.710709, -19.13048, 5.8203487, 11.625116, -18.890398, 5.671045, 11.137901, -18.725073, 5.250473, 10.419577, -18.908766, 5.0725474, 10.710709, -19.13048, 5.8203487, 11.625116, -18.890398, 5.671045, 10.710709, -19.13048, 5.8203487, 10.937548, -18.908766, 6.640559, 11.625116, -18.890398, 5.671045, 10.937548, -18.908766, 6.640559, 11.467482, -18.725073, 6.2481823, 10.710709, -19.13048, 5.8203487, 10.201282, -18.747929, 6.6170635, 10.937548, -18.908766, 6.640559, 10.710709, -19.13048, 5.8203487, 9.731583, -18.927135, 6.042062, 10.201282, -18.747929, 6.6170635, 10.419577, -18.908766, 5.0725474, 9.731583, -18.927135, 6.042062, 10.710709, -19.13048, 5.8203487, 10.419577, -18.908766, 5.0725474, 9.806982, -18.747929, 5.422632, 9.731583, -18.927135, 6.042062, 15.540274, 10.966449, -3.3071837, 18.560066, 13.1471405, -1.460484, 18.346163, 10.143484, -1.9265671, 18.346163, 10.143484, -1.9265671, 18.560066, 13.1471405, -1.460484, 22.219316, 13.223694, -3.2587752, 18.346163, 10.143484, -1.9265671, 22.219316, 13.223694, -3.2587752, 21.565973, 10.92592, -3.6194077, 15.540274, 10.966449, -3.3071837, 15.086948, 13.100231, -2.2530527, 18.560066, 13.1471405, -1.460484, 14.412214, 10.35138, -7.0625033, 15.086948, 13.100231, -2.2530527, 15.540274, 10.966449, -3.3071837, 18.560066, 13.1471405, -1.460484, 21.16256, 15.42765, -4.2870145, 22.219316, 13.223694, -3.2587752, 14.412214, 10.35138, -7.0625033, 14.188929, 13.313381, -6.7934375, 15.086948, 13.100231, -2.2530527, 16.12682, 11.184852, -10.555136, 14.188929, 13.313381, -6.7934375, 14.412214, 10.35138, -7.0625033, 15.086948, 13.100231, -2.2530527, 18.959728, 16.180443, -3.5852609, 18.560066, 13.1471405, -1.460484, 18.560066, 13.1471405, -1.460484, 18.959728, 16.180443, -3.5852609, 21.16256, 15.42765, -4.2870145, 14.188929, 13.313381, -6.7934375, 16.632683, 15.373238, -3.8400683, 15.086948, 13.100231, -2.2530527, 15.086948, 13.100231, -2.2530527, 16.632683, 15.373238, -3.8400683, 18.959728, 16.180443, -3.5852609, 16.12682, 11.184852, -10.555136, 15.662611, 13.473244, -11.258017, 14.188929, 13.313381, -6.7934375, 15.662611, 13.473244, -11.258017, 16.12682, 11.184852, -10.555136, 19.275705, 10.291335, -11.819794, 14.188929, 13.313381, -6.7934375, 16.33697, 16.22172, -6.448566, 16.632683, 15.373238, -3.8400683, 15.662611, 13.473244, -11.258017, 16.33697, 16.22172, -6.448566, 14.188929, 13.313381, -6.7934375, 16.632683, 15.373238, -3.8400683, 19.092949, 17.191416, -6.333734, 18.959728, 16.180443, -3.5852609, 16.632683, 15.373238, -3.8400683, 16.33697, 16.22172, -6.448566, 19.092949, 17.191416, -6.333734, 15.662611, 13.473244, -11.258017, 16.88824, 15.538728, -9.202293, 16.33697, 16.22172, -6.448566, 15.662611, 13.473244, -11.258017, 19.275705, 10.291335, -11.819794, 19.30385, 13.265345, -11.823172, 16.88824, 15.538728, -9.202293, 15.662611, 13.473244, -11.258017, 19.30385, 13.265345, -11.823172, 19.30385, 13.265345, -11.823172, 19.275705, 10.291335, -11.819794, 22.136759, 10.891392, -9.770823, 16.33697, 16.22172, -6.448566, 16.88824, 15.538728, -9.202293, 19.145487, 16.20971, -9.236067, 16.88824, 15.538728, -9.202293, 19.30385, 13.265345, -11.823172, 19.145487, 16.20971, -9.236067, 16.33697, 16.22172, -6.448566, 19.145487, 16.20971, -9.236067, 19.092949, 17.191416, -6.333734, 19.30385, 13.265345, -11.823172, 22.136759, 10.891392, -9.770823, 22.758955, 13.0278, -9.797468, 19.145487, 16.20971, -9.236067, 19.30385, 13.265345, -11.823172, 22.758955, 13.0278, -9.797468, 23.209654, 10.083063, -6.6834817, 22.758955, 13.0278, -9.797468, 22.136759, 10.891392, -9.770823, 19.092949, 17.191416, -6.333734, 19.145487, 16.20971, -9.236067, 21.402355, 15.340961, -8.553076, 19.145487, 16.20971, -9.236067, 22.758955, 13.0278, -9.797468, 21.402355, 15.340961, -8.553076, 23.209654, 10.083063, -6.6834817, 23.674988, 13.098728, -6.4902196, 22.758955, 13.0278, -9.797468, 23.674988, 13.098728, -6.4902196, 21.402355, 15.340961, -8.553076, 22.758955, 13.0278, -9.797468, 21.565973, 10.92592, -3.6194077, 23.674988, 13.098728, -6.4902196, 23.209654, 10.083063, -6.6834817, 21.565973, 10.92592, -3.6194077, 22.219316, 13.223694, -3.2587752, 23.674988, 13.098728, -6.4902196, 23.674988, 13.098728, -6.4902196, 21.768618, 16.168056, -6.372762, 21.402355, 15.340961, -8.553076, 19.092949, 17.191416, -6.333734, 21.402355, 15.340961, -8.553076, 21.768618, 16.168056, -6.372762, 22.219316, 13.223694, -3.2587752, 21.768618, 16.168056, -6.372762, 23.674988, 13.098728, -6.4902196, 18.959728, 16.180443, -3.5852609, 19.092949, 17.191416, -6.333734, 21.768618, 16.168056, -6.372762, 22.219316, 13.223694, -3.2587752, 21.16256, 15.42765, -4.2870145, 21.768618, 16.168056, -6.372762, 18.959728, 16.180443, -3.5852609, 21.768618, 16.168056, -6.372762, 21.16256, 15.42765, -4.2870145, 16.12682, 11.184852, -10.555136, 18.770592, 9.221069, -6.949922, 19.275705, 10.291335, -11.819794, 16.12682, 11.184852, -10.555136, 14.412214, 10.35138, -7.0625033, 18.770592, 9.221069, -6.949922, 19.275705, 10.291335, -11.819794, 18.770592, 9.221069, -6.949922, 23.209654, 10.083063, -6.6834817, 19.275705, 10.291335, -11.819794, 23.209654, 10.083063, -6.6834817, 22.136759, 10.891392, -9.770823, 18.770592, 9.221069, -6.949922, 21.565973, 10.92592, -3.6194077, 23.209654, 10.083063, -6.6834817, 18.770592, 9.221069, -6.949922, 18.346163, 10.143484, -1.9265671, 21.565973, 10.92592, -3.6194077, 14.412214, 10.35138, -7.0625033, 18.346163, 10.143484, -1.9265671, 18.770592, 9.221069, -6.949922, 14.412214, 10.35138, -7.0625033, 15.540274, 10.966449, -3.3071837, 18.346163, 10.143484, -1.9265671, 11.936562, 9.984368, -5.338141, 13.67631, 11.271917, -4.06185, 13.421877, 9.512654, -4.639014, 13.421877, 9.512654, -4.639014, 13.67631, 11.271917, -4.06185, 15.91254, 11.2813, -4.7530966, 13.421877, 9.512654, -4.639014, 15.91254, 11.2813, -4.7530966, 15.246437, 9.984368, -5.338141, 11.936562, 9.984368, -5.338141, 11.566921, 11.2813, -4.7530966, 13.67631, 11.271917, -4.06185, 11.566921, 9.561063, -7.158197, 11.566921, 11.2813, -4.7530966, 11.936562, 9.984368, -5.338141, 13.67631, 11.271917, -4.06185, 15.369902, 12.5936165, -5.243574, 15.91254, 11.2813, -4.7530966, 11.566921, 9.561063, -7.158197, 11.441956, 11.310569, -6.8275857, 11.566921, 11.2813, -4.7530966, 12.604542, 10.044409, -8.806381, 11.441956, 11.310569, -6.8275857, 11.566921, 9.561063, -7.158197, 11.566921, 11.2813, -4.7530966, 14.057959, 13.050319, -4.8671794, 13.67631, 11.271917, -4.06185, 13.67631, 11.271917, -4.06185, 14.057959, 13.050319, -4.8671794, 15.369902, 12.5936165, -5.243574, 11.441956, 11.310569, -6.8275857, 12.604542, 12.5936165, -5.243574, 11.566921, 11.2813, -4.7530966, 11.566921, 11.2813, -4.7530966, 12.604542, 12.5936165, -5.243574, 14.057959, 13.050319, -4.8671794, 12.604542, 10.044409, -8.806381, 12.56101, 11.339464, -8.901699, 11.441956, 11.310569, -6.8275857, 12.56101, 11.339464, -8.901699, 12.604542, 10.044409, -8.806381, 14.057959, 9.609097, -9.677006, 11.441956, 11.310569, -6.8275857, 12.56101, 13.0597, -6.4969745, 12.604542, 12.5936165, -5.243574, 12.56101, 11.339464, -8.901699, 12.56101, 13.0597, -6.4969745, 11.441956, 11.310569, -6.8275857, 12.604542, 12.5936165, -5.243574, 14.184801, 13.643244, -6.386646, 14.057959, 13.050319, -4.8671794, 12.604542, 12.5936165, -5.243574, 12.56101, 13.0597, -6.4969745, 14.184801, 13.643244, -6.386646, 12.56101, 11.339464, -8.901699, 13.046232, 12.619133, -7.921497, 12.56101, 13.0597, -6.4969745, 12.56101, 11.339464, -8.901699, 14.057959, 9.609097, -9.677006, 14.184801, 11.34922, -9.592945, 13.046232, 12.619133, -7.921497, 12.56101, 11.339464, -8.901699, 14.184801, 11.34922, -9.592945, 14.184801, 11.34922, -9.592945, 14.057959, 9.609097, -9.677006, 15.369902, 10.044409, -8.806381, 12.56101, 13.0597, -6.4969745, 13.046232, 12.619133, -7.921497, 14.184801, 13.069456, -8.126394, 13.046232, 12.619133, -7.921497, 14.184801, 11.34922, -9.592945, 14.184801, 13.069456, -8.126394, 12.56101, 13.0597, -6.4969745, 14.184801, 13.069456, -8.126394, 14.184801, 13.643244, -6.386646, 14.184801, 11.34922, -9.592945, 15.369902, 10.044409, -8.806381, 15.681749, 11.339464, -8.901699, 14.184801, 13.069456, -8.126394, 14.184801, 11.34922, -9.592945, 15.681749, 11.339464, -8.901699, 15.91254, 9.561063, -7.158197, 15.681749, 11.339464, -8.901699, 15.369902, 10.044409, -8.806381, 14.184801, 13.643244, -6.386646, 14.184801, 13.069456, -8.126394, 15.2670765, 12.619133, -7.921497, 14.184801, 13.069456, -8.126394, 15.681749, 11.339464, -8.901699, 15.2670765, 12.619133, -7.921497, 15.91254, 9.561063, -7.158197, 16.419529, 11.310569, -6.8275857, 15.681749, 11.339464, -8.901699, 16.419529, 11.310569, -6.8275857, 15.2670765, 12.619133, -7.921497, 15.681749, 11.339464, -8.901699, 15.246437, 9.984368, -5.338141, 16.419529, 11.310569, -6.8275857, 15.91254, 9.561063, -7.158197, 15.246437, 9.984368, -5.338141, 15.91254, 11.2813, -4.7530966, 16.419529, 11.310569, -6.8275857, 16.419529, 11.310569, -6.8275857, 15.681749, 13.0597, -6.4969745, 15.2670765, 12.619133, -7.921497, 14.184801, 13.643244, -6.386646, 15.2670765, 12.619133, -7.921497, 15.681749, 13.0597, -6.4969745, 15.91254, 11.2813, -4.7530966, 15.681749, 13.0597, -6.4969745, 16.419529, 11.310569, -6.8275857, 14.057959, 13.050319, -4.8671794, 14.184801, 13.643244, -6.386646, 15.681749, 13.0597, -6.4969745, 15.91254, 11.2813, -4.7530966, 15.369902, 12.5936165, -5.243574, 15.681749, 13.0597, -6.4969745, 14.057959, 13.050319, -4.8671794, 15.681749, 13.0597, -6.4969745, 15.369902, 12.5936165, -5.243574, 12.604542, 10.044409, -8.806381, 13.67631, 8.977895, -7.268151, 14.057959, 9.609097, -9.677006, 12.604542, 10.044409, -8.806381, 11.566921, 9.561063, -7.158197, 13.67631, 8.977895, -7.268151, 14.057959, 9.609097, -9.677006, 13.67631, 8.977895, -7.268151, 15.91254, 9.561063, -7.158197, 14.057959, 9.609097, -9.677006, 15.91254, 9.561063, -7.158197, 15.369902, 10.044409, -8.806381, 13.67631, 8.977895, -7.268151, 15.246437, 9.984368, -5.338141, 15.91254, 9.561063, -7.158197, 13.67631, 8.977895, -7.268151, 13.421877, 9.512654, -4.639014, 15.246437, 9.984368, -5.338141, 11.566921, 9.561063, -7.158197, 13.421877, 9.512654, -4.639014, 13.67631, 8.977895, -7.268151, 11.566921, 9.561063, -7.158197, 11.936562, 9.984368, -5.338141, 13.421877, 9.512654, -4.639014, 16.48933, 8.916348, -10.894003, 16.04426, 9.775716, -9.5235195, 16.35686, 8.601498, -9.805721, 16.35686, 8.601498, -9.805721, 16.04426, 9.775716, -9.5235195, 16.95091, 9.782472, -8.250607, 16.35686, 8.601498, -9.805721, 16.95091, 9.782472, -8.250607, 17.182077, 8.916349, -8.795498, 16.48933, 8.916348, -10.894003, 16.040882, 9.78247, -11.005459, 16.04426, 9.775716, -9.5235195, 17.565601, 8.63377, -11.509069, 16.040882, 9.78247, -11.005459, 16.48933, 8.916348, -10.894003, 16.04426, 9.775716, -9.5235195, 17.147928, 10.65835, -8.697552, 16.95091, 9.782472, -8.250607, 17.565601, 8.63377, -11.509069, 17.329557, 9.801608, -11.519203, 16.040882, 9.78247, -11.005459, 18.827633, 8.956502, -11.19647, 17.329557, 9.801608, -11.519203, 17.565601, 8.63377, -11.509069, 16.040882, 9.78247, -11.005459, 16.634933, 10.963444, -9.450344, 16.04426, 9.775716, -9.5235195, 16.04426, 9.775716, -9.5235195, 16.634933, 10.963444, -9.450344, 17.147928, 10.65835, -8.697552, 17.329557, 9.801608, -11.519203, 16.568888, 10.658349, -10.450436, 16.040882, 9.78247, -11.005459, 16.040882, 9.78247, -11.005459, 16.568888, 10.658349, -10.450436, 16.634933, 10.963444, -9.450344, 18.827633, 8.956502, -11.19647, 18.87867, 9.821122, -11.244131, 17.329557, 9.801608, -11.519203, 18.87867, 9.821122, -11.244131, 18.827633, 8.956502, -11.19647, 19.683622, 8.666044, -10.457565, 17.329557, 9.801608, -11.519203, 17.354326, 10.969822, -10.740519, 16.568888, 10.658349, -10.450436, 18.87867, 9.821122, -11.244131, 17.354326, 10.969822, -10.740519, 17.329557, 9.801608, -11.519203, 16.568888, 10.658349, -10.450436, 17.62452, 11.358977, -9.687889, 16.634933, 10.963444, -9.450344, 16.568888, 10.658349, -10.450436, 17.354326, 10.969822, -10.740519, 17.62452, 11.358977, -9.687889, 18.87867, 9.821122, -11.244131, 18.359297, 10.6756115, -10.731136, 17.354326, 10.969822, -10.740519, 18.87867, 9.821122, -11.244131, 19.683622, 8.666044, -10.457565, 19.657354, 9.827502, -10.359245, 18.359297, 10.6756115, -10.731136, 18.87867, 9.821122, -11.244131, 19.657354, 9.827502, -10.359245, 19.657354, 9.827502, -10.359245, 19.683622, 8.666044, -10.457565, 19.406673, 8.956503, -9.443586, 17.354326, 10.969822, -10.740519, 18.359297, 10.6756115, -10.731136, 18.727436, 10.976202, -10.052275, 18.359297, 10.6756115, -10.731136, 19.657354, 9.827502, -10.359245, 18.727436, 10.976202, -10.052275, 17.354326, 10.969822, -10.740519, 18.727436, 10.976202, -10.052275, 17.62452, 11.358977, -9.687889, 19.657354, 9.827502, -10.359245, 19.406673, 8.956503, -9.443586, 19.532389, 9.821123, -9.265711, 18.727436, 10.976202, -10.052275, 19.657354, 9.827502, -10.359245, 19.532389, 9.821123, -9.265711, 18.47563, 8.633772, -8.754217, 19.532389, 9.821123, -9.265711, 19.406673, 8.956503, -9.443586, 17.62452, 11.358977, -9.687889, 18.727436, 10.976202, -10.052275, 18.824255, 10.6756115, -9.323126, 18.727436, 10.976202, -10.052275, 19.532389, 9.821123, -9.265711, 18.824255, 10.6756115, -9.323126, 18.47563, 8.633772, -8.754217, 18.371681, 9.80161, -8.363562, 19.532389, 9.821123, -9.265711, 18.371681, 9.80161, -8.363562, 18.824255, 10.6756115, -9.323126, 19.532389, 9.821123, -9.265711, 17.182077, 8.916349, -8.795498, 18.371681, 9.80161, -8.363562, 18.47563, 8.633772, -8.754217, 17.182077, 8.916349, -8.795498, 16.95091, 9.782472, -8.250607, 18.371681, 9.80161, -8.363562, 18.371681, 9.80161, -8.363562, 18.008045, 10.969824, -8.762098, 18.824255, 10.6756115, -9.323126, 17.62452, 11.358977, -9.687889, 18.824255, 10.6756115, -9.323126, 18.008045, 10.969824, -8.762098, 16.95091, 9.782472, -8.250607, 18.008045, 10.969824, -8.762098, 18.371681, 9.80161, -8.363562, 16.634933, 10.963444, -9.450344, 17.62452, 11.358977, -9.687889, 18.008045, 10.969824, -8.762098, 16.95091, 9.782472, -8.250607, 17.147928, 10.65835, -8.697552, 18.008045, 10.969824, -8.762098, 16.634933, 10.963444, -9.450344, 18.008045, 10.969824, -8.762098, 17.147928, 10.65835, -8.697552, 18.827633, 8.956502, -11.19647, 18.077095, 8.244242, -10.195251, 19.683622, 8.666044, -10.457565, 18.827633, 8.956502, -11.19647, 17.565601, 8.63377, -11.509069, 18.077095, 8.244242, -10.195251, 19.683622, 8.666044, -10.457565, 18.077095, 8.244242, -10.195251, 18.47563, 8.633772, -8.754217, 19.683622, 8.666044, -10.457565, 18.47563, 8.633772, -8.754217, 19.406673, 8.956503, -9.443586, 18.077095, 8.244242, -10.195251, 17.182077, 8.916349, -8.795498, 18.47563, 8.633772, -8.754217, 18.077095, 8.244242, -10.195251, 16.35686, 8.601498, -9.805721, 17.182077, 8.916349, -8.795498, 17.565601, 8.63377, -11.509069, 16.35686, 8.601498, -9.805721, 18.077095, 8.244242, -10.195251, 17.565601, 8.63377, -11.509069, 16.48933, 8.916348, -10.894003, 16.35686, 8.601498, -9.805721, 18.97211, 7.0576415, -6.386642, 16.75427, 7.642686, -6.783302, 18.871164, 6.7709355, -6.804692, 18.97211, 7.0576415, -6.386642, 16.92877, 7.871976, -6.386642, 16.75427, 7.642686, -6.783302, 16.92877, 7.871976, -6.386642, 14.656889, 9.864281, -6.5018516, 16.75427, 7.642686, -6.783302, 16.92877, 7.871976, -6.386642, 14.740574, 9.86353, -6.386643, 14.656889, 9.864281, -6.5018516, 16.75427, 7.642686, -6.783302, 14.656889, 9.864281, -6.5018516, 14.521418, 9.865407, -6.4579444, 18.871164, 6.7709355, -6.804692, 16.75427, 7.642686, -6.783302, 16.472067, 7.2715445, -6.6316934, 16.75427, 7.642686, -6.783302, 14.521418, 9.865407, -6.4579444, 16.472067, 7.2715445, -6.6316934, 18.871164, 6.7709355, -6.804692, 16.472067, 7.2715445, -6.6316934, 18.70717, 6.307478, -6.645202, 16.472067, 7.2715445, -6.6316934, 14.521418, 9.865407, -6.4579444, 14.521418, 9.865407, -6.3153424, 18.70717, 6.307478, -6.645202, 16.472067, 7.2715445, -6.6316934, 16.472067, 7.271545, -6.1415906, 16.472067, 7.2715445, -6.6316934, 14.521418, 9.865407, -6.3153424, 16.472067, 7.271545, -6.1415906, 18.70717, 6.307478, -6.645202, 16.472067, 7.271545, -6.1415906, 18.70717, 6.307478, -6.128081, 16.472067, 7.271545, -6.1415906, 14.521418, 9.865407, -6.3153424, 14.656889, 9.864282, -6.2714353, 18.70717, 6.307478, -6.128081, 16.472067, 7.271545, -6.1415906, 16.75427, 7.642687, -5.9899826, 16.472067, 7.271545, -6.1415906, 14.656889, 9.864282, -6.2714353, 16.75427, 7.642687, -5.9899826, 16.75427, 7.642687, -5.9899826, 14.656889, 9.864282, -6.2714353, 14.740574, 9.86353, -6.386643, 18.70717, 6.307478, -6.128081, 16.75427, 7.642687, -5.9899826, 18.871164, 6.770936, -5.9685917, 16.75427, 7.642687, -5.9899826, 14.740574, 9.86353, -6.386643, 16.92877, 7.871976, -6.386642, 18.871164, 6.770936, -5.9685917, 16.75427, 7.642687, -5.9899826, 16.92877, 7.871976, -6.386642, 18.871164, 6.770936, -5.9685917, 16.92877, 7.871976, -6.386642, 18.97211, 7.0576415, -6.386642, 16.917137, 3.1484604, -6.38664, 18.672272, 6.762305, -5.989982, 17.66805, 3.1484613, -5.289727, 16.917137, 3.1484604, -6.38664, 18.388943, 6.8137164, -6.386642, 18.672272, 6.762305, -5.989982, 18.388943, 6.8137164, -6.386642, 18.814873, 10.392285, -6.211393, 18.672272, 6.762305, -5.989982, 18.388943, 6.8137164, -6.386642, 18.688408, 10.405795, -6.386644, 18.814873, 10.392285, -6.211393, 18.672272, 6.762305, -5.989982, 18.814873, 10.392285, -6.211393, 19.01977, 10.370894, -6.278191, 17.66805, 3.1484613, -5.289727, 18.672272, 6.762305, -5.989982, 19.13085, 6.6789947, -6.1415906, 18.672272, 6.762305, -5.989982, 19.01977, 10.370894, -6.278191, 19.13085, 6.6789947, -6.1415906, 17.66805, 3.1484613, -5.289727, 19.13085, 6.6789947, -6.1415906, 18.883549, 3.148085, -5.708528, 19.13085, 6.6789947, -6.1415906, 19.01977, 10.370894, -6.278191, 19.01977, 10.370894, -6.4950967, 18.883549, 3.148085, -5.708528, 19.13085, 6.6789947, -6.1415906, 19.13085, 6.678994, -6.6316924, 19.13085, 6.6789947, -6.1415906, 19.01977, 10.370894, -6.4950967, 19.13085, 6.678994, -6.6316924, 18.883549, 3.148085, -5.708528, 19.13085, 6.678994, -6.6316924, 18.883549, 3.1480846, -7.064751, 19.13085, 6.678994, -6.6316924, 19.01977, 10.370894, -6.4950967, 18.814873, 10.392285, -6.561895, 18.883549, 3.1480846, -7.064751, 19.13085, 6.678994, -6.6316924, 18.672272, 6.7623043, -6.783302, 19.13085, 6.678994, -6.6316924, 18.814873, 10.392285, -6.561895, 18.672272, 6.7623043, -6.783302, 18.672272, 6.7623043, -6.783302, 18.814873, 10.392285, -6.561895, 18.688408, 10.405795, -6.386644, 18.883549, 3.1480846, -7.064751, 18.672272, 6.7623043, -6.783302, 17.66805, 3.14846, -7.4835525, 18.672272, 6.7623043, -6.783302, 18.688408, 10.405795, -6.386644, 18.388943, 6.8137164, -6.386642, 17.66805, 3.14846, -7.4835525, 18.672272, 6.7623043, -6.783302, 18.388943, 6.8137164, -6.386642, 17.66805, 3.14846, -7.4835525, 18.388943, 6.8137164, -6.386642, 16.917137, 3.1484604, -6.38664, 18.62724, 6.962323, -6.6530833, 18.414085, 7.3529778, -8.142153, 18.871164, 6.7709355, -6.804692, 18.62724, 6.962323, -6.6530833, 18.199057, 7.5060873, -7.9485135, 18.414085, 7.3529778, -8.142153, 18.199057, 7.5060873, -7.9485135, 17.796392, 8.836041, -9.412815, 18.414085, 7.3529778, -8.142153, 18.199057, 7.5060873, -7.9485135, 17.740852, 8.835667, -9.33551, 17.796392, 8.836041, -9.412815, 18.414085, 7.3529778, -8.142153, 17.796392, 8.836041, -9.412815, 17.740103, 8.836792, -9.48937, 18.871164, 6.7709355, -6.804692, 18.414085, 7.3529778, -8.142153, 18.258724, 7.1053, -8.289257, 18.414085, 7.3529778, -8.142153, 17.740103, 8.836792, -9.48937, 18.258724, 7.1053, -8.289257, 18.871164, 6.7709355, -6.804692, 18.258724, 7.1053, -8.289257, 18.735317, 6.461338, -6.8752427, 18.258724, 7.1053, -8.289257, 17.740103, 8.836792, -9.48937, 17.650038, 8.836792, -9.459724, 18.735317, 6.461338, -6.8752427, 18.258724, 7.1053, -8.289257, 17.948002, 7.1053, -8.18681, 18.258724, 7.1053, -8.289257, 17.650038, 8.836792, -9.459724, 17.948002, 7.1053, -8.18681, 18.735317, 6.461338, -6.8752427, 17.948002, 7.1053, -8.18681, 18.407707, 6.461338, -6.7671647, 17.948002, 7.1053, -8.18681, 17.650038, 8.836792, -9.459724, 17.650414, 8.836041, -9.36478, 18.407707, 6.461338, -6.7671647, 17.948002, 7.1053, -8.18681, 17.911226, 7.3529778, -7.975908, 17.948002, 7.1053, -8.18681, 17.650414, 8.836041, -9.36478, 17.911226, 7.3529778, -7.975908, 17.911226, 7.3529778, -7.975908, 17.650414, 8.836041, -9.36478, 17.740852, 8.835667, -9.33551, 18.407707, 6.461338, -6.7671647, 17.911226, 7.3529778, -7.975908, 18.340908, 6.7709355, -6.6298165, 17.911226, 7.3529778, -7.975908, 17.740852, 8.835667, -9.33551, 18.199057, 7.5060873, -7.9485135, 18.340908, 6.7709355, -6.6298165, 17.911226, 7.3529778, -7.975908, 18.199057, 7.5060873, -7.9485135, 18.340908, 6.7709355, -6.6298165, 18.199057, 7.5060873, -7.9485135, 18.62724, 6.962323, -6.6530833, -21.319002, 11.927798, 5.296257, -17.92721, 14.377124, 7.3704467, -18.167463, 11.003454, 6.8469477, -18.167463, 11.003454, 6.8469477, -17.92721, 14.377124, 7.3704467, -13.817183, 14.463108, 5.3506293, -18.167463, 11.003454, 6.8469477, -13.817183, 14.463108, 5.3506293, -14.551012, 11.882277, 4.945571, -21.319002, 11.927798, 5.296257, -21.828173, 14.324436, 6.480244, -17.92721, 14.377124, 7.3704467, -22.586025, 11.236962, 1.0783278, -21.828173, 14.324436, 6.480244, -21.319002, 11.927798, 5.296257, -17.92721, 14.377124, 7.3704467, -15.004122, 16.938564, 4.195724, -13.817183, 14.463108, 5.3506293, -22.586025, 11.236962, 1.0783278, -22.836817, 14.563845, 1.3805406, -21.828173, 14.324436, 6.480244, -20.660202, 12.173107, -2.8445535, -22.836817, 14.563845, 1.3805406, -22.586025, 11.236962, 1.0783278, -21.828173, 14.324436, 6.480244, -17.478313, 17.78409, 4.983925, -17.92721, 14.377124, 7.3704467, -17.92721, 14.377124, 7.3704467, -17.478313, 17.78409, 4.983925, -15.004122, 16.938564, 4.195724, -22.836817, 14.563845, 1.3805406, -20.092022, 16.877449, 4.697728, -21.828173, 14.324436, 6.480244, -21.828173, 14.324436, 6.480244, -20.092022, 16.877449, 4.697728, -17.478313, 17.78409, 4.983925, -20.660202, 12.173107, -2.8445535, -21.181595, 14.743402, -3.6340203, -22.836817, 14.563845, 1.3805406, -21.181595, 14.743402, -3.6340203, -20.660202, 12.173107, -2.8445535, -17.123413, 11.16952, -4.2650013, -22.836817, 14.563845, 1.3805406, -20.424162, 17.830454, 1.7678959, -20.092022, 16.877449, 4.697728, -21.181595, 14.743402, -3.6340203, -20.424162, 17.830454, 1.7678959, -22.836817, 14.563845, 1.3805406, -20.092022, 16.877449, 4.697728, -17.328682, 18.919605, 1.896874, -17.478313, 17.78409, 4.983925, -20.092022, 16.877449, 4.697728, -20.424162, 17.830454, 1.7678959, -17.328682, 18.919605, 1.896874, -21.181595, 14.743402, -3.6340203, -19.804983, 17.063326, -1.3250556, -20.424162, 17.830454, 1.7678959, -21.181595, 14.743402, -3.6340203, -17.123413, 11.16952, -4.2650013, -17.0918, 14.5098915, -4.268796, -19.804983, 17.063326, -1.3250556, -21.181595, 14.743402, -3.6340203, -17.0918, 14.5098915, -4.268796, -17.0918, 14.5098915, -4.268796, -17.123413, 11.16952, -4.2650013, -13.909912, 11.843496, -1.963623, -20.424162, 17.830454, 1.7678959, -19.804983, 17.063326, -1.3250556, -17.269672, 17.816965, -1.3629904, -19.804983, 17.063326, -1.3250556, -17.0918, 14.5098915, -4.268796, -17.269672, 17.816965, -1.3629904, -20.424162, 17.830454, 1.7678959, -17.269672, 17.816965, -1.3629904, -17.328682, 18.919605, 1.896874, -17.0918, 14.5098915, -4.268796, -13.909912, 11.843496, -1.963623, -13.211069, 14.243084, -1.9935503, -17.269672, 17.816965, -1.3629904, -17.0918, 14.5098915, -4.268796, -13.211069, 14.243084, -1.9935503, -12.704849, 10.935591, 1.5040407, -13.211069, 14.243084, -1.9935503, -13.909912, 11.843496, -1.963623, -17.328682, 18.919605, 1.896874, -17.269672, 17.816965, -1.3629904, -14.734785, 16.841198, -0.5958638, -17.269672, 17.816965, -1.3629904, -13.211069, 14.243084, -1.9935503, -14.734785, 16.841198, -0.5958638, -12.704849, 10.935591, 1.5040407, -12.182192, 14.322748, 1.7211109, -13.211069, 14.243084, -1.9935503, -12.182192, 14.322748, 1.7211109, -14.734785, 16.841198, -0.5958638, -13.211069, 14.243084, -1.9935503, -14.551012, 11.882277, 4.945571, -12.182192, 14.322748, 1.7211109, -12.704849, 10.935591, 1.5040407, -14.551012, 11.882277, 4.945571, -13.817183, 14.463108, 5.3506293, -12.182192, 14.322748, 1.7211109, -12.182192, 14.322748, 1.7211109, -14.323402, 17.77018, 1.8530385, -14.734785, 16.841198, -0.5958638, -17.328682, 18.919605, 1.896874, -14.734785, 16.841198, -0.5958638, -14.323402, 17.77018, 1.8530385, -13.817183, 14.463108, 5.3506293, -14.323402, 17.77018, 1.8530385, -12.182192, 14.322748, 1.7211109, -17.478313, 17.78409, 4.983925, -17.328682, 18.919605, 1.896874, -14.323402, 17.77018, 1.8530385, -13.817183, 14.463108, 5.3506293, -15.004122, 16.938564, 4.195724, -14.323402, 17.77018, 1.8530385, -17.478313, 17.78409, 4.983925, -14.323402, 17.77018, 1.8530385, -15.004122, 16.938564, 4.195724, -20.660202, 12.173107, -2.8445535, -17.690748, 9.96741, 1.2047776, -17.123413, 11.16952, -4.2650013, -20.660202, 12.173107, -2.8445535, -22.586025, 11.236962, 1.0783278, -17.690748, 9.96741, 1.2047776, -17.123413, 11.16952, -4.2650013, -17.690748, 9.96741, 1.2047776, -12.704849, 10.935591, 1.5040407, -17.123413, 11.16952, -4.2650013, -12.704849, 10.935591, 1.5040407, -13.909912, 11.843496, -1.963623, -17.690748, 9.96741, 1.2047776, -14.551012, 11.882277, 4.945571, -12.704849, 10.935591, 1.5040407, -17.690748, 9.96741, 1.2047776, -18.167463, 11.003454, 6.8469477, -14.551012, 11.882277, 4.945571, -22.586025, 11.236962, 1.0783278, -18.167463, 11.003454, 6.8469477, -17.690748, 9.96741, 1.2047776, -22.586025, 11.236962, 1.0783278, -21.319002, 11.927798, 5.296257, -18.167463, 11.003454, 6.8469477, -25.366648, 10.824738, 3.0151107, -23.412582, 12.270897, 4.4486246, -23.698359, 10.294915, 3.8003616, -23.698359, 10.294915, 3.8003616, -23.412582, 12.270897, 4.4486246, -20.900877, 12.281435, 3.6722255, -23.698359, 10.294915, 3.8003616, -20.900877, 12.281435, 3.6722255, -21.649036, 10.824738, 3.0151107, -25.366648, 10.824738, 3.0151107, -25.781822, 12.281435, 3.6722255, -23.412582, 12.270897, 4.4486246, -25.781822, 10.349287, 0.97084606, -25.781822, 12.281435, 3.6722255, -25.366648, 10.824738, 3.0151107, -23.412582, 12.270897, 4.4486246, -21.510363, 13.755413, 3.1213274, -20.900877, 12.281435, 3.6722255, -25.781822, 10.349287, 0.97084606, -25.922182, 12.314311, 1.342185, -25.781822, 12.281435, 3.6722255, -24.61638, 10.892176, -0.880373, -25.922182, 12.314311, 1.342185, -25.781822, 10.349287, 0.97084606, -25.781822, 12.281435, 3.6722255, -22.98392, 14.268375, 3.5440893, -23.412582, 12.270897, 4.4486246, -23.412582, 12.270897, 4.4486246, -22.98392, 14.268375, 3.5440893, -21.510363, 13.755413, 3.1213274, -25.922182, 12.314311, 1.342185, -24.61638, 13.755413, 3.1213274, -25.781822, 12.281435, 3.6722255, -25.781822, 12.281435, 3.6722255, -24.61638, 13.755413, 3.1213274, -22.98392, 14.268375, 3.5440893, -24.61638, 10.892176, -0.880373, -24.665274, 12.3467655, -0.9874339, -25.922182, 12.314311, 1.342185, -24.665274, 12.3467655, -0.9874339, -24.61638, 10.892176, -0.880373, -22.98392, 10.403238, -1.8582478, -25.922182, 12.314311, 1.342185, -24.665274, 14.278913, 1.713524, -24.61638, 13.755413, 3.1213274, -24.665274, 12.3467655, -0.9874339, -24.665274, 14.278913, 1.713524, -25.922182, 12.314311, 1.342185, -24.61638, 13.755413, 3.1213274, -22.841454, 14.934341, 1.8374442, -22.98392, 14.268375, 3.5440893, -24.61638, 13.755413, 3.1213274, -24.665274, 14.278913, 1.713524, -22.841454, 14.934341, 1.8374442, -24.665274, 12.3467655, -0.9874339, -24.120277, 13.784074, 0.11351812, -24.665274, 14.278913, 1.713524, -24.665274, 12.3467655, -0.9874339, -22.98392, 10.403238, -1.8582478, -22.841454, 12.357724, -1.763833, -24.120277, 13.784074, 0.11351812, -24.665274, 12.3467655, -0.9874339, -22.841454, 12.357724, -1.763833, -22.841454, 12.357724, -1.763833, -22.98392, 10.403238, -1.8582478, -21.510363, 10.892176, -0.880373, -24.665274, 14.278913, 1.713524, -24.120277, 13.784074, 0.11351812, -22.841454, 14.289871, -0.116620064, -24.120277, 13.784074, 0.11351812, -22.841454, 12.357724, -1.763833, -22.841454, 14.289871, -0.116620064, -24.665274, 14.278913, 1.713524, -22.841454, 14.289871, -0.116620064, -22.841454, 14.934341, 1.8374442, -22.841454, 12.357724, -1.763833, -21.510363, 10.892176, -0.880373, -21.160097, 12.3467655, -0.9874339, -22.841454, 14.289871, -0.116620064, -22.841454, 12.357724, -1.763833, -21.160097, 12.3467655, -0.9874339, -20.900877, 10.349287, 0.97084606, -21.160097, 12.3467655, -0.9874339, -21.510363, 10.892176, -0.880373, -22.841454, 14.934341, 1.8374442, -22.841454, 14.289871, -0.116620064, -21.625853, 13.784074, 0.11351812, -22.841454, 14.289871, -0.116620064, -21.160097, 12.3467655, -0.9874339, -21.625853, 13.784074, 0.11351812, -20.900877, 10.349287, 0.97084606, -20.331432, 12.314311, 1.342185, -21.160097, 12.3467655, -0.9874339, -20.331432, 12.314311, 1.342185, -21.625853, 13.784074, 0.11351812, -21.160097, 12.3467655, -0.9874339, -21.649036, 10.824738, 3.0151107, -20.331432, 12.314311, 1.342185, -20.900877, 10.349287, 0.97084606, -21.649036, 10.824738, 3.0151107, -20.900877, 12.281435, 3.6722255, -20.331432, 12.314311, 1.342185, -20.331432, 12.314311, 1.342185, -21.160097, 14.278913, 1.713524, -21.625853, 13.784074, 0.11351812, -22.841454, 14.934341, 1.8374442, -21.625853, 13.784074, 0.11351812, -21.160097, 14.278913, 1.713524, -20.900877, 12.281435, 3.6722255, -21.160097, 14.278913, 1.713524, -20.331432, 12.314311, 1.342185, -22.98392, 14.268375, 3.5440893, -22.841454, 14.934341, 1.8374442, -21.160097, 14.278913, 1.713524, -20.900877, 12.281435, 3.6722255, -21.510363, 13.755413, 3.1213274, -21.160097, 14.278913, 1.713524, -22.98392, 14.268375, 3.5440893, -21.160097, 14.278913, 1.713524, -21.510363, 13.755413, 3.1213274, -24.61638, 10.892176, -0.880373, -23.412582, 9.69428, 0.8473475, -22.98392, 10.403238, -1.8582478, -24.61638, 10.892176, -0.880373, -25.781822, 10.349287, 0.97084606, -23.412582, 9.69428, 0.8473475, -22.98392, 10.403238, -1.8582478, -23.412582, 9.69428, 0.8473475, -20.900877, 10.349287, 0.97084606, -22.98392, 10.403238, -1.8582478, -20.900877, 10.349287, 0.97084606, -21.510363, 10.892176, -0.880373, -23.412582, 9.69428, 0.8473475, -21.649036, 10.824738, 3.0151107, -20.900877, 10.349287, 0.97084606, -23.412582, 9.69428, 0.8473475, -23.698359, 10.294915, 3.8003616, -21.649036, 10.824738, 3.0151107, -25.781822, 10.349287, 0.97084606, -23.698359, 10.294915, 3.8003616, -23.412582, 9.69428, 0.8473475, -25.781822, 10.349287, 0.97084606, -25.366648, 10.824738, 3.0151107, -23.698359, 10.294915, 3.8003616, -20.253035, 9.625153, -3.2251654, -20.752932, 10.590384, -1.6858549, -20.401823, 9.271516, -2.002821, -20.401823, 9.271516, -2.002821, -20.752932, 10.590384, -1.6858549, -19.734592, 10.597971, -0.2561345, -20.401823, 9.271516, -2.002821, -19.734592, 10.597971, -0.2561345, -19.474949, 9.625154, -0.8681493, -20.253035, 9.625153, -3.2251654, -20.756725, 10.597969, -3.3503504, -20.752932, 10.590384, -1.6858549, -19.044178, 9.307764, -3.9160008, -20.756725, 10.597969, -3.3503504, -20.253035, 9.625153, -3.2251654, -20.752932, 10.590384, -1.6858549, -19.513306, 11.581747, -0.75813866, -19.734592, 10.597971, -0.2561345, -19.044178, 9.307764, -3.9160008, -19.309301, 10.619466, -3.9273815, -20.756725, 10.597969, -3.3503504, -17.62668, 9.670253, -3.5648928, -19.309301, 10.619466, -3.9273815, -19.044178, 9.307764, -3.9160008, -20.756725, 10.597969, -3.3503504, -20.089493, 11.924424, -1.6036634, -20.752932, 10.590384, -1.6858549, -20.752932, 10.590384, -1.6858549, -20.089493, 11.924424, -1.6036634, -19.513306, 11.581747, -0.75813866, -19.309301, 10.619466, -3.9273815, -20.163677, 11.581746, -2.7269554, -20.756725, 10.597969, -3.3503504, -20.756725, 10.597969, -3.3503504, -20.163677, 11.581746, -2.7269554, -20.089493, 11.924424, -1.6036634, -17.62668, 9.670253, -3.5648928, -17.569357, 10.641384, -3.6184235, -19.309301, 10.619466, -3.9273815, -17.569357, 10.641384, -3.6184235, -17.62668, 9.670253, -3.5648928, -16.665245, 9.344013, -2.7349634, -19.309301, 10.619466, -3.9273815, -19.281483, 11.931589, -3.052774, -20.163677, 11.581746, -2.7269554, -17.569357, 10.641384, -3.6184235, -19.281483, 11.931589, -3.052774, -19.309301, 10.619466, -3.9273815, -20.163677, 11.581746, -2.7269554, -18.978003, 12.368683, -1.870472, -20.089493, 11.924424, -1.6036634, -20.163677, 11.581746, -2.7269554, -19.281483, 11.931589, -3.052774, -18.978003, 12.368683, -1.870472, -17.569357, 10.641384, -3.6184235, -18.15271, 11.601134, -3.0422359, -19.281483, 11.931589, -3.052774, -17.569357, 10.641384, -3.6184235, -16.665245, 9.344013, -2.7349634, -16.69475, 10.64855, -2.6245313, -18.15271, 11.601134, -3.0422359, -17.569357, 10.641384, -3.6184235, -16.69475, 10.64855, -2.6245313, -16.69475, 10.64855, -2.6245313, -16.665245, 9.344013, -2.7349634, -16.976309, 9.670254, -1.596076, -19.281483, 11.931589, -3.052774, -18.15271, 11.601134, -3.0422359, -17.739222, 11.938755, -2.279746, -18.15271, 11.601134, -3.0422359, -16.69475, 10.64855, -2.6245313, -17.739222, 11.938755, -2.279746, -19.281483, 11.931589, -3.052774, -17.739222, 11.938755, -2.279746, -18.978003, 12.368683, -1.870472, -16.69475, 10.64855, -2.6245313, -16.976309, 9.670254, -1.596076, -16.835108, 10.641384, -1.396286, -17.739222, 11.938755, -2.279746, -16.69475, 10.64855, -2.6245313, -16.835108, 10.641384, -1.396286, -18.022047, 9.307765, -0.821784, -16.835108, 10.641384, -1.396286, -16.976309, 9.670254, -1.596076, -18.978003, 12.368683, -1.870472, -17.739222, 11.938755, -2.279746, -17.630474, 11.601135, -1.4607759, -17.739222, 11.938755, -2.279746, -16.835108, 10.641384, -1.396286, -17.630474, 11.601135, -1.4607759, -18.022047, 9.307765, -0.821784, -18.138802, 10.619467, -0.38300514, -16.835108, 10.641384, -1.396286, -18.138802, 10.619467, -0.38300514, -17.630474, 11.601135, -1.4607759, -16.835108, 10.641384, -1.396286, -19.474949, 9.625154, -0.8681493, -18.138802, 10.619467, -0.38300514, -18.022047, 9.307765, -0.821784, -19.474949, 9.625154, -0.8681493, -19.734592, 10.597971, -0.2561345, -18.138802, 10.619467, -0.38300514, -18.138802, 10.619467, -0.38300514, -18.547232, 11.93159, -0.8306365, -17.630474, 11.601135, -1.4607759, -18.978003, 12.368683, -1.870472, -17.630474, 11.601135, -1.4607759, -18.547232, 11.93159, -0.8306365, -19.734592, 10.597971, -0.2561345, -18.547232, 11.93159, -0.8306365, -18.138802, 10.619467, -0.38300514, -20.089493, 11.924424, -1.6036634, -18.978003, 12.368683, -1.870472, -18.547232, 11.93159, -0.8306365, -19.734592, 10.597971, -0.2561345, -19.513306, 11.581747, -0.75813866, -18.547232, 11.93159, -0.8306365, -20.089493, 11.924424, -1.6036634, -18.547232, 11.93159, -0.8306365, -19.513306, 11.581747, -0.75813866, -17.62668, 9.670253, -3.5648928, -18.469677, 8.87025, -2.4403362, -16.665245, 9.344013, -2.7349634, -17.62668, 9.670253, -3.5648928, -19.044178, 9.307764, -3.9160008, -18.469677, 8.87025, -2.4403362, -16.665245, 9.344013, -2.7349634, -18.469677, 8.87025, -2.4403362, -18.022047, 9.307765, -0.821784, -16.665245, 9.344013, -2.7349634, -18.022047, 9.307765, -0.821784, -16.976309, 9.670254, -1.596076, -18.469677, 8.87025, -2.4403362, -19.474949, 9.625154, -0.8681493, -18.022047, 9.307765, -0.821784, -18.469677, 8.87025, -2.4403362, -20.401823, 9.271516, -2.002821, -19.474949, 9.625154, -0.8681493, -19.044178, 9.307764, -3.9160008, -20.401823, 9.271516, -2.002821, -18.469677, 8.87025, -2.4403362, -19.044178, 9.307764, -3.9160008, -20.253035, 9.625153, -3.2251654, -20.401823, 9.271516, -2.002821, -17.464405, 7.537475, 1.8374468, -19.955458, 8.19459, 1.3919232, -17.577787, 7.2154503, 1.3678982, -17.464405, 7.537475, 1.8374468, -19.75946, 8.452126, 1.8374465, -19.955458, 8.19459, 1.3919232, -19.75946, 8.452126, 1.8374465, -22.311209, 10.6898575, 1.7080457, -19.955458, 8.19459, 1.3919232, -19.75946, 8.452126, 1.8374465, -22.217215, 10.689015, 1.8374457, -22.311209, 10.6898575, 1.7080457, -19.955458, 8.19459, 1.3919232, -22.311209, 10.6898575, 1.7080457, -22.46337, 10.691122, 1.757361, -17.577787, 7.2154503, 1.3678982, -19.955458, 8.19459, 1.3919232, -20.272423, 7.7777286, 1.5622085, -19.955458, 8.19459, 1.3919232, -22.46337, 10.691122, 1.757361, -20.272423, 7.7777286, 1.5622085, -17.577787, 7.2154503, 1.3678982, -20.272423, 7.7777286, 1.5622085, -17.761982, 6.6949005, 1.547035, -20.272423, 7.7777286, 1.5622085, -22.46337, 10.691122, 1.757361, -22.46337, 10.691122, 1.9175303, -17.761982, 6.6949005, 1.547035, -20.272423, 7.7777286, 1.5622085, -20.272423, 7.777729, 2.1126847, -20.272423, 7.7777286, 1.5622085, -22.46337, 10.691122, 1.9175303, -20.272423, 7.777729, 2.1126847, -17.761982, 6.6949005, 1.547035, -20.272423, 7.777729, 2.1126847, -17.761982, 6.694901, 2.127859, -20.272423, 7.777729, 2.1126847, -22.46337, 10.691122, 1.9175303, -22.311209, 10.689858, 1.9668455, -17.761982, 6.694901, 2.127859, -20.272423, 7.777729, 2.1126847, -19.955458, 8.194591, 2.28297, -20.272423, 7.777729, 2.1126847, -22.311209, 10.689858, 1.9668455, -19.955458, 8.194591, 2.28297, -19.955458, 8.194591, 2.28297, -22.311209, 10.689858, 1.9668455, -22.217215, 10.689015, 1.8374457, -17.761982, 6.694901, 2.127859, -19.955458, 8.194591, 2.28297, -17.577787, 7.215451, 2.3069956, -19.955458, 8.194591, 2.28297, -22.217215, 10.689015, 1.8374457, -19.75946, 8.452126, 1.8374465, -17.577787, 7.215451, 2.3069956, -19.955458, 8.194591, 2.28297, -19.75946, 8.452126, 1.8374465, -17.577787, 7.215451, 2.3069956, -19.75946, 8.452126, 1.8374465, -17.464405, 7.537475, 1.8374468, -19.772528, 3.1467314, 1.8374484, -17.801182, 7.205756, 2.2829702, -18.92911, 3.1467319, 3.0694866, -19.772528, 3.1467314, 1.8374484, -18.119411, 7.263501, 1.8374469, -17.801182, 7.205756, 2.2829702, -18.119411, 7.263501, 1.8374469, -17.641012, 11.282906, 2.034285, -17.801182, 7.205756, 2.2829702, -18.119411, 7.263501, 1.8374469, -17.783056, 11.2980795, 1.8374455, -17.641012, 11.282906, 2.034285, -17.801182, 7.205756, 2.2829702, -17.641012, 11.282906, 2.034285, -17.410873, 11.258881, 1.9592583, -18.92911, 3.1467319, 3.0694866, -17.801182, 7.205756, 2.2829702, -17.28611, 7.1121836, 2.1126852, -17.801182, 7.205756, 2.2829702, -17.410873, 11.258881, 1.9592583, -17.28611, 7.1121836, 2.1126852, -18.92911, 3.1467319, 3.0694866, -17.28611, 7.1121836, 2.1126852, -17.563877, 3.1463103, 2.599095, -17.28611, 7.1121836, 2.1126852, -17.410873, 11.258881, 1.9592583, -17.410873, 11.25888, 1.7156326, -17.563877, 3.1463103, 2.599095, -17.28611, 7.1121836, 2.1126852, -17.28611, 7.1121836, 1.5622088, -17.28611, 7.1121836, 2.1126852, -17.410873, 11.25888, 1.7156326, -17.28611, 7.1121836, 1.5622088, -17.563877, 3.1463103, 2.599095, -17.28611, 7.1121836, 1.5622088, -17.563877, 3.1463099, 1.0758016, -17.28611, 7.1121836, 1.5622088, -17.410873, 11.25888, 1.7156326, -17.641012, 11.282906, 1.6406059, -17.563877, 3.1463099, 1.0758016, -17.28611, 7.1121836, 1.5622088, -17.801182, 7.205756, 1.3919237, -17.28611, 7.1121836, 1.5622088, -17.641012, 11.282906, 1.6406059, -17.801182, 7.205756, 1.3919237, -17.801182, 7.205756, 1.3919237, -17.641012, 11.282906, 1.6406059, -17.783056, 11.2980795, 1.8374455, -17.563877, 3.1463099, 1.0758016, -17.801182, 7.205756, 1.3919237, -18.92911, 3.146731, 0.60541, -17.801182, 7.205756, 1.3919237, -17.783056, 11.2980795, 1.8374455, -18.119411, 7.263501, 1.8374469, -18.92911, 3.146731, 0.60541, -17.801182, 7.205756, 1.3919237, -18.119411, 7.263501, 1.8374469, -18.92911, 3.146731, 0.60541, -18.119411, 7.263501, 1.8374469, -19.772528, 3.1467314, 1.8374484, -17.85176, 7.430414, 1.5381833, -18.091171, 7.869193, -0.13432074, -17.577787, 7.2154503, 1.3678982, -17.85176, 7.430414, 1.5381833, -18.33269, 8.041164, 0.08317232, -18.091171, 7.869193, -0.13432074, -18.33269, 8.041164, 0.08317232, -18.784958, 9.534952, -1.5615134, -18.091171, 7.869193, -0.13432074, -18.33269, 8.041164, 0.08317232, -18.84734, 9.534531, -1.4746847, -18.784958, 9.534952, -1.5615134, -18.091171, 7.869193, -0.13432074, -18.784958, 9.534952, -1.5615134, -18.848183, 9.535795, -1.6474986, -17.577787, 7.2154503, 1.3678982, -18.091171, 7.869193, -0.13432074, -18.265673, 7.5910044, -0.29954767, -18.091171, 7.869193, -0.13432074, -18.848183, 9.535795, -1.6474986, -18.265673, 7.5910044, -0.29954767, -17.577787, 7.2154503, 1.3678982, -18.265673, 7.5910044, -0.29954767, -17.73037, 6.867715, 1.2886567, -18.265673, 7.5910044, -0.29954767, -18.848183, 9.535795, -1.6474986, -18.94934, 9.535795, -1.6142006, -17.73037, 6.867715, 1.2886567, -18.265673, 7.5910044, -0.29954767, -18.614672, 7.591005, -0.18447876, -18.265673, 7.5910044, -0.29954767, -18.94934, 9.535795, -1.6142006, -18.614672, 7.591005, -0.18447876, -17.73037, 6.867715, 1.2886567, -18.614672, 7.591005, -0.18447876, -18.098337, 6.867715, 1.4100481, -18.614672, 7.591005, -0.18447876, -18.94934, 9.535795, -1.6142006, -18.94892, 9.534952, -1.5075612, -18.098337, 6.867715, 1.4100481, -18.614672, 7.591005, -0.18447876, -18.65598, 7.869193, 0.052403092, -18.614672, 7.591005, -0.18447876, -18.94892, 9.534952, -1.5075612, -18.65598, 7.869193, 0.052403092, -18.65598, 7.869193, 0.052403092, -18.94892, 9.534952, -1.5075612, -18.84734, 9.534531, -1.4746847, -18.098337, 6.867715, 1.4100481, -18.65598, 7.869193, 0.052403092, -18.173365, 7.2154503, 1.5643163, -18.65598, 7.869193, 0.052403092, -18.84734, 9.534531, -1.4746847, -18.33269, 8.041164, 0.08317232, -18.173365, 7.2154503, 1.5643163, -18.65598, 7.869193, 0.052403092, -18.33269, 8.041164, 0.08317232, -18.173365, 7.2154503, 1.5643163, -18.33269, 8.041164, 0.08317232, -17.85176, 7.430414, 1.5381833, -18.021717, 16.363804, -17.998592, -21.997292, 20.020512, -13.591122, -21.163078, 14.983807, -13.799868, -21.163078, 14.983807, -13.799868, -21.997292, 20.020512, -13.591122, -20.16242, 20.148884, -7.004968, -21.163078, 14.983807, -13.799868, -20.16242, 20.148884, -7.004968, -19.366419, 16.295841, -7.970547, -18.021717, 16.363804, -17.998592, -19.619282, 19.941853, -19.07104, -21.997292, 20.020512, -13.591122, -11.484028, 15.332424, -18.699127, -19.619282, 19.941853, -19.07104, -18.021717, 16.363804, -17.998592, -21.997292, 20.020512, -13.591122, -18.141577, 23.844606, -8.429464, -20.16242, 20.148884, -7.004968, -11.484028, 15.332424, -18.699127, -11.858614, 20.29928, -19.150177, -19.619282, 19.941853, -19.07104, -6.256456, 16.73004, -14.795372, -11.858614, 20.29928, -19.150177, -11.484028, 15.332424, -18.699127, -19.619282, 19.941853, -19.07104, -18.61851, 25.10693, -12.27676, -21.997292, 20.020512, -13.591122, -21.997292, 20.020512, -13.591122, -18.61851, 25.10693, -12.27676, -18.141577, 23.844606, -8.429464, -11.858614, 20.29928, -19.150177, -17.480486, 23.753365, -16.033627, -19.619282, 19.941853, -19.07104, -19.619282, 19.941853, -19.07104, -17.480486, 23.753365, -16.033627, -18.61851, 25.10693, -12.27676, -6.256456, 16.73004, -14.795372, -4.954714, 20.567348, -15.343607, -11.858614, 20.29928, -19.150177, -4.954714, 20.567348, -15.343607, -6.256456, 16.73004, -14.795372, -5.1436496, 15.231738, -9.215076, -11.858614, 20.29928, -19.150177, -13.089849, 25.17615, -15.716137, -17.480486, 23.753365, -16.033627, -4.954714, 20.567348, -15.343607, -13.089849, 25.17615, -15.716137, -11.858614, 20.29928, -19.150177, -17.480486, 23.753365, -16.033627, -14.129517, 26.802193, -11.209101, -18.61851, 25.10693, -12.27676, -17.480486, 23.753365, -16.033627, -13.089849, 25.17615, -15.716137, -14.129517, 26.802193, -11.209101, -4.954714, 20.567348, -15.343607, -8.721195, 24.030872, -13.957819, -13.089849, 25.17615, -15.716137, -4.954714, 20.567348, -15.343607, -5.1436496, 15.231738, -9.215076, -5.146767, 20.21873, -9.1676445, -8.721195, 24.030872, -13.957819, -4.954714, 20.567348, -15.343607, -5.146767, 20.21873, -9.1676445, -5.146767, 20.21873, -9.1676445, -5.1436496, 15.231738, -9.215076, -9.403631, 16.237947, -5.131659, -13.089849, 25.17615, -15.716137, -8.721195, 24.030872, -13.957819, -9.362032, 25.156012, -10.226952, -8.721195, 24.030872, -13.957819, -5.146767, 20.21873, -9.1676445, -9.362032, 25.156012, -10.226952, -13.089849, 25.17615, -15.716137, -9.362032, 25.156012, -10.226952, -14.129517, 26.802193, -11.209101, -5.146767, 20.21873, -9.1676445, -9.403631, 16.237947, -5.131659, -9.5517025, 19.8204, -4.097917, -9.362032, 25.156012, -10.226952, -5.146767, 20.21873, -9.1676445, -9.5517025, 19.8204, -4.097917, -14.8233185, 14.882492, -4.315932, -9.5517025, 19.8204, -4.097917, -9.403631, 16.237947, -5.131659, -14.129517, 26.802193, -11.209101, -9.362032, 25.156012, -10.226952, -11.184143, 23.699244, -6.717871, -9.362032, 25.156012, -10.226952, -9.5517025, 19.8204, -4.097917, -11.184143, 23.699244, -6.717871, -14.8233185, 14.882492, -4.315932, -15.285444, 19.939335, -3.6085896, -9.5517025, 19.8204, -4.097917, -15.285444, 19.939335, -3.6085896, -11.184143, 23.699244, -6.717871, -9.5517025, 19.8204, -4.097917, -19.366419, 16.295841, -7.970547, -15.285444, 19.939335, -3.6085896, -14.8233185, 14.882492, -4.315932, -19.366419, 16.295841, -7.970547, -20.16242, 20.148884, -7.004968, -15.285444, 19.939335, -3.6085896, -15.285444, 19.939335, -3.6085896, -14.890806, 25.086163, -6.7869554, -11.184143, 23.699244, -6.717871, -14.129517, 26.802193, -11.209101, -11.184143, 23.699244, -6.717871, -14.890806, 25.086163, -6.7869554, -20.16242, 20.148884, -7.004968, -14.890806, 25.086163, -6.7869554, -15.285444, 19.939335, -3.6085896, -18.61851, 25.10693, -12.27676, -14.129517, 26.802193, -11.209101, -14.890806, 25.086163, -6.7869554, -20.16242, 20.148884, -7.004968, -18.141577, 23.844606, -8.429464, -14.890806, 25.086163, -6.7869554, -18.61851, 25.10693, -12.27676, -14.890806, 25.086163, -6.7869554, -18.141577, 23.844606, -8.429464, -6.256456, 16.73004, -14.795372, -13.014427, 13.437051, -11.550283, -5.1436496, 15.231738, -9.215076, -6.256456, 16.73004, -14.795372, -11.484028, 15.332424, -18.699127, -13.014427, 13.437051, -11.550283, -5.1436496, 15.231738, -9.215076, -13.014427, 13.437051, -11.550283, -14.8233185, 14.882492, -4.315932, -5.1436496, 15.231738, -9.215076, -14.8233185, 14.882492, -4.315932, -9.403631, 16.237947, -5.131659, -13.014427, 13.437051, -11.550283, -19.366419, 16.295841, -7.970547, -14.8233185, 14.882492, -4.315932, -13.014427, 13.437051, -11.550283, -21.163078, 14.983807, -13.799868, -19.366419, 16.295841, -7.970547, -11.484028, 15.332424, -18.699127, -21.163078, 14.983807, -13.799868, -13.014427, 13.437051, -11.550283, -11.484028, 15.332424, -18.699127, -18.021717, 16.363804, -17.998592, -21.163078, 14.983807, -13.799868, -13.562267, 14.716995, -23.311634, -16.202703, 16.876032, -20.837955, -15.172899, 13.925998, -21.079226, -15.172899, 13.925998, -21.079226, -16.202703, 16.876032, -20.837955, -15.753397, 16.891764, -16.938854, -15.172899, 13.925998, -21.079226, -15.753397, 16.891764, -16.938854, -14.583576, 14.716994, -17.856216, -13.562267, 14.716995, -23.311634, -14.412493, 16.891764, -24.101406, -16.202703, 16.876032, -20.837955, -10.448349, 14.0071745, -23.35928, -14.412493, 16.891764, -24.101406, -13.562267, 14.716995, -23.311634, -16.202703, 16.876032, -20.837955, -14.777542, 19.092333, -17.681902, -15.753397, 16.891764, -16.938854, -10.448349, 14.0071745, -23.35928, -10.954712, 16.940847, -23.667263, -14.412493, 16.891764, -24.101406, -8.051947, 14.8176775, -21.140478, -10.954712, 16.940847, -23.667263, -10.448349, 14.0071745, -23.35928, -14.412493, 16.891764, -24.101406, -14.993107, 19.85816, -19.960417, -16.202703, 16.876032, -20.837955, -16.202703, 16.876032, -20.837955, -14.993107, 19.85816, -19.960417, -14.777542, 19.092333, -17.681902, -10.954712, 16.940847, -23.667263, -13.92425, 19.092335, -22.239834, -14.412493, 16.891764, -24.101406, -14.412493, 16.891764, -24.101406, -13.92425, 19.092335, -22.239834, -14.993107, 19.85816, -19.960417, -8.051947, 14.8176775, -21.140478, -7.8814073, 16.989302, -21.182816, -10.954712, 16.940847, -23.667263, -7.8814073, 16.989302, -21.182816, -8.051947, 14.8176775, -21.140478, -7.065434, 14.08772, -18.476276, -10.954712, 16.940847, -23.667263, -11.844934, 19.873892, -21.924828, -13.92425, 19.092335, -22.239834, -7.8814073, 16.989302, -21.182816, -11.844934, 19.873892, -21.924828, -10.954712, 16.940847, -23.667263, -13.92425, 19.092335, -22.239834, -12.527824, 20.852411, -19.282501, -14.993107, 19.85816, -19.960417, -13.92425, 19.092335, -22.239834, -11.844934, 19.873892, -21.924828, -12.527824, 20.852411, -19.282501, -7.8814073, 16.989302, -21.182816, -9.646725, 19.135124, -20.685514, -11.844934, 19.873892, -21.924828, -7.8814073, 16.989302, -21.182816, -7.065434, 14.08772, -18.476276, -7.2431235, 17.005661, -18.293152, -9.646725, 19.135124, -20.685514, -7.8814073, 16.989302, -21.182816, -7.2431235, 17.005661, -18.293152, -7.2431235, 17.005661, -18.293152, -7.065434, 14.08772, -18.476276, -8.905237, 14.8176775, -16.582546, -11.844934, 19.873892, -21.924828, -9.646725, 19.135124, -20.685514, -9.66033, 19.890251, -18.745678, -9.646725, 19.135124, -20.685514, -7.2431235, 17.005661, -18.293152, -9.66033, 19.890251, -18.745678, -11.844934, 19.873892, -21.924828, -9.66033, 19.890251, -18.745678, -12.527824, 20.852411, -19.282501, -7.2431235, 17.005661, -18.293152, -8.905237, 14.8176775, -16.582546, -8.844357, 16.9893, -16.039139, -9.66033, 19.890251, -18.745678, -7.2431235, 17.005661, -18.293152, -8.844357, 16.9893, -16.039139, -11.789251, 14.007174, -16.196726, -8.844357, 16.9893, -16.039139, -8.905237, 14.8176775, -16.582546, -12.527824, 20.852411, -19.282501, -9.66033, 19.890251, -18.745678, -10.331999, 19.135124, -17.025066, -9.66033, 19.890251, -18.745678, -8.844357, 16.9893, -16.039139, -10.331999, 19.135124, -17.025066, -11.789251, 14.007174, -16.196726, -12.490612, 16.940847, -15.46311, -8.844357, 16.9893, -16.039139, -12.490612, 16.940847, -15.46311, -10.331999, 19.135124, -17.025066, -8.844357, 16.9893, -16.039139, -14.583576, 14.716994, -17.856216, -12.490612, 16.940847, -15.46311, -11.789251, 14.007174, -16.196726, -14.583576, 14.716994, -17.856216, -15.753397, 16.891764, -16.938854, -12.490612, 16.940847, -15.46311, -12.490612, 16.940847, -15.46311, -12.807882, 19.87389, -16.78115, -10.331999, 19.135124, -17.025066, -12.527824, 20.852411, -19.282501, -10.331999, 19.135124, -17.025066, -12.807882, 19.87389, -16.78115, -15.753397, 16.891764, -16.938854, -12.807882, 19.87389, -16.78115, -12.490612, 16.940847, -15.46311, -14.993107, 19.85816, -19.960417, -12.527824, 20.852411, -19.282501, -12.807882, 19.87389, -16.78115, -15.753397, 16.891764, -16.938854, -14.777542, 19.092333, -17.681902, -12.807882, 19.87389, -16.78115, -14.993107, 19.85816, -19.960417, -12.807882, 19.87389, -16.78115, -14.777542, 19.092333, -17.681902, -8.051947, 14.8176775, -21.140478, -10.918001, 13.029283, -19.848606, -7.065434, 14.08772, -18.476276, -8.051947, 14.8176775, -21.140478, -10.448349, 14.0071745, -23.35928, -10.918001, 13.029283, -19.848606, -7.065434, 14.08772, -18.476276, -10.918001, 13.029283, -19.848606, -11.789251, 14.007174, -16.196726, -7.065434, 14.08772, -18.476276, -11.789251, 14.007174, -16.196726, -8.905237, 14.8176775, -16.582546, -10.918001, 13.029283, -19.848606, -14.583576, 14.716994, -17.856216, -11.789251, 14.007174, -16.196726, -10.918001, 13.029283, -19.848606, -15.172899, 13.925998, -21.079226, -14.583576, 14.716994, -17.856216, -10.448349, 14.0071745, -23.35928, -15.172899, 13.925998, -21.079226, -10.918001, 13.029283, -19.848606, -10.448349, 14.0071745, -23.35928, -13.562267, 14.716995, -23.311634, -15.172899, 13.925998, -21.079226, -5.8097816, 12.926081, -14.093311, -7.931314, 14.367117, -15.249769, -7.562638, 12.39812, -14.647457, -7.562638, 12.39812, -14.647457, -7.931314, 14.367117, -15.249769, -10.30912, 14.378445, -14.14818, -7.562638, 12.39812, -14.647457, -10.30912, 14.378445, -14.14818, -9.4823475, 12.926081, -13.599033, -5.8097816, 12.926081, -14.093311, -5.4877048, 14.378444, -14.798061, -7.931314, 14.367117, -15.249769, -5.128114, 12.452237, -12.129587, -5.4877048, 14.378444, -14.798061, -5.8097816, 12.926081, -14.093311, -7.931314, 14.367117, -15.249769, -9.633245, 15.847169, -13.685543, -10.30912, 14.378445, -14.14818, -5.128114, 12.452237, -12.129587, -5.038579, 14.410537, -12.515514, -5.4877048, 14.378444, -14.798061, -6.032766, 12.993412, -10.145935, -5.038579, 14.410537, -12.515514, -5.128114, 12.452237, -12.129587, -5.4877048, 14.378444, -14.798061, -8.234188, 16.358768, -14.298784, -7.931314, 14.367117, -15.249769, -7.931314, 14.367117, -15.249769, -8.234188, 16.358768, -14.298784, -9.633245, 15.847169, -13.685543, -5.038579, 14.410537, -12.515514, -6.565431, 15.847169, -14.099052, -5.4877048, 14.378444, -14.798061, -5.4877048, 14.378444, -14.798061, -6.565431, 15.847169, -14.099052, -8.234188, 16.358768, -14.298784, -6.032766, 12.993412, -10.145935, -5.9699607, 14.443258, -10.047108, -5.038579, 14.410537, -12.515514, -5.9699607, 14.443258, -10.047108, -6.032766, 12.993412, -10.145935, -7.5147758, 12.506355, -8.963072, -5.038579, 14.410537, -12.515514, -6.3296666, 16.369465, -12.714966, -6.565431, 15.847169, -14.099052, -5.9699607, 14.443258, -10.047108, -6.3296666, 16.369465, -12.714966, -5.038579, 14.410537, -12.515514, -6.565431, 15.847169, -14.099052, -8.14801, 17.022022, -12.594429, -8.234188, 16.358768, -14.298784, -6.565431, 15.847169, -14.099052, -6.3296666, 16.369465, -12.714966, -8.14801, 17.022022, -12.594429, -5.9699607, 14.443258, -10.047108, -6.655229, 15.876115, -11.061441, -6.3296666, 16.369465, -12.714966, -5.9699607, 14.443258, -10.047108, -7.5147758, 12.506355, -8.963072, -7.6687236, 14.453957, -9.036707, -6.655229, 15.876115, -11.061441, -5.9699607, 14.443258, -10.047108, -7.6687236, 14.453957, -9.036707, -7.6687236, 14.453957, -9.036707, -7.5147758, 12.506355, -8.963072, -9.10058, 12.993413, -9.732424, -6.3296666, 16.369465, -12.714966, -6.655229, 15.876115, -11.061441, -7.8877406, 16.380163, -10.664139, -6.655229, 15.876115, -11.061441, -7.6687236, 14.453957, -9.036707, -7.8877406, 16.380163, -10.664139, -6.3296666, 16.369465, -12.714966, -7.8877406, 16.380163, -10.664139, -8.14801, 17.022022, -12.594429, -7.6687236, 14.453957, -9.036707, -9.10058, 12.993413, -9.732424, -9.432554, 14.443259, -9.580103, -7.8877406, 16.380163, -10.664139, -7.6687236, 14.453957, -9.036707, -9.432554, 14.443259, -9.580103, -9.94953, 12.452238, -11.479706, -9.432554, 14.443259, -9.580103, -9.10058, 12.993413, -9.732424, -8.14801, 17.022022, -12.594429, -7.8877406, 16.380163, -10.664139, -9.119415, 15.876115, -10.7295475, -7.8877406, 16.380163, -10.664139, -9.432554, 14.443259, -9.580103, -9.119415, 15.876115, -10.7295475, -9.94953, 12.452238, -11.479706, -10.561342, 14.410537, -11.77158, -9.432554, 14.443259, -9.580103, -10.561342, 14.410537, -11.77158, -9.119415, 15.876115, -10.7295475, -9.432554, 14.443259, -9.580103, -9.4823475, 12.926081, -13.599033, -10.561342, 14.410537, -11.77158, -9.94953, 12.452238, -11.479706, -9.4823475, 12.926081, -13.599033, -10.30912, 14.378445, -14.14818, -10.561342, 14.410537, -11.77158, -10.561342, 14.410537, -11.77158, -9.792261, 16.369465, -12.247959, -9.119415, 15.876115, -10.7295475, -8.14801, 17.022022, -12.594429, -9.119415, 15.876115, -10.7295475, -9.792261, 16.369465, -12.247959, -10.30912, 14.378445, -14.14818, -9.792261, 16.369465, -12.247959, -10.561342, 14.410537, -11.77158, -8.234188, 16.358768, -14.298784, -8.14801, 17.022022, -12.594429, -9.792261, 16.369465, -12.247959, -10.30912, 14.378445, -14.14818, -9.633245, 15.847169, -13.685543, -9.792261, 16.369465, -12.247959, -8.234188, 16.358768, -14.298784, -9.792261, 16.369465, -12.247959, -9.633245, 15.847169, -13.685543, -6.032766, 12.993412, -10.145935, -7.45141, 11.799051, -11.691931, -7.5147758, 12.506355, -8.963072, -6.032766, 12.993412, -10.145935, -5.128114, 12.452237, -12.129587, -7.45141, 11.799051, -11.691931, -7.5147758, 12.506355, -8.963072, -7.45141, 11.799051, -11.691931, -9.94953, 12.452238, -11.479706, -7.5147758, 12.506355, -8.963072, -9.94953, 12.452238, -11.479706, -9.10058, 12.993413, -9.732424, -7.45141, 11.799051, -11.691931, -9.4823475, 12.926081, -13.599033, -9.94953, 12.452238, -11.479706, -7.45141, 11.799051, -11.691931, -7.562638, 12.39812, -14.647457, -9.4823475, 12.926081, -13.599033, -5.128114, 12.452237, -12.129587, -7.562638, 12.39812, -14.647457, -7.45141, 11.799051, -11.691931, -5.128114, 12.452237, -12.129587, -5.8097816, 12.926081, -14.093311, -7.562638, 12.39812, -14.647457, -14.00502, 9.80929, -11.391943, -12.666889, 10.790327, -14.925047, -13.28483, 9.328525, -11.429331, -14.00502, 9.80929, -11.391943, -13.374517, 11.174813, -14.759827, -12.666889, 10.790327, -14.925047, -13.374517, 11.174813, -14.759827, -12.483606, 14.515627, -18.468845, -12.666889, 10.790327, -14.925047, -13.374517, 11.174813, -14.759827, -12.699318, 14.514368, -18.366463, -12.483606, 14.515627, -18.468845, -12.666889, 10.790327, -14.925047, -12.483606, 14.515627, -18.468845, -12.5141735, 14.517514, -18.70568, -13.28483, 9.328525, -11.429331, -12.666889, 10.790327, -14.925047, -12.829697, 10.167975, -15.43696, -12.666889, 10.790327, -14.925047, -12.5141735, 14.517514, -18.70568, -12.829697, 10.167975, -15.43696, -13.28483, 9.328525, -11.429331, -12.829697, 10.167975, -15.43696, -13.497103, 8.551372, -11.74884, -12.829697, 10.167975, -15.43696, -12.5141735, 14.517514, -18.70568, -12.749214, 14.517514, -18.749683, -13.497103, 8.551372, -11.74884, -12.829697, 10.167975, -15.43696, -13.637495, 10.167976, -15.588188, -12.829697, 10.167975, -15.43696, -12.749214, 14.517514, -18.749683, -13.637495, 10.167976, -15.588188, -13.497103, 8.551372, -11.74884, -13.637495, 10.167976, -15.588188, -14.349434, 8.551372, -11.908405, -13.637495, 10.167976, -15.588188, -12.749214, 14.517514, -18.749683, -12.863384, 14.515627, -18.539944, -14.349434, 8.551372, -11.908405, -13.637495, 10.167976, -15.588188, -13.974457, 10.790327, -15.169838, -13.637495, 10.167976, -15.588188, -12.863384, 14.515627, -18.539944, -13.974457, 10.790327, -15.169838, -13.974457, 10.790327, -15.169838, -12.863384, 14.515627, -18.539944, -12.699318, 14.514368, -18.366463, -14.349434, 8.551372, -11.908405, -13.974457, 10.790327, -15.169838, -14.662912, 9.328525, -11.687323, -13.974457, 10.790327, -15.169838, -12.699318, 14.514368, -18.366463, -13.374517, 11.174813, -14.759827, -14.662912, 9.328525, -11.687323, -13.974457, 10.790327, -15.169838, -13.374517, 11.174813, -14.759827, -14.662912, 9.328525, -11.687323, -13.374517, 11.174813, -14.759827, -14.00502, 9.80929, -11.391943, -13.370928, 3.2541475, -14.779001, -14.566284, 9.314051, -12.008541, -15.410591, 3.2541475, -13.879795, -13.370928, 3.2541475, -14.779001, -13.825075, 9.400261, -12.353134, -14.566284, 9.314051, -12.008541, -13.825075, 9.400261, -12.353134, -14.245354, 15.401014, -11.705181, -14.566284, 9.314051, -12.008541, -13.825075, 9.400261, -12.353134, -13.917479, 15.423668, -11.859549, -14.245354, 15.401014, -11.705181, -14.566284, 9.314051, -12.008541, -14.245354, 15.401014, -11.705181, -14.198481, 15.365146, -11.346853, -15.410591, 3.2541475, -13.879795, -14.566284, 9.314051, -12.008541, -14.457899, 9.174353, -11.205919, -14.566284, 9.314051, -12.008541, -14.198481, 15.365146, -11.346853, -14.457899, 9.174353, -11.205919, -15.410591, 3.2541475, -13.879795, -14.457899, 9.174353, -11.205919, -15.095373, 3.253518, -11.747156, -14.457899, 9.174353, -11.205919, -14.198481, 15.365146, -11.346853, -13.840971, 15.365146, -11.279924, -15.095373, 3.253518, -11.747156, -14.457899, 9.174353, -11.205919, -13.650103, 9.174353, -11.054691, -14.457899, 9.174353, -11.205919, -13.840971, 15.365146, -11.279924, -13.650103, 9.174353, -11.054691, -15.095373, 3.253518, -11.747156, -13.650103, 9.174353, -11.054691, -12.860012, 3.253518, -11.328674, -13.650103, 9.174353, -11.054691, -13.840971, 15.365146, -11.279924, -13.667648, 15.401014, -11.597029, -12.860012, 3.253518, -11.328674, -13.650103, 9.174353, -11.054691, -13.258715, 9.314051, -11.763752, -13.650103, 9.174353, -11.054691, -13.667648, 15.401014, -11.597029, -13.258715, 9.314051, -11.763752, -13.258715, 9.314051, -11.763752, -13.667648, 15.401014, -11.597029, -13.917479, 15.423668, -11.859549, -12.860012, 3.253518, -11.328674, -13.258715, 9.314051, -11.763752, -11.794676, 3.2541475, -13.20286, -13.258715, 9.314051, -11.763752, -13.917479, 15.423668, -11.859549, -13.825075, 9.400261, -12.353134, -11.794676, 3.2541475, -13.20286, -13.258715, 9.314051, -11.763752, -13.825075, 9.400261, -12.353134, -11.794676, 3.2541475, -13.20286, -13.825075, 9.400261, -12.353134, -13.370928, 3.2541475, -14.779001, -13.45945, 9.649454, -11.878155, -10.93936, 10.304527, -11.770006, -13.28483, 9.328525, -11.429331, -13.45945, 9.649454, -11.878155, -11.192169, 10.561271, -12.184172, -10.93936, 10.304527, -11.770006, -11.192169, 10.561271, -12.184172, -8.654425, 12.791416, -12.396021, -10.93936, 10.304527, -11.770006, -11.192169, 10.561271, -12.184172, -8.764704, 12.790787, -12.511417, -8.654425, 12.791416, -12.396021, -10.93936, 10.304527, -11.770006, -8.654425, 12.791416, -12.396021, -8.510876, 12.792675, -12.465178, -13.28483, 9.328525, -11.429331, -10.93936, 10.304527, -11.770006, -10.648958, 9.889207, -11.980684, -10.93936, 10.304527, -11.770006, -8.510876, 12.792675, -12.465178, -10.648958, 9.889207, -11.980684, -13.28483, 9.328525, -11.429331, -10.648958, 9.889207, -11.980684, -13.126629, 8.809374, -11.63147, -10.648958, 9.889207, -11.980684, -8.510876, 12.792675, -12.465178, -8.53195, 12.792675, -12.622772, -13.126629, 8.809374, -11.63147, -10.648958, 9.889207, -11.980684, -10.721937, 9.889207, -12.524437, -10.648958, 9.889207, -11.980684, -8.53195, 12.792675, -12.622772, -10.721937, 9.889207, -12.524437, -13.126629, 8.809374, -11.63147, -10.721937, 9.889207, -12.524437, -13.203676, 8.809374, -12.204792, -10.721937, 9.889207, -12.524437, -8.53195, 12.792675, -12.622772, -8.688553, 12.791416, -12.651451, -13.203676, 8.809374, -12.204792, -10.721937, 9.889207, -12.524437, -11.058203, 10.304527, -12.650129, -10.721937, 9.889207, -12.524437, -8.688553, 12.791416, -12.651451, -11.058203, 10.304527, -12.650129, -11.058203, 10.304527, -12.650129, -8.688553, 12.791416, -12.651451, -8.764704, 12.790787, -12.511417, -13.203676, 8.809374, -12.204792, -11.058203, 10.304527, -12.650129, -13.409447, 9.328525, -12.357271, -11.058203, 10.304527, -12.650129, -8.764704, 12.790787, -12.511417, -11.192169, 10.561271, -12.184172, -13.409447, 9.328525, -12.357271, -11.058203, 10.304527, -12.650129, -11.192169, 10.561271, -12.184172, -13.409447, 9.328525, -12.357271, -11.192169, 10.561271, -12.184172, -13.45945, 9.649454, -11.878155, 1.2086613, -17.361609, 9.414333, 2.631641, -16.33421, 10.284525, 2.5310369, -17.7495, 10.064947, 2.5310369, -17.7495, 10.064947, 2.631641, -16.33421, 10.284525, 4.3560047, -16.298113, 9.437187, 2.5310369, -17.7495, 10.064947, 4.3560047, -16.298113, 9.437187, 4.0482125, -17.380833, 9.267164, 1.2086613, -17.361609, 9.414333, 0.99485135, -16.35621, 9.910944, 2.631641, -16.33421, 10.284525, 0.67701983, -17.65146, 7.644686, 0.99485135, -16.35621, 9.910944, 1.2086613, -17.361609, 9.414333, 2.631641, -16.33421, 10.284525, 3.8578982, -15.259179, 8.952537, 4.3560047, -16.298113, 9.437187, 0.67701983, -17.65146, 7.644686, 0.57171667, -16.255608, 7.7713485, 0.99485135, -16.35621, 9.910944, 1.4848416, -17.258871, 5.9989257, 0.57171667, -16.255608, 7.7713485, 0.67701983, -17.65146, 7.644686, 0.99485135, -16.35621, 9.910944, 2.820033, -14.904823, 9.283184, 2.631641, -16.33421, 10.284525, 2.631641, -16.33421, 10.284525, 2.820033, -14.904823, 9.283184, 3.8578982, -15.259179, 8.952537, 0.57171667, -16.255608, 7.7713485, 1.7232151, -15.285238, 9.163143, 0.99485135, -16.35621, 9.910944, 0.99485135, -16.35621, 9.910944, 1.7232151, -15.285238, 9.163143, 2.820033, -14.904823, 9.283184, 1.4848416, -17.258871, 5.9989257, 1.2661188, -16.180422, 5.6674232, 0.57171667, -16.255608, 7.7713485, 1.2661188, -16.180422, 5.6674232, 1.4848416, -17.258871, 5.9989257, 2.9689097, -17.679869, 5.4027777, 0.57171667, -16.255608, 7.7713485, 1.5839503, -14.885172, 7.933895, 1.7232151, -15.285238, 9.163143, 1.2661188, -16.180422, 5.6674232, 1.5839503, -14.885172, 7.933895, 0.57171667, -16.255608, 7.7713485, 1.7232151, -15.285238, 9.163143, 2.8828304, -14.428289, 7.988148, 2.820033, -14.904823, 9.283184, 1.7232151, -15.285238, 9.163143, 1.5839503, -14.885172, 7.933895, 2.8828304, -14.428289, 7.988148, 1.2661188, -16.180422, 5.6674232, 1.8436836, -15.206849, 6.6362967, 1.5839503, -14.885172, 7.933895, 1.2661188, -16.180422, 5.6674232, 2.9689097, -17.679869, 5.4027777, 2.9821527, -16.278463, 5.4012823, 1.8436836, -15.206849, 6.6362967, 1.2661188, -16.180422, 5.6674232, 2.9821527, -16.278463, 5.4012823, 2.9821527, -16.278463, 5.4012823, 2.9689097, -17.679869, 5.4027777, 4.3171306, -17.397068, 6.368234, 1.5839503, -14.885172, 7.933895, 1.8436836, -15.206849, 6.6362967, 2.9076076, -14.890726, 6.620277, 1.8436836, -15.206849, 6.6362967, 2.9821527, -16.278463, 5.4012823, 2.9076076, -14.890726, 6.620277, 1.5839503, -14.885172, 7.933895, 2.9076076, -14.890726, 6.620277, 2.8828304, -14.428289, 7.988148, 2.9821527, -16.278463, 5.4012823, 4.3171306, -17.397068, 6.368234, 4.6103983, -16.390173, 6.355845, 2.9076076, -14.890726, 6.620277, 2.9821527, -16.278463, 5.4012823, 4.6103983, -16.390173, 6.355845, 4.8227134, -17.777908, 7.823253, 4.6103983, -16.390173, 6.355845, 4.3171306, -17.397068, 6.368234, 2.8828304, -14.428289, 7.988148, 2.9076076, -14.890726, 6.620277, 3.9711041, -15.30019, 6.942167, 2.9076076, -14.890726, 6.620277, 4.6103983, -16.390173, 6.355845, 3.9711041, -15.30019, 6.942167, 4.8227134, -17.777908, 7.823253, 5.042077, -16.356638, 7.9142447, 4.6103983, -16.390173, 6.355845, 5.042077, -16.356638, 7.9142447, 3.9711041, -15.30019, 6.942167, 4.6103983, -16.390173, 6.355845, 4.0482125, -17.380833, 9.267164, 5.042077, -16.356638, 7.9142447, 4.8227134, -17.777908, 7.823253, 4.0482125, -17.380833, 9.267164, 4.3560047, -16.298113, 9.437187, 5.042077, -16.356638, 7.9142447, 5.042077, -16.356638, 7.9142447, 4.1434765, -14.910377, 7.969779, 3.9711041, -15.30019, 6.942167, 2.8828304, -14.428289, 7.988148, 3.9711041, -15.30019, 6.942167, 4.1434765, -14.910377, 7.969779, 4.3560047, -16.298113, 9.437187, 4.1434765, -14.910377, 7.969779, 5.042077, -16.356638, 7.9142447, 2.820033, -14.904823, 9.283184, 2.8828304, -14.428289, 7.988148, 4.1434765, -14.910377, 7.969779, 4.3560047, -16.298113, 9.437187, 3.8578982, -15.259179, 8.952537, 4.1434765, -14.910377, 7.969779, 2.820033, -14.904823, 9.283184, 4.1434765, -14.910377, 7.969779, 3.8578982, -15.259179, 8.952537, 1.4848416, -17.258871, 5.9989257, 2.7309632, -18.18417, 7.6976585, 2.9689097, -17.679869, 5.4027777, 1.4848416, -17.258871, 5.9989257, 0.67701983, -17.65146, 7.644686, 2.7309632, -18.18417, 7.6976585, 2.9689097, -17.679869, 5.4027777, 2.7309632, -18.18417, 7.6976585, 4.8227134, -17.777908, 7.823253, 2.9689097, -17.679869, 5.4027777, 4.8227134, -17.777908, 7.823253, 4.3171306, -17.397068, 6.368234, 2.7309632, -18.18417, 7.6976585, 4.0482125, -17.380833, 9.267164, 4.8227134, -17.777908, 7.823253, 2.7309632, -18.18417, 7.6976585, 2.5310369, -17.7495, 10.064947, 4.0482125, -17.380833, 9.267164, 0.67701983, -17.65146, 7.644686, 2.5310369, -17.7495, 10.064947, 2.7309632, -18.18417, 7.6976585, 0.67701983, -17.65146, 7.644686, 1.2086613, -17.361609, 9.414333, 2.5310369, -17.7495, 10.064947, -1.1748617, -18.139956, 8.584938, -0.18462849, -17.407104, 9.311379, -0.3294468, -18.408445, 8.982868, -0.3294468, -18.408445, 8.982868, -0.18462849, -17.407104, 9.311379, 1.0884066, -17.401766, 8.917934, -0.3294468, -18.408445, 8.982868, 1.0884066, -17.401766, 8.917934, 0.70905924, -18.139956, 8.584938, -1.1748617, -18.139956, 8.584938, -1.3850405, -17.401766, 8.917934, -0.18462849, -17.407104, 9.311379, -1.3850405, -18.381105, 7.5489955, -1.3850405, -17.401766, 8.917934, -1.1748617, -18.139956, 8.584938, -0.18462849, -17.407104, 9.311379, 0.7793324, -16.655033, 8.638763, 1.0884066, -17.401766, 8.917934, -1.3850405, -18.381105, 7.5489955, -1.4563816, -17.385319, 7.7371736, -1.3850405, -17.401766, 8.917934, -0.79465985, -18.10578, 6.6108794, -1.4563816, -17.385319, 7.7371736, -1.3850405, -18.381105, 7.5489955, -1.3850405, -17.401766, 8.917934, 0.03259921, -16.395086, 8.853001, -0.18462849, -17.407104, 9.311379, -0.18462849, -17.407104, 9.311379, 0.03259921, -16.395086, 8.853001, 0.7793324, -16.655033, 8.638763, -1.4563816, -17.385319, 7.7371736, -0.79465985, -16.655033, 8.638763, -1.3850405, -17.401766, 8.917934, -1.3850405, -17.401766, 8.917934, -0.79465985, -16.655033, 8.638763, 0.03259921, -16.395086, 8.853001, -0.79465985, -18.10578, 6.6108794, -0.8192234, -17.368872, 6.556626, -1.4563816, -17.385319, 7.7371736, -0.8192234, -17.368872, 6.556626, -0.79465985, -18.10578, 6.6108794, 0.03259921, -18.353552, 6.115336, -1.4563816, -17.385319, 7.7371736, -0.8192234, -16.389532, 7.9253516, -0.79465985, -16.655033, 8.638763, -0.8192234, -17.368872, 6.556626, -0.8192234, -16.389532, 7.9253516, -1.4563816, -17.385319, 7.7371736, -0.79465985, -16.655033, 8.638763, 0.10500836, -16.057604, 7.9881487, 0.03259921, -16.395086, 8.853001, -0.79465985, -16.655033, 8.638763, -0.8192234, -16.389532, 7.9253516, 0.10500836, -16.057604, 7.9881487, -0.8192234, -17.368872, 6.556626, -0.54325676, -16.640295, 7.1145396, -0.8192234, -16.389532, 7.9253516, -0.8192234, -17.368872, 6.556626, 0.03259921, -18.353552, 6.115336, 0.10500836, -17.36332, 6.1631813, -0.54325676, -16.640295, 7.1145396, -0.8192234, -17.368872, 6.556626, 0.10500836, -17.36332, 6.1631813, 0.10500836, -17.36332, 6.1631813, 0.03259921, -18.353552, 6.115336, 0.7793324, -18.10578, 6.6108794, -0.8192234, -16.389532, 7.9253516, -0.54325676, -16.640295, 7.1145396, 0.10500836, -16.38398, 6.9979157, -0.54325676, -16.640295, 7.1145396, 0.10500836, -17.36332, 6.1631813, 0.10500836, -16.38398, 6.9979157, -0.8192234, -16.389532, 7.9253516, 0.10500836, -16.38398, 6.9979157, 0.10500836, -16.057604, 7.9881487, 0.10500836, -17.36332, 6.1631813, 0.7793324, -18.10578, 6.6108794, 0.9570446, -17.368872, 6.556626, 0.10500836, -16.38398, 6.9979157, 0.10500836, -17.36332, 6.1631813, 0.9570446, -17.368872, 6.556626, 1.0884066, -18.381105, 7.5489955, 0.9570446, -17.368872, 6.556626, 0.7793324, -18.10578, 6.6108794, 0.10500836, -16.057604, 7.9881487, 0.10500836, -16.38398, 6.9979157, 0.7210206, -16.640295, 7.1145396, 0.10500836, -16.38398, 6.9979157, 0.9570446, -17.368872, 6.556626, 0.7210206, -16.640295, 7.1145396, 1.0884066, -18.381105, 7.5489955, 1.3767617, -17.385319, 7.7371736, 0.9570446, -17.368872, 6.556626, 1.3767617, -17.385319, 7.7371736, 0.7210206, -16.640295, 7.1145396, 0.9570446, -17.368872, 6.556626, 0.70905924, -18.139956, 8.584938, 1.3767617, -17.385319, 7.7371736, 1.0884066, -18.381105, 7.5489955, 0.70905924, -18.139956, 8.584938, 1.0884066, -17.401766, 8.917934, 1.3767617, -17.385319, 7.7371736, 1.3767617, -17.385319, 7.7371736, 0.9570446, -16.389532, 7.9253516, 0.7210206, -16.640295, 7.1145396, 0.10500836, -16.057604, 7.9881487, 0.7210206, -16.640295, 7.1145396, 0.9570446, -16.389532, 7.9253516, 1.0884066, -17.401766, 8.917934, 0.9570446, -16.389532, 7.9253516, 1.3767617, -17.385319, 7.7371736, 0.03259921, -16.395086, 8.853001, 0.10500836, -16.057604, 7.9881487, 0.9570446, -16.389532, 7.9253516, 1.0884066, -17.401766, 8.917934, 0.7793324, -16.655033, 8.638763, 0.9570446, -16.389532, 7.9253516, 0.03259921, -16.395086, 8.853001, 0.9570446, -16.389532, 7.9253516, 0.7793324, -16.655033, 8.638763, -0.79465985, -18.10578, 6.6108794, -0.18462849, -18.712822, 7.4864116, 0.03259921, -18.353552, 6.115336, -0.79465985, -18.10578, 6.6108794, -1.3850405, -18.381105, 7.5489955, -0.18462849, -18.712822, 7.4864116, 0.03259921, -18.353552, 6.115336, -0.18462849, -18.712822, 7.4864116, 1.0884066, -18.381105, 7.5489955, 0.03259921, -18.353552, 6.115336, 1.0884066, -18.381105, 7.5489955, 0.7793324, -18.10578, 6.6108794, -0.18462849, -18.712822, 7.4864116, 0.70905924, -18.139956, 8.584938, 1.0884066, -18.381105, 7.5489955, -0.18462849, -18.712822, 7.4864116, -0.3294468, -18.408445, 8.982868, 0.70905924, -18.139956, 8.584938, -1.3850405, -18.381105, 7.5489955, -0.3294468, -18.408445, 8.982868, -0.18462849, -18.712822, 7.4864116, -1.3850405, -18.381105, 7.5489955, -1.1748617, -18.139956, 8.584938, -0.3294468, -18.408445, 8.982868, 2.6649618, -19.729753, 7.98815, 1.5668626, -19.439903, 7.791855, 2.6147668, -19.871582, 7.781175, 2.6649618, -19.729753, 7.98815, 1.6531556, -19.32627, 7.98815, 1.5668626, -19.439903, 7.791855, 1.6531556, -19.32627, 7.98815, 0.52835655, -18.340096, 7.9311194, 1.5668626, -19.439903, 7.791855, 1.6531556, -19.32627, 7.98815, 0.5697943, -18.34031, 7.9881496, 0.52835655, -18.340096, 7.9311194, 1.5668626, -19.439903, 7.791855, 0.52835655, -18.340096, 7.9311194, 0.46128738, -18.339455, 7.952906, 2.6147668, -19.871582, 7.781175, 1.5668626, -19.439903, 7.791855, 1.4271705, -19.62381, 7.8668275, 1.5668626, -19.439903, 7.791855, 0.46128738, -18.339455, 7.952906, 1.4271705, -19.62381, 7.8668275, 2.6147668, -19.871582, 7.781175, 1.4271705, -19.62381, 7.8668275, 2.5338137, -20.101198, 7.860206, 1.4271705, -19.62381, 7.8668275, 0.46128738, -18.339455, 7.952906, 0.46128738, -18.339455, 8.023394, 2.5338137, -20.101198, 7.860206, 1.4271705, -19.62381, 7.8668275, 1.4271705, -19.62381, 8.109472, 1.4271705, -19.62381, 7.8668275, 0.46128738, -18.339455, 8.023394, 1.4271705, -19.62381, 8.109472, 2.5338137, -20.101198, 7.860206, 1.4271705, -19.62381, 8.109472, 2.5338137, -20.101198, 8.116095, 1.4271705, -19.62381, 8.109472, 0.46128738, -18.339455, 8.023394, 0.52835655, -18.340096, 8.045179, 2.5338137, -20.101198, 8.116095, 1.4271705, -19.62381, 8.109472, 1.5668626, -19.439903, 8.184445, 1.4271705, -19.62381, 8.109472, 0.52835655, -18.340096, 8.045179, 1.5668626, -19.439903, 8.184445, 1.5668626, -19.439903, 8.184445, 0.52835655, -18.340096, 8.045179, 0.5697943, -18.34031, 7.9881496, 2.5338137, -20.101198, 8.116095, 1.5668626, -19.439903, 8.184445, 2.6147668, -19.871582, 8.195126, 1.5668626, -19.439903, 8.184445, 0.5697943, -18.34031, 7.9881496, 1.6531556, -19.32627, 7.98815, 2.6147668, -19.871582, 8.195126, 1.5668626, -19.439903, 8.184445, 1.6531556, -19.32627, 7.98815, 2.6147668, -19.871582, 8.195126, 1.6531556, -19.32627, 7.98815, 2.6649618, -19.729753, 7.98815, 1.5335416, -22.031042, 7.988151, 2.5325322, -19.973894, 8.213709, 1.9611616, -22.031042, 8.6124935, 1.5335416, -22.031042, 7.988151, 2.3712668, -19.944632, 7.98815, 2.5325322, -19.973894, 8.213709, 2.3712668, -19.944632, 7.98815, 2.613699, -17.907776, 8.087899, 2.5325322, -19.973894, 8.213709, 2.3712668, -19.944632, 7.98815, 2.5417168, -17.900085, 7.9881496, 2.613699, -17.907776, 8.087899, 2.5325322, -19.973894, 8.213709, 2.613699, -17.907776, 8.087899, 2.730536, -17.920164, 8.049879, 1.9611616, -22.031042, 8.6124935, 2.5325322, -19.973894, 8.213709, 2.7935472, -20.021313, 8.127628, 2.5325322, -19.973894, 8.213709, 2.730536, -17.920164, 8.049879, 2.7935472, -20.021313, 8.127628, 1.9611616, -22.031042, 8.6124935, 2.7935472, -20.021313, 8.127628, 2.652787, -22.031042, 8.373906, 2.7935472, -20.021313, 8.127628, 2.730536, -17.920164, 8.049879, 2.730536, -17.920164, 7.92642, 2.652787, -22.031042, 8.373906, 2.7935472, -20.021313, 8.127628, 2.7935472, -20.021313, 7.848672, 2.7935472, -20.021313, 8.127628, 2.730536, -17.920164, 7.92642, 2.7935472, -20.021313, 7.848672, 2.652787, -22.031042, 8.373906, 2.7935472, -20.021313, 7.848672, 2.652787, -22.031042, 7.6021824, 2.7935472, -20.021313, 7.848672, 2.730536, -17.920164, 7.92642, 2.613699, -17.907776, 7.8884, 2.652787, -22.031042, 7.6021824, 2.7935472, -20.021313, 7.848672, 2.5325322, -19.973894, 7.7623787, 2.7935472, -20.021313, 7.848672, 2.613699, -17.907776, 7.8884, 2.5325322, -19.973894, 7.7623787, 2.5325322, -19.973894, 7.7623787, 2.613699, -17.907776, 7.8884, 2.5417168, -17.900085, 7.9881496, 2.652787, -22.031042, 7.6021824, 2.5325322, -19.973894, 7.7623787, 1.9611616, -22.031042, 7.3638086, 2.5325322, -19.973894, 7.7623787, 2.5417168, -17.900085, 7.9881496, 2.3712668, -19.944632, 7.98815, 1.9611616, -22.031042, 7.3638086, 2.5325322, -19.973894, 7.7623787, 2.3712668, -19.944632, 7.98815, 1.9611616, -22.031042, 7.3638086, 2.3712668, -19.944632, 7.98815, 1.5335416, -22.031042, 7.988151, 7.7761884, 7.8527765, 15.352116, 9.199167, 8.88039, 16.222307, 9.098564, 7.4648857, 16.002731, 9.098564, 7.4648857, 16.002731, 9.199167, 8.88039, 16.222307, 10.9235325, 8.916273, 15.3749695, 9.098564, 7.4648857, 16.002731, 10.9235325, 8.916273, 15.3749695, 10.61574, 7.8335533, 15.204948, 7.7761884, 7.8527765, 15.352116, 7.562378, 8.858175, 15.848726, 9.199167, 8.88039, 16.222307, 7.2445464, 7.562926, 13.58247, 7.562378, 8.858175, 15.848726, 7.7761884, 7.8527765, 15.352116, 9.199167, 8.88039, 16.222307, 10.425425, 9.955207, 14.89032, 10.9235325, 8.916273, 15.3749695, 7.2445464, 7.562926, 13.58247, 7.1392436, 8.958778, 13.709131, 7.562378, 8.858175, 15.848726, 8.052368, 7.9555154, 11.936708, 7.1392436, 8.958778, 13.709131, 7.2445464, 7.562926, 13.58247, 7.562378, 8.858175, 15.848726, 9.38756, 10.309776, 15.220966, 9.199167, 8.88039, 16.222307, 9.199167, 8.88039, 16.222307, 9.38756, 10.309776, 15.220966, 10.425425, 9.955207, 14.89032, 7.1392436, 8.958778, 13.709131, 8.290742, 9.929361, 15.100925, 7.562378, 8.858175, 15.848726, 7.562378, 8.858175, 15.848726, 8.290742, 9.929361, 15.100925, 9.38756, 10.309776, 15.220966, 8.052368, 7.9555154, 11.936708, 7.8336453, 9.033964, 11.605206, 7.1392436, 8.958778, 13.709131, 7.8336453, 9.033964, 11.605206, 8.052368, 7.9555154, 11.936708, 9.536436, 7.534303, 11.340561, 7.1392436, 8.958778, 13.709131, 8.151478, 10.329214, 13.871677, 8.290742, 9.929361, 15.100925, 7.8336453, 9.033964, 11.605206, 8.151478, 10.329214, 13.871677, 7.1392436, 8.958778, 13.709131, 8.290742, 9.929361, 15.100925, 9.450357, 10.786097, 13.925932, 9.38756, 10.309776, 15.220966, 8.290742, 9.929361, 15.100925, 8.151478, 10.329214, 13.871677, 9.450357, 10.786097, 13.925932, 7.8336453, 9.033964, 11.605206, 8.411211, 10.007323, 12.5740795, 8.151478, 10.329214, 13.871677, 7.8336453, 9.033964, 11.605206, 9.536436, 7.534303, 11.340561, 9.549679, 8.935923, 11.339066, 8.411211, 10.007323, 12.5740795, 7.8336453, 9.033964, 11.605206, 9.549679, 8.935923, 11.339066, 9.549679, 8.935923, 11.339066, 9.536436, 7.534303, 11.340561, 10.884657, 7.817319, 12.306017, 8.151478, 10.329214, 13.871677, 8.411211, 10.007323, 12.5740795, 9.475134, 10.32366, 12.55806, 8.411211, 10.007323, 12.5740795, 9.549679, 8.935923, 11.339066, 9.475134, 10.32366, 12.55806, 8.151478, 10.329214, 13.871677, 9.475134, 10.32366, 12.55806, 9.450357, 10.786097, 13.925932, 9.549679, 8.935923, 11.339066, 10.884657, 7.817319, 12.306017, 11.177925, 8.823998, 12.293627, 9.475134, 10.32366, 12.55806, 9.549679, 8.935923, 11.339066, 11.177925, 8.823998, 12.293627, 11.39024, 7.4364767, 13.761036, 11.177925, 8.823998, 12.293627, 10.884657, 7.817319, 12.306017, 9.450357, 10.786097, 13.925932, 9.475134, 10.32366, 12.55806, 10.538631, 9.914196, 12.87995, 9.475134, 10.32366, 12.55806, 11.177925, 8.823998, 12.293627, 10.538631, 9.914196, 12.87995, 11.39024, 7.4364767, 13.761036, 11.609604, 8.857534, 13.852028, 11.177925, 8.823998, 12.293627, 11.609604, 8.857534, 13.852028, 10.538631, 9.914196, 12.87995, 11.177925, 8.823998, 12.293627, 10.61574, 7.8335533, 15.204948, 11.609604, 8.857534, 13.852028, 11.39024, 7.4364767, 13.761036, 10.61574, 7.8335533, 15.204948, 10.9235325, 8.916273, 15.3749695, 11.609604, 8.857534, 13.852028, 11.609604, 8.857534, 13.852028, 10.711004, 10.304009, 13.907562, 10.538631, 9.914196, 12.87995, 9.450357, 10.786097, 13.925932, 10.538631, 9.914196, 12.87995, 10.711004, 10.304009, 13.907562, 10.9235325, 8.916273, 15.3749695, 10.711004, 10.304009, 13.907562, 11.609604, 8.857534, 13.852028, 9.38756, 10.309776, 15.220966, 9.450357, 10.786097, 13.925932, 10.711004, 10.304009, 13.907562, 10.9235325, 8.916273, 15.3749695, 10.425425, 9.955207, 14.89032, 10.711004, 10.304009, 13.907562, 9.38756, 10.309776, 15.220966, 10.711004, 10.304009, 13.907562, 10.425425, 9.955207, 14.89032, 8.052368, 7.9555154, 11.936708, 9.298491, 7.030216, 13.635441, 9.536436, 7.534303, 11.340561, 8.052368, 7.9555154, 11.936708, 7.2445464, 7.562926, 13.58247, 9.298491, 7.030216, 13.635441, 9.536436, 7.534303, 11.340561, 9.298491, 7.030216, 13.635441, 11.39024, 7.4364767, 13.761036, 9.536436, 7.534303, 11.340561, 11.39024, 7.4364767, 13.761036, 10.884657, 7.817319, 12.306017, 9.298491, 7.030216, 13.635441, 10.61574, 7.8335533, 15.204948, 11.39024, 7.4364767, 13.761036, 9.298491, 7.030216, 13.635441, 9.098564, 7.4648857, 16.002731, 10.61574, 7.8335533, 15.204948, 7.2445464, 7.562926, 13.58247, 9.098564, 7.4648857, 16.002731, 9.298491, 7.030216, 13.635441, 7.2445464, 7.562926, 13.58247, 7.7761884, 7.8527765, 15.352116, 9.098564, 7.4648857, 16.002731, 5.392665, 7.074431, 14.52272, 6.3828983, 7.807067, 15.249163, 6.23808, 6.805727, 14.9206505, 6.23808, 6.805727, 14.9206505, 6.3828983, 7.807067, 15.249163, 7.6559334, 7.8126206, 14.855719, 6.23808, 6.805727, 14.9206505, 7.6559334, 7.8126206, 14.855719, 7.276586, 7.074431, 14.52272, 5.392665, 7.074431, 14.52272, 5.1824865, 7.8126206, 14.855719, 6.3828983, 7.807067, 15.249163, 5.1824865, 6.83328, 13.486778, 5.1824865, 7.8126206, 14.855719, 5.392665, 7.074431, 14.52272, 6.3828983, 7.807067, 15.249163, 7.3468595, 8.559354, 14.576548, 7.6559334, 7.8126206, 14.855719, 5.1824865, 6.83328, 13.486778, 5.111145, 7.829067, 13.674955, 5.1824865, 7.8126206, 14.855719, 5.772867, 7.108606, 12.548662, 5.111145, 7.829067, 13.674955, 5.1824865, 6.83328, 13.486778, 5.1824865, 7.8126206, 14.855719, 6.600126, 8.819301, 14.790783, 6.3828983, 7.807067, 15.249163, 6.3828983, 7.807067, 15.249163, 6.600126, 8.819301, 14.790783, 7.3468595, 8.559354, 14.576548, 5.111145, 7.829067, 13.674955, 5.772867, 8.559354, 14.576548, 5.1824865, 7.8126206, 14.855719, 5.1824865, 7.8126206, 14.855719, 5.772867, 8.559354, 14.576548, 6.600126, 8.819301, 14.790783, 5.772867, 7.108606, 12.548662, 5.7483034, 7.8455133, 12.49441, 5.111145, 7.829067, 13.674955, 5.7483034, 7.8455133, 12.49441, 5.772867, 7.108606, 12.548662, 6.600126, 6.8608336, 12.05312, 5.111145, 7.829067, 13.674955, 5.7483034, 8.824854, 13.863134, 5.772867, 8.559354, 14.576548, 5.7483034, 7.8455133, 12.49441, 5.7483034, 8.824854, 13.863134, 5.111145, 7.829067, 13.674955, 5.772867, 8.559354, 14.576548, 6.6725354, 9.156782, 13.925932, 6.600126, 8.819301, 14.790783, 5.772867, 8.559354, 14.576548, 5.7483034, 8.824854, 13.863134, 6.6725354, 9.156782, 13.925932, 5.7483034, 7.8455133, 12.49441, 6.02427, 8.574091, 13.052322, 5.7483034, 8.824854, 13.863134, 5.7483034, 7.8455133, 12.49441, 6.600126, 6.8608336, 12.05312, 6.6725354, 7.8510666, 12.100964, 6.02427, 8.574091, 13.052322, 5.7483034, 7.8455133, 12.49441, 6.6725354, 7.8510666, 12.100964, 6.6725354, 7.8510666, 12.100964, 6.600126, 6.8608336, 12.05312, 7.3468595, 7.108606, 12.548662, 5.7483034, 8.824854, 13.863134, 6.02427, 8.574091, 13.052322, 6.6725354, 8.830407, 12.935699, 6.02427, 8.574091, 13.052322, 6.6725354, 7.8510666, 12.100964, 6.6725354, 8.830407, 12.935699, 5.7483034, 8.824854, 13.863134, 6.6725354, 8.830407, 12.935699, 6.6725354, 9.156782, 13.925932, 6.6725354, 7.8510666, 12.100964, 7.3468595, 7.108606, 12.548662, 7.5245714, 7.8455133, 12.49441, 6.6725354, 8.830407, 12.935699, 6.6725354, 7.8510666, 12.100964, 7.5245714, 7.8455133, 12.49441, 7.6559334, 6.83328, 13.486778, 7.5245714, 7.8455133, 12.49441, 7.3468595, 7.108606, 12.548662, 6.6725354, 9.156782, 13.925932, 6.6725354, 8.830407, 12.935699, 7.2885475, 8.574091, 13.052322, 6.6725354, 8.830407, 12.935699, 7.5245714, 7.8455133, 12.49441, 7.2885475, 8.574091, 13.052322, 7.6559334, 6.83328, 13.486778, 7.9442887, 7.829067, 13.674955, 7.5245714, 7.8455133, 12.49441, 7.9442887, 7.829067, 13.674955, 7.2885475, 8.574091, 13.052322, 7.5245714, 7.8455133, 12.49441, 7.276586, 7.074431, 14.52272, 7.9442887, 7.829067, 13.674955, 7.6559334, 6.83328, 13.486778, 7.276586, 7.074431, 14.52272, 7.6559334, 7.8126206, 14.855719, 7.9442887, 7.829067, 13.674955, 7.9442887, 7.829067, 13.674955, 7.5245714, 8.824854, 13.863134, 7.2885475, 8.574091, 13.052322, 6.6725354, 9.156782, 13.925932, 7.2885475, 8.574091, 13.052322, 7.5245714, 8.824854, 13.863134, 7.6559334, 7.8126206, 14.855719, 7.5245714, 8.824854, 13.863134, 7.9442887, 7.829067, 13.674955, 6.600126, 8.819301, 14.790783, 6.6725354, 9.156782, 13.925932, 7.5245714, 8.824854, 13.863134, 7.6559334, 7.8126206, 14.855719, 7.3468595, 8.559354, 14.576548, 7.5245714, 8.824854, 13.863134, 6.600126, 8.819301, 14.790783, 7.5245714, 8.824854, 13.863134, 7.3468595, 8.559354, 14.576548, 5.772867, 7.108606, 12.548662, 6.3828983, 6.5013514, 13.424194, 6.600126, 6.8608336, 12.05312, 5.772867, 7.108606, 12.548662, 5.1824865, 6.83328, 13.486778, 6.3828983, 6.5013514, 13.424194, 6.600126, 6.8608336, 12.05312, 6.3828983, 6.5013514, 13.424194, 7.6559334, 6.83328, 13.486778, 6.600126, 6.8608336, 12.05312, 7.6559334, 6.83328, 13.486778, 7.3468595, 7.108606, 12.548662, 6.3828983, 6.5013514, 13.424194, 7.276586, 7.074431, 14.52272, 7.6559334, 6.83328, 13.486778, 6.3828983, 6.5013514, 13.424194, 6.23808, 6.805727, 14.9206505, 7.276586, 7.074431, 14.52272, 5.1824865, 6.83328, 13.486778, 6.23808, 6.805727, 14.9206505, 6.3828983, 6.5013514, 13.424194, 5.1824865, 6.83328, 13.486778, 5.392665, 7.074431, 14.52272, 6.23808, 6.805727, 14.9206505, 9.232489, 5.4846325, 13.925934, 8.13439, 5.7744827, 13.729637, 9.182293, 5.342804, 13.718958, 9.232489, 5.4846325, 13.925934, 8.220682, 5.8879023, 13.925932, 8.13439, 5.7744827, 13.729637, 8.220682, 5.8879023, 13.925932, 7.0958834, 6.874291, 13.868902, 8.13439, 5.7744827, 13.729637, 8.220682, 5.8879023, 13.925932, 7.137321, 6.8738637, 13.925932, 7.0958834, 6.874291, 13.868902, 8.13439, 5.7744827, 13.729637, 7.0958834, 6.874291, 13.868902, 7.0288143, 6.874932, 13.89069, 9.182293, 5.342804, 13.718958, 8.13439, 5.7744827, 13.729637, 7.994697, 5.590576, 13.804609, 8.13439, 5.7744827, 13.729637, 7.0288143, 6.874932, 13.89069, 7.994697, 5.590576, 13.804609, 9.182293, 5.342804, 13.718958, 7.994697, 5.590576, 13.804609, 9.101341, 5.1134014, 13.797989, 7.994697, 5.590576, 13.804609, 7.0288143, 6.874932, 13.89069, 7.0288143, 6.874932, 13.961176, 9.101341, 5.1134014, 13.797989, 7.994697, 5.590576, 13.804609, 7.994697, 5.590576, 14.047256, 7.994697, 5.590576, 13.804609, 7.0288143, 6.874932, 13.961176, 7.994697, 5.590576, 14.047256, 9.101341, 5.1134014, 13.797989, 7.994697, 5.590576, 14.047256, 9.101341, 5.113402, 14.053877, 7.994697, 5.590576, 14.047256, 7.0288143, 6.874932, 13.961176, 7.0958834, 6.874291, 13.982964, 9.101341, 5.113402, 14.053877, 7.994697, 5.590576, 14.047256, 8.13439, 5.7744827, 14.122229, 7.994697, 5.590576, 14.047256, 7.0958834, 6.874291, 13.982964, 8.13439, 5.7744827, 14.122229, 8.13439, 5.7744827, 14.122229, 7.0958834, 6.874291, 13.982964, 7.137321, 6.8738637, 13.925932, 9.101341, 5.113402, 14.053877, 8.13439, 5.7744827, 14.122229, 9.182293, 5.3428044, 14.132908, 8.13439, 5.7744827, 14.122229, 7.137321, 6.8738637, 13.925932, 8.220682, 5.8879023, 13.925932, 9.182293, 5.3428044, 14.132908, 8.13439, 5.7744827, 14.122229, 8.220682, 5.8879023, 13.925932, 9.182293, 5.3428044, 14.132908, 8.220682, 5.8879023, 13.925932, 9.232489, 5.4846325, 13.925934, 8.1010685, 3.1833441, 13.925934, 9.1000595, 5.240492, 14.151705, 8.528688, 3.1833444, 14.550276, 8.1010685, 3.1833441, 13.925934, 8.938793, 5.2697544, 13.925934, 9.1000595, 5.240492, 14.151705, 8.938793, 5.2697544, 13.925934, 9.181226, 7.30661, 14.025682, 9.1000595, 5.240492, 14.151705, 8.938793, 5.2697544, 13.925934, 9.109243, 7.314086, 13.925932, 9.181226, 7.30661, 14.025682, 9.1000595, 5.240492, 14.151705, 9.181226, 7.30661, 14.025682, 9.298063, 7.2942214, 13.987661, 8.528688, 3.1833444, 14.550276, 9.1000595, 5.240492, 14.151705, 9.361288, 5.1928596, 14.0654125, 9.1000595, 5.240492, 14.151705, 9.298063, 7.2942214, 13.987661, 9.361288, 5.1928596, 14.0654125, 8.528688, 3.1833444, 14.550276, 9.361288, 5.1928596, 14.0654125, 9.220314, 3.1833444, 14.311903, 9.361288, 5.1928596, 14.0654125, 9.298063, 7.2942214, 13.987661, 9.298063, 7.2942214, 13.8642025, 9.220314, 3.1833444, 14.311903, 9.361288, 5.1928596, 14.0654125, 9.361074, 5.1928596, 13.786455, 9.361288, 5.1928596, 14.0654125, 9.298063, 7.2942214, 13.8642025, 9.361074, 5.1928596, 13.786455, 9.220314, 3.1833444, 14.311903, 9.361074, 5.1928596, 13.786455, 9.220314, 3.1833441, 13.539965, 9.361074, 5.1928596, 13.786455, 9.298063, 7.2942214, 13.8642025, 9.181226, 7.30661, 13.826183, 9.220314, 3.1833441, 13.539965, 9.361074, 5.1928596, 13.786455, 9.1000595, 5.240492, 13.700161, 9.361074, 5.1928596, 13.786455, 9.181226, 7.30661, 13.826183, 9.1000595, 5.240492, 13.700161, 9.1000595, 5.240492, 13.700161, 9.181226, 7.30661, 13.826183, 9.109243, 7.314086, 13.925932, 9.220314, 3.1833441, 13.539965, 9.1000595, 5.240492, 13.700161, 8.528688, 3.183344, 13.301592, 9.1000595, 5.240492, 13.700161, 9.109243, 7.314086, 13.925932, 8.938793, 5.2697544, 13.925934, 8.528688, 3.183344, 13.301592, 9.1000595, 5.240492, 13.700161, 8.938793, 5.2697544, 13.925934, 8.528688, 3.183344, 13.301592, 8.938793, 5.2697544, 13.925934, 8.1010685, 3.1833441, 13.925934, -21.010672, 7.8103647, 2.1272998, -19.587692, 8.837978, 2.9974916, -19.688295, 7.422474, 2.7779148, -19.688295, 7.422474, 2.7779148, -19.587692, 8.837978, 2.9974916, -17.863327, 8.873861, 2.1501544, -19.688295, 7.422474, 2.7779148, -17.863327, 8.873861, 2.1501544, -18.17112, 7.7911415, 1.9801317, -21.010672, 7.8103647, 2.1272998, -21.224482, 8.815763, 2.6239111, -19.587692, 8.837978, 2.9974916, -21.542313, 7.520514, 0.35765326, -21.224482, 8.815763, 2.6239111, -21.010672, 7.8103647, 2.1272998, -19.587692, 8.837978, 2.9974916, -18.361435, 9.912795, 1.6655035, -17.863327, 8.873861, 2.1501544, -21.542313, 7.520514, 0.35765326, -21.647615, 8.916367, 0.4843155, -21.224482, 8.815763, 2.6239111, -20.734491, 7.9131036, -1.2881074, -21.647615, 8.916367, 0.4843155, -21.542313, 7.520514, 0.35765326, -21.224482, 8.815763, 2.6239111, -19.3993, 10.2673645, 1.9961507, -19.587692, 8.837978, 2.9974916, -19.587692, 8.837978, 2.9974916, -19.3993, 10.2673645, 1.9961507, -18.361435, 9.912795, 1.6655035, -21.647615, 8.916367, 0.4843155, -20.496117, 9.88695, 1.8761096, -21.224482, 8.815763, 2.6239111, -21.224482, 8.815763, 2.6239111, -20.496117, 9.88695, 1.8761096, -19.3993, 10.2673645, 1.9961507, -20.734491, 7.9131036, -1.2881074, -20.953215, 8.991552, -1.6196096, -21.647615, 8.916367, 0.4843155, -20.953215, 8.991552, -1.6196096, -20.734491, 7.9131036, -1.2881074, -19.250423, 7.4918914, -1.8842552, -21.647615, 8.916367, 0.4843155, -20.635382, 10.286802, 0.6468619, -20.496117, 9.88695, 1.8761096, -20.953215, 8.991552, -1.6196096, -20.635382, 10.286802, 0.6468619, -21.647615, 8.916367, 0.4843155, -20.496117, 9.88695, 1.8761096, -19.336502, 10.743685, 0.70111525, -19.3993, 10.2673645, 1.9961507, -20.496117, 9.88695, 1.8761096, -20.635382, 10.286802, 0.6468619, -19.336502, 10.743685, 0.70111525, -20.953215, 8.991552, -1.6196096, -20.375648, 9.964911, -0.65073633, -20.635382, 10.286802, 0.6468619, -20.953215, 8.991552, -1.6196096, -19.250423, 7.4918914, -1.8842552, -19.23718, 8.893511, -1.885751, -20.375648, 9.964911, -0.65073633, -20.953215, 8.991552, -1.6196096, -19.23718, 8.893511, -1.885751, -19.23718, 8.893511, -1.885751, -19.250423, 7.4918914, -1.8842552, -17.902203, 7.774907, -0.91879916, -20.635382, 10.286802, 0.6468619, -20.375648, 9.964911, -0.65073633, -19.311726, 10.281248, -0.66675615, -20.375648, 9.964911, -0.65073633, -19.23718, 8.893511, -1.885751, -19.311726, 10.281248, -0.66675615, -20.635382, 10.286802, 0.6468619, -19.311726, 10.281248, -0.66675615, -19.336502, 10.743685, 0.70111525, -19.23718, 8.893511, -1.885751, -17.902203, 7.774907, -0.91879916, -17.608934, 8.781587, -0.9311881, -19.311726, 10.281248, -0.66675615, -19.23718, 8.893511, -1.885751, -17.608934, 8.781587, -0.9311881, -17.39662, 7.394065, 0.53621995, -17.608934, 8.781587, -0.9311881, -17.902203, 7.774907, -0.91879916, -19.336502, 10.743685, 0.70111525, -19.311726, 10.281248, -0.66675615, -18.248228, 9.871784, -0.34486628, -19.311726, 10.281248, -0.66675615, -17.608934, 8.781587, -0.9311881, -18.248228, 9.871784, -0.34486628, -17.39662, 7.394065, 0.53621995, -17.177256, 8.815123, 0.6272116, -17.608934, 8.781587, -0.9311881, -17.177256, 8.815123, 0.6272116, -18.248228, 9.871784, -0.34486628, -17.608934, 8.781587, -0.9311881, -18.17112, 7.7911415, 1.9801317, -17.177256, 8.815123, 0.6272116, -17.39662, 7.394065, 0.53621995, -18.17112, 7.7911415, 1.9801317, -17.863327, 8.873861, 2.1501544, -17.177256, 8.815123, 0.6272116, -17.177256, 8.815123, 0.6272116, -18.075855, 10.261598, 0.6827462, -18.248228, 9.871784, -0.34486628, -19.336502, 10.743685, 0.70111525, -18.248228, 9.871784, -0.34486628, -18.075855, 10.261598, 0.6827462, -17.863327, 8.873861, 2.1501544, -18.075855, 10.261598, 0.6827462, -17.177256, 8.815123, 0.6272116, -19.3993, 10.2673645, 1.9961507, -19.336502, 10.743685, 0.70111525, -18.075855, 10.261598, 0.6827462, -17.863327, 8.873861, 2.1501544, -18.361435, 9.912795, 1.6655035, -18.075855, 10.261598, 0.6827462, -19.3993, 10.2673645, 1.9961507, -18.075855, 10.261598, 0.6827462, -18.361435, 9.912795, 1.6655035, -20.734491, 7.9131036, -1.2881074, -19.488369, 6.9878044, 0.41062546, -19.250423, 7.4918914, -1.8842552, -20.734491, 7.9131036, -1.2881074, -21.542313, 7.520514, 0.35765326, -19.488369, 6.9878044, 0.41062546, -19.250423, 7.4918914, -1.8842552, -19.488369, 6.9878044, 0.41062546, -17.39662, 7.394065, 0.53621995, -19.250423, 7.4918914, -1.8842552, -17.39662, 7.394065, 0.53621995, -17.902203, 7.774907, -0.91879916, -19.488369, 6.9878044, 0.41062546, -18.17112, 7.7911415, 1.9801317, -17.39662, 7.394065, 0.53621995, -19.488369, 6.9878044, 0.41062546, -19.688295, 7.422474, 2.7779148, -18.17112, 7.7911415, 1.9801317, -21.542313, 7.520514, 0.35765326, -19.688295, 7.422474, 2.7779148, -19.488369, 6.9878044, 0.41062546, -21.542313, 7.520514, 0.35765326, -21.010672, 7.8103647, 2.1272998, -19.688295, 7.422474, 2.7779148, -23.394194, 7.032019, 1.297905, -22.403961, 7.764655, 2.0243464, -22.54878, 6.763315, 1.6958355, -22.54878, 6.763315, 1.6958355, -22.403961, 7.764655, 2.0243464, -21.130926, 7.770209, 1.6309018, -22.54878, 6.763315, 1.6958355, -21.130926, 7.770209, 1.6309018, -21.510273, 7.032019, 1.297905, -23.394194, 7.032019, 1.297905, -23.604374, 7.770209, 1.6309018, -22.403961, 7.764655, 2.0243464, -23.604374, 6.7908683, 0.2619624, -23.604374, 7.770209, 1.6309018, -23.394194, 7.032019, 1.297905, -22.403961, 7.764655, 2.0243464, -21.44, 8.516942, 1.3517308, -21.130926, 7.770209, 1.6309018, -23.604374, 6.7908683, 0.2619624, -23.675714, 7.7866554, 0.45014048, -23.604374, 7.770209, 1.6309018, -23.013992, 7.066194, -0.6761534, -23.675714, 7.7866554, 0.45014048, -23.604374, 6.7908683, 0.2619624, -23.604374, 7.770209, 1.6309018, -22.186733, 8.776889, 1.5659679, -22.403961, 7.764655, 2.0243464, -22.403961, 7.764655, 2.0243464, -22.186733, 8.776889, 1.5659679, -21.44, 8.516942, 1.3517308, -23.675714, 7.7866554, 0.45014048, -23.013992, 8.516942, 1.3517308, -23.604374, 7.770209, 1.6309018, -23.604374, 7.770209, 1.6309018, -23.013992, 8.516942, 1.3517308, -22.186733, 8.776889, 1.5659679, -23.013992, 7.066194, -0.6761534, -23.038557, 7.8031015, -0.73040724, -23.675714, 7.7866554, 0.45014048, -23.038557, 7.8031015, -0.73040724, -23.013992, 7.066194, -0.6761534, -22.186733, 6.818422, -1.1716971, -23.675714, 7.7866554, 0.45014048, -23.038557, 8.782442, 0.63831854, -23.013992, 8.516942, 1.3517308, -23.038557, 7.8031015, -0.73040724, -23.038557, 8.782442, 0.63831854, -23.675714, 7.7866554, 0.45014048, -23.013992, 8.516942, 1.3517308, -22.114325, 9.11437, 0.70111585, -22.186733, 8.776889, 1.5659679, -23.013992, 8.516942, 1.3517308, -23.038557, 8.782442, 0.63831854, -22.114325, 9.11437, 0.70111585, -23.038557, 7.8031015, -0.73040724, -22.76259, 8.531679, -0.17249346, -23.038557, 8.782442, 0.63831854, -23.038557, 7.8031015, -0.73040724, -22.186733, 6.818422, -1.1716971, -22.114325, 7.808655, -1.1238518, -22.76259, 8.531679, -0.17249346, -23.038557, 7.8031015, -0.73040724, -22.114325, 7.808655, -1.1238518, -22.114325, 7.808655, -1.1238518, -22.186733, 6.818422, -1.1716971, -21.44, 7.066194, -0.6761534, -23.038557, 8.782442, 0.63831854, -22.76259, 8.531679, -0.17249346, -22.114325, 8.787995, -0.28911734, -22.76259, 8.531679, -0.17249346, -22.114325, 7.808655, -1.1238518, -22.114325, 8.787995, -0.28911734, -23.038557, 8.782442, 0.63831854, -22.114325, 8.787995, -0.28911734, -22.114325, 9.11437, 0.70111585, -22.114325, 7.808655, -1.1238518, -21.44, 7.066194, -0.6761534, -21.262287, 7.8031015, -0.73040724, -22.114325, 8.787995, -0.28911734, -22.114325, 7.808655, -1.1238518, -21.262287, 7.8031015, -0.73040724, -21.130926, 6.7908683, 0.2619624, -21.262287, 7.8031015, -0.73040724, -21.44, 7.066194, -0.6761534, -22.114325, 9.11437, 0.70111585, -22.114325, 8.787995, -0.28911734, -21.498312, 8.531679, -0.17249346, -22.114325, 8.787995, -0.28911734, -21.262287, 7.8031015, -0.73040724, -21.498312, 8.531679, -0.17249346, -21.130926, 6.7908683, 0.2619624, -20.842571, 7.7866554, 0.45014048, -21.262287, 7.8031015, -0.73040724, -20.842571, 7.7866554, 0.45014048, -21.498312, 8.531679, -0.17249346, -21.262287, 7.8031015, -0.73040724, -21.510273, 7.032019, 1.297905, -20.842571, 7.7866554, 0.45014048, -21.130926, 6.7908683, 0.2619624, -21.510273, 7.032019, 1.297905, -21.130926, 7.770209, 1.6309018, -20.842571, 7.7866554, 0.45014048, -20.842571, 7.7866554, 0.45014048, -21.262287, 8.782442, 0.63831854, -21.498312, 8.531679, -0.17249346, -22.114325, 9.11437, 0.70111585, -21.498312, 8.531679, -0.17249346, -21.262287, 8.782442, 0.63831854, -21.130926, 7.770209, 1.6309018, -21.262287, 8.782442, 0.63831854, -20.842571, 7.7866554, 0.45014048, -22.186733, 8.776889, 1.5659679, -22.114325, 9.11437, 0.70111585, -21.262287, 8.782442, 0.63831854, -21.130926, 7.770209, 1.6309018, -21.44, 8.516942, 1.3517308, -21.262287, 8.782442, 0.63831854, -22.186733, 8.776889, 1.5659679, -21.262287, 8.782442, 0.63831854, -21.44, 8.516942, 1.3517308, -23.013992, 7.066194, -0.6761534, -22.403961, 6.4589396, 0.19937873, -22.186733, 6.818422, -1.1716971, -23.013992, 7.066194, -0.6761534, -23.604374, 6.7908683, 0.2619624, -22.403961, 6.4589396, 0.19937873, -22.186733, 6.818422, -1.1716971, -22.403961, 6.4589396, 0.19937873, -21.130926, 6.7908683, 0.2619624, -22.186733, 6.818422, -1.1716971, -21.130926, 6.7908683, 0.2619624, -21.44, 7.066194, -0.6761534, -22.403961, 6.4589396, 0.19937873, -21.510273, 7.032019, 1.297905, -21.130926, 6.7908683, 0.2619624, -22.403961, 6.4589396, 0.19937873, -22.54878, 6.763315, 1.6958355, -21.510273, 7.032019, 1.297905, -23.604374, 6.7908683, 0.2619624, -22.54878, 6.763315, 1.6958355, -22.403961, 6.4589396, 0.19937873, -23.604374, 6.7908683, 0.2619624, -23.394194, 7.032019, 1.297905, -22.54878, 6.763315, 1.6958355, -19.55437, 5.4422207, 0.70111716, -20.65247, 5.732071, 0.5048219, -19.604567, 5.300392, 0.49414217, -19.55437, 5.4422207, 0.70111716, -20.566177, 5.8454905, 0.70111704, -20.65247, 5.732071, 0.5048219, -20.566177, 5.8454905, 0.70111704, -21.690977, 6.831879, 0.64408636, -20.65247, 5.732071, 0.5048219, -20.566177, 5.8454905, 0.70111704, -21.649538, 6.831452, 0.7011167, -21.690977, 6.831879, 0.64408636, -20.65247, 5.732071, 0.5048219, -21.690977, 6.831879, 0.64408636, -21.758045, 6.83252, 0.66587317, -19.604567, 5.300392, 0.49414217, -20.65247, 5.732071, 0.5048219, -20.792162, 5.5481644, 0.5797943, -20.65247, 5.732071, 0.5048219, -21.758045, 6.83252, 0.66587317, -20.792162, 5.5481644, 0.5797943, -19.604567, 5.300392, 0.49414217, -20.792162, 5.5481644, 0.5797943, -19.685518, 5.0709896, 0.57317305, -20.792162, 5.5481644, 0.5797943, -21.758045, 6.83252, 0.66587317, -21.758045, 6.83252, 0.7363601, -19.685518, 5.0709896, 0.57317305, -20.792162, 5.5481644, 0.5797943, -20.792162, 5.5481644, 0.8224399, -20.792162, 5.5481644, 0.5797943, -21.758045, 6.83252, 0.7363601, -20.792162, 5.5481644, 0.8224399, -19.685518, 5.0709896, 0.57317305, -20.792162, 5.5481644, 0.8224399, -19.685518, 5.07099, 0.8290615, -20.792162, 5.5481644, 0.8224399, -21.758045, 6.83252, 0.7363601, -21.690977, 6.831879, 0.7581469, -19.685518, 5.07099, 0.8290615, -20.792162, 5.5481644, 0.8224399, -20.65247, 5.732071, 0.8974122, -20.792162, 5.5481644, 0.8224399, -21.690977, 6.831879, 0.7581469, -20.65247, 5.732071, 0.8974122, -20.65247, 5.732071, 0.8974122, -21.690977, 6.831879, 0.7581469, -21.649538, 6.831452, 0.7011167, -19.685518, 5.07099, 0.8290615, -20.65247, 5.732071, 0.8974122, -19.604567, 5.3003926, 0.90809214, -20.65247, 5.732071, 0.8974122, -21.649538, 6.831452, 0.7011167, -20.566177, 5.8454905, 0.70111704, -19.604567, 5.3003926, 0.90809214, -20.65247, 5.732071, 0.8974122, -20.566177, 5.8454905, 0.70111704, -19.604567, 5.3003926, 0.90809214, -20.566177, 5.8454905, 0.70111704, -19.55437, 5.4422207, 0.70111716, -20.685791, 3.1409323, 0.701118, -19.6868, 5.19808, 0.9268887, -20.258171, 3.1409326, 1.3254604, -20.685791, 3.1409323, 0.701118, -19.848066, 5.2273426, 0.70111716, -19.6868, 5.19808, 0.9268887, -19.848066, 5.2273426, 0.70111716, -19.605635, 7.2641983, 0.800866, -19.6868, 5.19808, 0.9268887, -19.848066, 5.2273426, 0.70111716, -19.677616, 7.271674, 0.70111644, -19.605635, 7.2641983, 0.800866, -19.6868, 5.19808, 0.9268887, -19.605635, 7.2641983, 0.800866, -19.488796, 7.2518096, 0.7628459, -20.258171, 3.1409326, 1.3254604, -19.6868, 5.19808, 0.9268887, -19.425571, 5.150448, 0.8405957, -19.6868, 5.19808, 0.9268887, -19.488796, 7.2518096, 0.7628459, -19.425571, 5.150448, 0.8405957, -20.258171, 3.1409326, 1.3254604, -19.425571, 5.150448, 0.8405957, -19.566545, 3.1409326, 1.0870868, -19.425571, 5.150448, 0.8405957, -19.488796, 7.2518096, 0.7628459, -19.488796, 7.2518096, 0.63938713, -19.566545, 3.1409326, 1.0870868, -19.425571, 5.150448, 0.8405957, -19.425785, 5.150448, 0.5616387, -19.425571, 5.150448, 0.8405957, -19.488796, 7.2518096, 0.63938713, -19.425785, 5.150448, 0.5616387, -19.566545, 3.1409326, 1.0870868, -19.425785, 5.150448, 0.5616387, -19.566545, 3.1409323, 0.3151492, -19.425785, 5.150448, 0.5616387, -19.488796, 7.2518096, 0.63938713, -19.605635, 7.2641983, 0.601367, -19.566545, 3.1409323, 0.3151492, -19.425785, 5.150448, 0.5616387, -19.6868, 5.19808, 0.47534573, -19.425785, 5.150448, 0.5616387, -19.605635, 7.2641983, 0.601367, -19.6868, 5.19808, 0.47534573, -19.6868, 5.19808, 0.47534573, -19.605635, 7.2641983, 0.601367, -19.677616, 7.271674, 0.70111644, -19.566545, 3.1409323, 0.3151492, -19.6868, 5.19808, 0.47534573, -20.258171, 3.140932, 0.07677555, -19.6868, 5.19808, 0.47534573, -19.677616, 7.271674, 0.70111644, -19.848066, 5.2273426, 0.70111716, -20.258171, 3.140932, 0.07677555, -19.6868, 5.19808, 0.47534573, -19.848066, 5.2273426, 0.70111716, -20.258171, 3.140932, 0.07677555, -19.848066, 5.2273426, 0.70111716, -20.685791, 3.1409323, 0.701118, -9.178474, 7.896264, -9.667162, -7.7554946, 8.923878, -8.79697, -7.8560977, 7.5083733, -9.016546, -7.8560977, 7.5083733, -9.016546, -7.7554946, 8.923878, -8.79697, -6.0311303, 8.959761, -9.644308, -7.8560977, 7.5083733, -9.016546, -6.0311303, 8.959761, -9.644308, -6.338922, 7.877041, -9.814329, -9.178474, 7.896264, -9.667162, -9.392284, 8.901663, -9.170551, -7.7554946, 8.923878, -8.79697, -9.710115, 7.6064134, -11.436808, -9.392284, 8.901663, -9.170551, -9.178474, 7.896264, -9.667162, -7.7554946, 8.923878, -8.79697, -6.5292373, 9.998694, -10.128958, -6.0311303, 8.959761, -9.644308, -9.710115, 7.6064134, -11.436808, -9.815418, 9.002266, -11.310146, -9.392284, 8.901663, -9.170551, -8.902294, 7.999003, -13.082569, -9.815418, 9.002266, -11.310146, -9.710115, 7.6064134, -11.436808, -9.392284, 8.901663, -9.170551, -7.567102, 10.353264, -9.798311, -7.7554946, 8.923878, -8.79697, -7.7554946, 8.923878, -8.79697, -7.567102, 10.353264, -9.798311, -6.5292373, 9.998694, -10.128958, -9.815418, 9.002266, -11.310146, -8.663919, 9.972849, -9.918352, -9.392284, 8.901663, -9.170551, -9.392284, 8.901663, -9.170551, -8.663919, 9.972849, -9.918352, -7.567102, 10.353264, -9.798311, -8.902294, 7.999003, -13.082569, -9.121016, 9.077452, -13.414072, -9.815418, 9.002266, -11.310146, -9.121016, 9.077452, -13.414072, -8.902294, 7.999003, -13.082569, -7.418226, 7.5777907, -13.678717, -9.815418, 9.002266, -11.310146, -8.8031845, 10.372702, -11.1476, -8.663919, 9.972849, -9.918352, -9.121016, 9.077452, -13.414072, -8.8031845, 10.372702, -11.1476, -9.815418, 9.002266, -11.310146, -8.663919, 9.972849, -9.918352, -7.5043044, 10.829584, -11.093346, -7.567102, 10.353264, -9.798311, -8.663919, 9.972849, -9.918352, -8.8031845, 10.372702, -11.1476, -7.5043044, 10.829584, -11.093346, -9.121016, 9.077452, -13.414072, -8.543451, 10.050811, -12.445198, -8.8031845, 10.372702, -11.1476, -9.121016, 9.077452, -13.414072, -7.418226, 7.5777907, -13.678717, -7.404982, 8.97941, -13.680212, -8.543451, 10.050811, -12.445198, -9.121016, 9.077452, -13.414072, -7.404982, 8.97941, -13.680212, -7.404982, 8.97941, -13.680212, -7.418226, 7.5777907, -13.678717, -6.070005, 7.8608065, -12.713261, -8.8031845, 10.372702, -11.1476, -8.543451, 10.050811, -12.445198, -7.479528, 10.367147, -12.461218, -8.543451, 10.050811, -12.445198, -7.404982, 8.97941, -13.680212, -7.479528, 10.367147, -12.461218, -8.8031845, 10.372702, -11.1476, -7.479528, 10.367147, -12.461218, -7.5043044, 10.829584, -11.093346, -7.404982, 8.97941, -13.680212, -6.070005, 7.8608065, -12.713261, -5.7767367, 8.867486, -12.725651, -7.479528, 10.367147, -12.461218, -7.404982, 8.97941, -13.680212, -5.7767367, 8.867486, -12.725651, -5.564422, 7.4799643, -11.258242, -5.7767367, 8.867486, -12.725651, -6.070005, 7.8608065, -12.713261, -7.5043044, 10.829584, -11.093346, -7.479528, 10.367147, -12.461218, -6.4160304, 9.957684, -12.139328, -7.479528, 10.367147, -12.461218, -5.7767367, 8.867486, -12.725651, -6.4160304, 9.957684, -12.139328, -5.564422, 7.4799643, -11.258242, -5.345058, 8.901022, -11.16725, -5.7767367, 8.867486, -12.725651, -5.345058, 8.901022, -11.16725, -6.4160304, 9.957684, -12.139328, -5.7767367, 8.867486, -12.725651, -6.338922, 7.877041, -9.814329, -5.345058, 8.901022, -11.16725, -5.564422, 7.4799643, -11.258242, -6.338922, 7.877041, -9.814329, -6.0311303, 8.959761, -9.644308, -5.345058, 8.901022, -11.16725, -5.345058, 8.901022, -11.16725, -6.2436585, 10.347497, -11.111715, -6.4160304, 9.957684, -12.139328, -7.5043044, 10.829584, -11.093346, -6.4160304, 9.957684, -12.139328, -6.2436585, 10.347497, -11.111715, -6.0311303, 8.959761, -9.644308, -6.2436585, 10.347497, -11.111715, -5.345058, 8.901022, -11.16725, -7.567102, 10.353264, -9.798311, -7.5043044, 10.829584, -11.093346, -6.2436585, 10.347497, -11.111715, -6.0311303, 8.959761, -9.644308, -6.5292373, 9.998694, -10.128958, -6.2436585, 10.347497, -11.111715, -7.567102, 10.353264, -9.798311, -6.2436585, 10.347497, -11.111715, -6.5292373, 9.998694, -10.128958, -8.902294, 7.999003, -13.082569, -7.6561713, 7.073704, -11.383837, -7.418226, 7.5777907, -13.678717, -8.902294, 7.999003, -13.082569, -9.710115, 7.6064134, -11.436808, -7.6561713, 7.073704, -11.383837, -7.418226, 7.5777907, -13.678717, -7.6561713, 7.073704, -11.383837, -5.564422, 7.4799643, -11.258242, -7.418226, 7.5777907, -13.678717, -5.564422, 7.4799643, -11.258242, -6.070005, 7.8608065, -12.713261, -7.6561713, 7.073704, -11.383837, -6.338922, 7.877041, -9.814329, -5.564422, 7.4799643, -11.258242, -7.6561713, 7.073704, -11.383837, -7.8560977, 7.5083733, -9.016546, -6.338922, 7.877041, -9.814329, -9.710115, 7.6064134, -11.436808, -7.8560977, 7.5083733, -9.016546, -7.6561713, 7.073704, -11.383837, -9.710115, 7.6064134, -11.436808, -9.178474, 7.896264, -9.667162, -7.8560977, 7.5083733, -9.016546, -11.561996, 7.1179185, -10.496557, -10.571764, 7.8505545, -9.770115, -10.716581, 6.8492146, -10.098627, -10.716581, 6.8492146, -10.098627, -10.571764, 7.8505545, -9.770115, -9.298729, 7.856108, -10.163559, -10.716581, 6.8492146, -10.098627, -9.298729, 7.856108, -10.163559, -9.678076, 7.1179185, -10.496557, -11.561996, 7.1179185, -10.496557, -11.772175, 7.856108, -10.163559, -10.571764, 7.8505545, -9.770115, -11.772175, 6.8767676, -11.532499, -11.772175, 7.856108, -10.163559, -11.561996, 7.1179185, -10.496557, -10.571764, 7.8505545, -9.770115, -9.607803, 8.602841, -10.44273, -9.298729, 7.856108, -10.163559, -11.772175, 6.8767676, -11.532499, -11.843517, 7.872555, -11.344322, -11.772175, 7.856108, -10.163559, -11.181795, 7.1520934, -12.470615, -11.843517, 7.872555, -11.344322, -11.772175, 6.8767676, -11.532499, -11.772175, 7.856108, -10.163559, -10.354536, 8.862788, -10.228495, -10.571764, 7.8505545, -9.770115, -10.571764, 7.8505545, -9.770115, -10.354536, 8.862788, -10.228495, -9.607803, 8.602841, -10.44273, -11.843517, 7.872555, -11.344322, -11.181795, 8.602841, -10.44273, -11.772175, 7.856108, -10.163559, -11.772175, 7.856108, -10.163559, -11.181795, 8.602841, -10.44273, -10.354536, 8.862788, -10.228495, -11.181795, 7.1520934, -12.470615, -11.206358, 7.889001, -12.524868, -11.843517, 7.872555, -11.344322, -11.206358, 7.889001, -12.524868, -11.181795, 7.1520934, -12.470615, -10.354536, 6.904321, -12.966158, -11.843517, 7.872555, -11.344322, -11.206358, 8.868341, -11.156143, -11.181795, 8.602841, -10.44273, -11.206358, 7.889001, -12.524868, -11.206358, 8.868341, -11.156143, -11.843517, 7.872555, -11.344322, -11.181795, 8.602841, -10.44273, -10.282127, 9.20027, -11.093346, -10.354536, 8.862788, -10.228495, -11.181795, 8.602841, -10.44273, -11.206358, 8.868341, -11.156143, -10.282127, 9.20027, -11.093346, -11.206358, 7.889001, -12.524868, -10.930391, 8.6175785, -11.966955, -11.206358, 8.868341, -11.156143, -11.206358, 7.889001, -12.524868, -10.354536, 6.904321, -12.966158, -10.282127, 7.894554, -12.918314, -10.930391, 8.6175785, -11.966955, -11.206358, 7.889001, -12.524868, -10.282127, 7.894554, -12.918314, -10.282127, 7.894554, -12.918314, -10.354536, 6.904321, -12.966158, -9.607803, 7.1520934, -12.470615, -11.206358, 8.868341, -11.156143, -10.930391, 8.6175785, -11.966955, -10.282127, 8.873895, -12.083578, -10.930391, 8.6175785, -11.966955, -10.282127, 7.894554, -12.918314, -10.282127, 8.873895, -12.083578, -11.206358, 8.868341, -11.156143, -10.282127, 8.873895, -12.083578, -10.282127, 9.20027, -11.093346, -10.282127, 7.894554, -12.918314, -9.607803, 7.1520934, -12.470615, -9.43009, 7.889001, -12.524868, -10.282127, 8.873895, -12.083578, -10.282127, 7.894554, -12.918314, -9.43009, 7.889001, -12.524868, -9.298729, 6.8767676, -11.532499, -9.43009, 7.889001, -12.524868, -9.607803, 7.1520934, -12.470615, -10.282127, 9.20027, -11.093346, -10.282127, 8.873895, -12.083578, -9.666115, 8.6175785, -11.966955, -10.282127, 8.873895, -12.083578, -9.43009, 7.889001, -12.524868, -9.666115, 8.6175785, -11.966955, -9.298729, 6.8767676, -11.532499, -9.010374, 7.872555, -11.344322, -9.43009, 7.889001, -12.524868, -9.010374, 7.872555, -11.344322, -9.666115, 8.6175785, -11.966955, -9.43009, 7.889001, -12.524868, -9.678076, 7.1179185, -10.496557, -9.010374, 7.872555, -11.344322, -9.298729, 6.8767676, -11.532499, -9.678076, 7.1179185, -10.496557, -9.298729, 7.856108, -10.163559, -9.010374, 7.872555, -11.344322, -9.010374, 7.872555, -11.344322, -9.43009, 8.868341, -11.156143, -9.666115, 8.6175785, -11.966955, -10.282127, 9.20027, -11.093346, -9.666115, 8.6175785, -11.966955, -9.43009, 8.868341, -11.156143, -9.298729, 7.856108, -10.163559, -9.43009, 8.868341, -11.156143, -9.010374, 7.872555, -11.344322, -10.354536, 8.862788, -10.228495, -10.282127, 9.20027, -11.093346, -9.43009, 8.868341, -11.156143, -9.298729, 7.856108, -10.163559, -9.607803, 8.602841, -10.44273, -9.43009, 8.868341, -11.156143, -10.354536, 8.862788, -10.228495, -9.43009, 8.868341, -11.156143, -9.607803, 8.602841, -10.44273, -11.181795, 7.1520934, -12.470615, -10.571764, 6.544839, -11.595083, -10.354536, 6.904321, -12.966158, -11.181795, 7.1520934, -12.470615, -11.772175, 6.8767676, -11.532499, -10.571764, 6.544839, -11.595083, -10.354536, 6.904321, -12.966158, -10.571764, 6.544839, -11.595083, -9.298729, 6.8767676, -11.532499, -10.354536, 6.904321, -12.966158, -9.298729, 6.8767676, -11.532499, -9.607803, 7.1520934, -12.470615, -10.571764, 6.544839, -11.595083, -9.678076, 7.1179185, -10.496557, -9.298729, 6.8767676, -11.532499, -10.571764, 6.544839, -11.595083, -10.716581, 6.8492146, -10.098627, -9.678076, 7.1179185, -10.496557, -11.772175, 6.8767676, -11.532499, -10.716581, 6.8492146, -10.098627, -10.571764, 6.544839, -11.595083, -11.772175, 6.8767676, -11.532499, -11.561996, 7.1179185, -10.496557, -10.716581, 6.8492146, -10.098627, -7.722173, 5.52812, -11.093344, -8.820272, 5.8179703, -11.28964, -7.772368, 5.3862915, -11.30032, -7.722173, 5.52812, -11.093344, -8.73398, 5.93139, -11.093346, -8.820272, 5.8179703, -11.28964, -8.73398, 5.93139, -11.093346, -9.858778, 6.9177785, -11.150375, -8.820272, 5.8179703, -11.28964, -8.73398, 5.93139, -11.093346, -9.817341, 6.9173512, -11.093346, -9.858778, 6.9177785, -11.150375, -8.820272, 5.8179703, -11.28964, -9.858778, 6.9177785, -11.150375, -9.925848, 6.9184194, -11.128588, -7.772368, 5.3862915, -11.30032, -8.820272, 5.8179703, -11.28964, -8.959965, 5.6340637, -11.214668, -8.820272, 5.8179703, -11.28964, -9.925848, 6.9184194, -11.128588, -8.959965, 5.6340637, -11.214668, -7.772368, 5.3862915, -11.30032, -8.959965, 5.6340637, -11.214668, -7.8533216, 5.156889, -11.221289, -8.959965, 5.6340637, -11.214668, -9.925848, 6.9184194, -11.128588, -9.925848, 6.9184194, -11.058102, -7.8533216, 5.156889, -11.221289, -8.959965, 5.6340637, -11.214668, -8.959965, 5.6340637, -10.972021, -8.959965, 5.6340637, -11.214668, -9.925848, 6.9184194, -11.058102, -8.959965, 5.6340637, -10.972021, -7.8533216, 5.156889, -11.221289, -8.959965, 5.6340637, -10.972021, -7.8533216, 5.1568894, -10.965401, -8.959965, 5.6340637, -10.972021, -9.925848, 6.9184194, -11.058102, -9.858778, 6.9177785, -11.036314, -7.8533216, 5.1568894, -10.965401, -8.959965, 5.6340637, -10.972021, -8.820272, 5.8179703, -10.897049, -8.959965, 5.6340637, -10.972021, -9.858778, 6.9177785, -11.036314, -8.820272, 5.8179703, -10.897049, -8.820272, 5.8179703, -10.897049, -9.858778, 6.9177785, -11.036314, -9.817341, 6.9173512, -11.093346, -7.8533216, 5.1568894, -10.965401, -8.820272, 5.8179703, -10.897049, -7.772368, 5.386292, -10.88637, -8.820272, 5.8179703, -10.897049, -9.817341, 6.9173512, -11.093346, -8.73398, 5.93139, -11.093346, -7.772368, 5.386292, -10.88637, -8.820272, 5.8179703, -10.897049, -8.73398, 5.93139, -11.093346, -7.772368, 5.386292, -10.88637, -8.73398, 5.93139, -11.093346, -7.722173, 5.52812, -11.093344, -8.853594, 3.2268317, -11.093344, -7.8546023, 5.2839794, -10.867573, -8.425974, 3.226832, -10.469002, -8.853594, 3.2268317, -11.093344, -8.015869, 5.313242, -11.093344, -7.8546023, 5.2839794, -10.867573, -8.015869, 5.313242, -11.093344, -7.773436, 7.3500977, -10.993595, -7.8546023, 5.2839794, -10.867573, -8.015869, 5.313242, -11.093344, -7.8454185, 7.3575735, -11.093346, -7.773436, 7.3500977, -10.993595, -7.8546023, 5.2839794, -10.867573, -7.773436, 7.3500977, -10.993595, -7.6565986, 7.337709, -11.031616, -8.425974, 3.226832, -10.469002, -7.8546023, 5.2839794, -10.867573, -7.5933747, 5.236347, -10.953865, -7.8546023, 5.2839794, -10.867573, -7.6565986, 7.337709, -11.031616, -7.5933747, 5.236347, -10.953865, -8.425974, 3.226832, -10.469002, -7.5933747, 5.236347, -10.953865, -7.734348, 3.226832, -10.707375, -7.5933747, 5.236347, -10.953865, -7.6565986, 7.337709, -11.031616, -7.6565986, 7.337709, -11.155075, -7.734348, 3.226832, -10.707375, -7.5933747, 5.236347, -10.953865, -7.5935884, 5.236347, -11.232822, -7.5933747, 5.236347, -10.953865, -7.6565986, 7.337709, -11.155075, -7.5935884, 5.236347, -11.232822, -7.734348, 3.226832, -10.707375, -7.5935884, 5.236347, -11.232822, -7.734348, 3.2268317, -11.479313, -7.5935884, 5.236347, -11.232822, -7.6565986, 7.337709, -11.155075, -7.773436, 7.3500977, -11.193094, -7.734348, 3.2268317, -11.479313, -7.5935884, 5.236347, -11.232822, -7.8546023, 5.2839794, -11.319117, -7.5935884, 5.236347, -11.232822, -7.773436, 7.3500977, -11.193094, -7.8546023, 5.2839794, -11.319117, -7.8546023, 5.2839794, -11.319117, -7.773436, 7.3500977, -11.193094, -7.8454185, 7.3575735, -11.093346, -7.734348, 3.2268317, -11.479313, -7.8546023, 5.2839794, -11.319117, -8.425974, 3.2268314, -11.717686, -7.8546023, 5.2839794, -11.319117, -7.8454185, 7.3575735, -11.093346, -8.015869, 5.313242, -11.093344, -8.425974, 3.2268314, -11.717686, -7.8546023, 5.2839794, -11.319117, -8.015869, 5.313242, -11.093344, -8.425974, 3.2268314, -11.717686, -8.015869, 5.313242, -11.093344, -8.853594, 3.2268317, -11.093344)
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_7ny4s"]
+resource_local_to_scene = true
+size = Vector2(16.1, 12.3675)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_agj3o"]
+resource_local_to_scene = true
+size = Vector3(16.1, 4, 12.3675)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_l6210"]
+resource_local_to_scene = true
+size = Vector3(16.1, 4.8, 12.3675)
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_6upbg"]
+resource_local_to_scene = true
+size = Vector2(10000, 10000)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_umo15"]
+resource_local_to_scene = true
+size = Vector3(17, 2, 80)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_e4lmg"]
+resource_local_to_scene = true
+size = Vector3(42, 2, 80)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_eb3g5"]
+resource_local_to_scene = true
+size = Vector3(48, 2, 18)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_wsqap"]
+resource_local_to_scene = true
+size = Vector3(48, 2, 18)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_1fdkm"]
+resource_local_to_scene = true
+size = Vector3(17, 5, 80)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_y2c0j"]
+resource_local_to_scene = true
+size = Vector3(42, 5, 80)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_uwwqy"]
+resource_local_to_scene = true
+size = Vector3(48, 5, 18)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_hf28p"]
+resource_local_to_scene = true
+size = Vector3(48, 5, 18)
+
+[node name="Main" type="Node3D" unique_id=704196381]
script = ExtResource("3_main")
fish_catalog = ExtResource("6_pool")
pelican_buyer_profile = ExtResource("7_pelicans")
main_shop_buyer_profile = ExtResource("12_main_shop")
item_catalog = ExtResource("11_items")
-[node name="TestWorld" parent="." instance=ExtResource("1_world")]
-
-[node name="Players" type="Node3D" parent="."]
-
-[node name="Player" parent="Players" instance=ExtResource("2_player")]
+[node name="TitleBackgroundLayer" type="CanvasLayer" parent="." unique_id=1690754702]
unique_name_in_owner = true
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 16)
+layer = -1
-[node name="FishingSpot" parent="." instance=ExtResource("4_spot")]
+[node name="TitleBackground" type="ColorRect" parent="TitleBackgroundLayer" unique_id=1768697365]
unique_name_in_owner = true
-position = Vector3(0, 1.02, -18)
+material = SubResource("ShaderMaterial_title_water_native")
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
-[node name="WaterRecovery" type="Node" parent="."]
+[node name="PlayerMenuBackdrop" type="ColorRect" parent="." unique_id=2055423661]
+unique_name_in_owner = true
+visible = false
+z_index = 10
+material = SubResource("ShaderMaterial_player_menu_pattern")
+mouse_filter = 2
+
+[node name="ShopBackdrop" type="ColorRect" parent="." unique_id=1404109502]
+unique_name_in_owner = true
+visible = false
+z_index = 10
+material = SubResource("ShaderMaterial_shop_pattern")
+mouse_filter = 2
+
+[node name="InterfaceFontController" type="Node" parent="." unique_id=384169781]
+unique_name_in_owner = true
+script = ExtResource("39_fonts")
+
+[node name="WorldTimeService" type="Node" parent="." unique_id=1774610576]
+unique_name_in_owner = true
+script = ExtResource("47_world_time")
+
+[node name="WorldTimeVisualController" type="Node" parent="." unique_id=1861087008]
+unique_name_in_owner = true
+script = ExtResource("48_world_time_visuals")
+
+[node name="NetworkWorldTimeService" type="Node" parent="." unique_id=1136962511]
+unique_name_in_owner = true
+script = ExtResource("49_network_world_time")
+
+[node name="WorldWeatherService" type="Node" parent="." unique_id=1812156280]
+unique_name_in_owner = true
+script = ExtResource("50_world_weather")
+
+[node name="NetworkWorldWeatherService" type="Node" parent="." unique_id=266238117]
+unique_name_in_owner = true
+script = ExtResource("51_network_world_weather")
+
+[node name="PlayerJobService" type="Node" parent="." unique_id=2062127535]
+unique_name_in_owner = true
+script = ExtResource("52_player_jobs")
+
+[node name="NetworkJobService" type="Node" parent="." unique_id=556458360]
+unique_name_in_owner = true
+script = ExtResource("53_network_jobs")
+
+[node name="PlayerDataRoot" type="Node" parent="." unique_id=281868360]
+unique_name_in_owner = true
+script = ExtResource("37_data_root")
+
+[node name="IdentityBackupService" type="Node" parent="." unique_id=1606277266]
+unique_name_in_owner = true
+script = ExtResource("38_identity_backup")
+
+[node name="NetworkSession" type="Node" parent="." unique_id=88578570]
+unique_name_in_owner = true
+script = ExtResource("17_network_session")
+
+[node name="DiscoveryClient" type="Node" parent="." unique_id=401971194]
+unique_name_in_owner = true
+script = ExtResource("57_discovery")
+
+[node name="NetworkProfilePreferences" type="Node" parent="." unique_id=2119445545]
+unique_name_in_owner = true
+script = ExtResource("18_network_profile")
+
+[node name="PlayerIdentityStore" type="Node" parent="." unique_id=737958453]
+unique_name_in_owner = true
+script = ExtResource("30_player_identity")
+
+[node name="HostIdentityStore" type="Node" parent="." unique_id=351659562]
+unique_name_in_owner = true
+script = ExtResource("31_host_identity")
+
+[node name="KnownPlayerStore" type="Node" parent="." unique_id=390068772]
+unique_name_in_owner = true
+script = ExtResource("32_known_players")
+
+[node name="ServerTrustStore" type="Node" parent="." unique_id=1565692831]
+unique_name_in_owner = true
+script = ExtResource("33_server_trust")
+
+[node name="PlayerRelationshipStore" type="Node" parent="." unique_id=612731662]
+unique_name_in_owner = true
+script = ExtResource("34_relationships")
+
+[node name="HostBanStore" type="Node" parent="." unique_id=1672077885]
+unique_name_in_owner = true
+script = ExtResource("35_bans")
+
+[node name="PlayerAppearanceStore" type="Node" parent="." unique_id=1740017030]
+unique_name_in_owner = true
+script = ExtResource("28_appearance")
+
+[node name="NetworkProfileService" type="Node" parent="." unique_id=1074818740]
+unique_name_in_owner = true
+script = ExtResource("29_profile_service")
+
+[node name="SavedServerStore" type="Node" parent="." unique_id=660213994]
+unique_name_in_owner = true
+script = ExtResource("19_saved_servers")
+
+[node name="PlayerSpawnService" type="Node" parent="." unique_id=2097451957]
+unique_name_in_owner = true
+script = ExtResource("20_spawn_service")
+
+[node name="NetworkFishingService" type="Node" parent="." unique_id=692209374]
+unique_name_in_owner = true
+script = ExtResource("21_network_fishing")
+
+[node name="NetworkSaleService" type="Node" parent="." unique_id=1445041024]
+unique_name_in_owner = true
+script = ExtResource("22_network_sale")
+
+[node name="NetworkShopService" type="Node" parent="." unique_id=1493674695]
+unique_name_in_owner = true
+script = ExtResource("23_network_shop")
+
+[node name="NetworkItemUseService" type="Node" parent="." unique_id=255154670]
+unique_name_in_owner = true
+script = ExtResource("24_network_item")
+
+[node name="NetworkFishShowcaseService" type="Node" parent="." unique_id=148288598]
+unique_name_in_owner = true
+script = ExtResource("45_fish_showcase")
+
+[node name="NetworkSurfaceDrawingService" type="Node" parent="." unique_id=80122665]
+unique_name_in_owner = true
+script = ExtResource("46_surface_drawing")
+
+[node name="NetworkChatService" type="Node" parent="." unique_id=1956959711]
+unique_name_in_owner = true
+script = ExtResource("25_network_chat")
+
+[node name="PlayerAssetReservationService" type="Node" parent="." unique_id=217588893]
+unique_name_in_owner = true
+script = ExtResource("27_reservations")
+
+[node name="NetworkMailService" type="Node" parent="." unique_id=1415330316]
+unique_name_in_owner = true
+script = ExtResource("26_network_mail")
+
+[node name="NetworkPlayerListService" type="Node" parent="." unique_id=1638035935]
+unique_name_in_owner = true
+script = ExtResource("36_player_list")
+
+[node name="TestWorld" parent="." unique_id=1334932296 instance=ExtResource("1_world")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.4475808, 0, -1.8959346)
+
+[node name="Sun" parent="TestWorld/Environment" index="1"]
+transform = Transform3D(0.8480481, 0.4287137, -0.31147876, 0, 0.5877853, 0.809017, 0.52991927, -0.68608534, 0.49847022, 0, 0, 0)
+
+[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/Terrain/Collision" index="0" unique_id=674742331]
+shape = SubResource("ConcavePolygonShape3D_hifdc")
+
+[node name="Pond" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies" index="0" unique_id=995477450]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.4, 2.51, 2.1)
+
+[node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond" index="0" unique_id=1034265960]
+mesh = SubResource("PlaneMesh_7ny4s")
+
+[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/FishingRegion" index="0" unique_id=1185360438]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -2, 0)
+shape = SubResource("BoxShape3D_agj3o")
+
+[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/RecoveryRegion" index="0" unique_id=1718808476]
+shape = SubResource("BoxShape3D_l6210")
+
+[node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean" index="0" unique_id=1176408937]
+mesh = SubResource("PlaneMesh_6upbg")
+
+[node name="WestShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="0" unique_id=357406062]
+shape = SubResource("BoxShape3D_umo15")
+
+[node name="EastShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="1" unique_id=772224738]
+shape = SubResource("BoxShape3D_e4lmg")
+
+[node name="NorthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="2" unique_id=646147646]
+shape = SubResource("BoxShape3D_eb3g5")
+
+[node name="SouthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="3" unique_id=894083854]
+shape = SubResource("BoxShape3D_wsqap")
+
+[node name="WestShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="0" unique_id=1183368645]
+shape = SubResource("BoxShape3D_1fdkm")
+
+[node name="EastShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="1" unique_id=1742419808]
+shape = SubResource("BoxShape3D_y2c0j")
+
+[node name="NorthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="2" unique_id=1844920260]
+shape = SubResource("BoxShape3D_uwwqy")
+
+[node name="SouthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="3" unique_id=1316495770]
+shape = SubResource("BoxShape3D_hf28p")
+
+[node name="FishingShopWorld" parent="TestWorld/Regions/StarterIslandRegion/Interactables" index="0" unique_id=1255373524]
+transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9976046, 7.3292284, 3.4012775, 14.888193)
+
+[node name="BelowWorldFailsafe" parent="TestWorld/Safety" index="0"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, -7, 0)
+
+[node name="North" parent="TestWorld/WorldBounds" index="0"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 5, 63.225395)
+
+[node name="South" parent="TestWorld/WorldBounds" index="1"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 5, -63.225395)
+
+[node name="West" parent="TestWorld/WorldBounds" index="2"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -72.4386, 5, 0)
+
+[node name="East" parent="TestWorld/WorldBounds" index="3"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 96.4386, 5, 0)
+
+[node name="Players" type="Node3D" parent="." unique_id=550630144]
+
+[node name="Player" parent="Players" unique_id=485429332 instance=ExtResource("2_player")]
+unique_name_in_owner = true
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081)
+
+[node name="SurfaceDrawings" type="Node3D" parent="." unique_id=234671307]
+
+[node name="FishingSpot" parent="." unique_id=1461328864 instance=ExtResource("4_spot")]
+unique_name_in_owner = true
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.02, -18)
+
+[node name="WaterRecovery" type="Node" parent="." unique_id=12981798]
unique_name_in_owner = true
script = ExtResource("8_recovery")
-[node name="PlayerSaveManager" type="Node" parent="."]
+[node name="PlayerSaveManager" type="Node" parent="." unique_id=223569593]
unique_name_in_owner = true
script = ExtResource("9_save")
-[node name="PlayerSettingsManager" type="Node" parent="."]
+[node name="PlayerSettingsManager" type="Node" parent="." unique_id=906991991]
unique_name_in_owner = true
script = ExtResource("10_settings")
-[node name="TitleMusic" type="AudioStreamPlayer" parent="."]
+[node name="ControllerMappingManager" type="Node" parent="." unique_id=1198390067]
+unique_name_in_owner = true
+script = ExtResource("56_controller_mapping")
+
+[node name="TitleMusic" type="AudioStreamPlayer" parent="." unique_id=1292939956]
unique_name_in_owner = true
stream = ExtResource("13_title_music")
volume_db = -80.0
bus = &"Music"
-[node name="GameUI" parent="." instance=ExtResource("5_ui")]
+[node name="DuskMusic" type="AudioStreamPlayer" parent="." unique_id=992744241]
unique_name_in_owner = true
+stream = ExtResource("dusk_music")
+bus = &"Music"
+
+[node name="ShorelineAmbience" type="Node" parent="." unique_id=1948037177]
+unique_name_in_owner = true
+script = ExtResource("54_shoreline_ambience")
+
+[node name="WavesAudio" type="AudioStreamPlayer" parent="ShorelineAmbience" unique_id=193530658]
+unique_name_in_owner = true
+stream = ExtResource("55_waves")
+bus = &"Environment"
+
+[node name="WorldPixelationPostprocess" parent="." unique_id=344378000 instance=ExtResource("16_world_pixelation")]
+unique_name_in_owner = true
+
+[node name="UIPresentation" type="SubViewportContainer" parent="." unique_id=1104419395]
+unique_name_in_owner = true
+z_index = 20
+texture_filter = 1
+offset_right = 1152.0
+offset_bottom = 648.0
+mouse_filter = 0
+stretch = true
+script = ExtResource("14_pixelation")
+
+[node name="UIViewport" type="SubViewport" parent="UIPresentation" unique_id=461155312]
+transparent_bg = true
+handle_input_locally = false
+size = Vector2i(1152, 648)
+render_target_update_mode = 4
+
+[node name="GameUI" parent="UIPresentation/UIViewport" unique_id=954136124 instance=ExtResource("5_ui")]
+unique_name_in_owner = true
+
+[node name="PixelationResetOverlay" parent="." unique_id=291636041 instance=ExtResource("15_reset")]
+unique_name_in_owner = true
+
+[editable path="TestWorld"]
+[editable path="TestWorld/Regions/StarterIslandRegion"]
diff --git a/main/texture_sampling_policy.gd b/main/texture_sampling_policy.gd
new file mode 100644
index 0000000..3d041d0
--- /dev/null
+++ b/main/texture_sampling_policy.gd
@@ -0,0 +1,74 @@
+extends Node
+
+## Enforces NETfishing's nearest-neighbor-only artwork policy at runtime.
+##
+## Godot uses separate texture-filter enums for CanvasItem and 3D materials.
+## Applying both here prevents imported models, dynamically created controls,
+## and Sprite3D presentations from silently falling back to linear sampling.
+
+
+func _enter_tree() -> void:
+ get_tree().node_added.connect(_on_node_added)
+ call_deferred("_enforce_existing_tree")
+
+
+func _on_node_added(node: Node) -> void:
+ enforce_node(node)
+ # Some runtime presenters assign their mesh or material immediately after
+ # add_child(). Recheck once deferred so those resources are covered too.
+ call_deferred("_enforce_deferred_node", weakref(node))
+
+
+func _enforce_existing_tree() -> void:
+ enforce_subtree(get_tree().root)
+
+
+func _enforce_deferred_node(node_ref: WeakRef) -> void:
+ var node := node_ref.get_ref() as Node
+ if node != null:
+ enforce_node(node)
+
+
+static func enforce_subtree(node: Node) -> void:
+ enforce_node(node)
+ for child: Node in node.get_children():
+ enforce_subtree(child)
+
+
+static func enforce_node(node: Node) -> void:
+ if node is CanvasItem:
+ (node as CanvasItem).texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ if node is SpriteBase3D:
+ (node as SpriteBase3D).texture_filter = (
+ BaseMaterial3D.TEXTURE_FILTER_NEAREST
+ )
+ if node is GeometryInstance3D:
+ var geometry := node as GeometryInstance3D
+ enforce_material(geometry.material_override)
+ enforce_material(geometry.material_overlay)
+ if node is MeshInstance3D:
+ enforce_mesh((node as MeshInstance3D).mesh)
+ if node is MultiMeshInstance3D:
+ var multimesh := (node as MultiMeshInstance3D).multimesh
+ if multimesh != null:
+ enforce_mesh(multimesh.mesh)
+ if node is GPUParticles3D:
+ var particles := node as GPUParticles3D
+ for pass_index: int in particles.draw_passes:
+ enforce_mesh(particles.get_draw_pass_mesh(pass_index))
+ if node is CPUParticles3D:
+ enforce_mesh((node as CPUParticles3D).mesh)
+
+
+static func enforce_mesh(mesh: Mesh) -> void:
+ if mesh == null:
+ return
+ for surface_index: int in mesh.get_surface_count():
+ enforce_material(mesh.surface_get_material(surface_index))
+
+
+static func enforce_material(material: Material) -> void:
+ if material is BaseMaterial3D:
+ (material as BaseMaterial3D).texture_filter = (
+ BaseMaterial3D.TEXTURE_FILTER_NEAREST
+ )
diff --git a/main/texture_sampling_policy.gd.uid b/main/texture_sampling_policy.gd.uid
new file mode 100644
index 0000000..820783e
--- /dev/null
+++ b/main/texture_sampling_policy.gd.uid
@@ -0,0 +1 @@
+uid://dwd4wfn8bwk5i
diff --git a/main/world_pixelation_postprocess.gd b/main/world_pixelation_postprocess.gd
new file mode 100644
index 0000000..303fa3e
--- /dev/null
+++ b/main/world_pixelation_postprocess.gd
@@ -0,0 +1,51 @@
+class_name WorldPixelationPostprocess
+extends CanvasLayer
+
+@onready var _screen_grid: ColorRect = %ScreenGrid
+
+var _pixel_size: int = PlayerSettings.DEFAULT_WORLD_PIXEL_SIZE
+var _gameplay_active: bool = false
+
+
+func _ready() -> void:
+ get_viewport().size_changed.connect(_refresh_grid)
+ _refresh_grid()
+
+
+func set_pixel_size(pixel_size: int) -> void:
+ _pixel_size = clampi(
+ pixel_size,
+ PlayerSettings.MIN_WORLD_PIXEL_SIZE,
+ PlayerSettings.MAX_WORLD_PIXEL_SIZE
+ )
+ _refresh_grid()
+
+
+func set_gameplay_active(active: bool) -> void:
+ _gameplay_active = active
+ _refresh_grid()
+
+
+func get_grid_size() -> Vector2i:
+ return PlayerSettings.get_world_grid_size(
+ _pixel_size,
+ get_window().size
+ )
+
+
+func _refresh_grid() -> void:
+ if not is_node_ready():
+ return
+ var effect_enabled: bool = (
+ _gameplay_active
+ and _pixel_size != PlayerSettings.MIN_WORLD_PIXEL_SIZE
+ )
+ _screen_grid.visible = effect_enabled
+ if not effect_enabled:
+ return
+ var shader_material := _screen_grid.material as ShaderMaterial
+ if shader_material != null:
+ shader_material.set_shader_parameter(
+ "logical_grid_size",
+ Vector2(get_grid_size())
+ )
diff --git a/main/world_pixelation_postprocess.gd.uid b/main/world_pixelation_postprocess.gd.uid
new file mode 100644
index 0000000..b77b05c
--- /dev/null
+++ b/main/world_pixelation_postprocess.gd.uid
@@ -0,0 +1 @@
+uid://c3n47x8egoc8x
diff --git a/main/world_pixelation_postprocess.gdshader b/main/world_pixelation_postprocess.gdshader
new file mode 100644
index 0000000..4018a3f
--- /dev/null
+++ b/main/world_pixelation_postprocess.gdshader
@@ -0,0 +1,15 @@
+shader_type canvas_item;
+render_mode unshaded;
+
+uniform sampler2D screen_texture
+ : hint_screen_texture, filter_nearest, repeat_disable;
+uniform vec2 logical_grid_size = vec2(640.0, 360.0);
+
+
+void fragment() {
+ vec2 safe_grid = max(logical_grid_size, vec2(1.0));
+ vec2 snapped_uv = (
+ floor(SCREEN_UV * safe_grid) + vec2(0.5)
+ ) / safe_grid;
+ COLOR = texture(screen_texture, snapped_uv);
+}
diff --git a/main/world_pixelation_postprocess.gdshader.uid b/main/world_pixelation_postprocess.gdshader.uid
new file mode 100644
index 0000000..0b61936
--- /dev/null
+++ b/main/world_pixelation_postprocess.gdshader.uid
@@ -0,0 +1 @@
+uid://sq224d8sn1b4
diff --git a/main/world_pixelation_postprocess.tscn b/main/world_pixelation_postprocess.tscn
new file mode 100644
index 0000000..b6f952b
--- /dev/null
+++ b/main/world_pixelation_postprocess.tscn
@@ -0,0 +1,21 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://main/world_pixelation_postprocess.gd" id="1_script"]
+[ext_resource type="Shader" path="res://main/world_pixelation_postprocess.gdshader" id="2_shader"]
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_world_pixelation"]
+shader = ExtResource("2_shader")
+
+[node name="WorldPixelationPostprocess" type="CanvasLayer"]
+layer = -1
+script = ExtResource("1_script")
+
+[node name="ScreenGrid" type="ColorRect" parent="."]
+unique_name_in_owner = true
+material = SubResource("ShaderMaterial_world_pixelation")
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
diff --git a/network/connection_endpoint.gd b/network/connection_endpoint.gd
new file mode 100644
index 0000000..a11eb36
--- /dev/null
+++ b/network/connection_endpoint.gd
@@ -0,0 +1,12 @@
+class_name ConnectionEndpoint
+extends RefCounted
+
+var host: String = ""
+var port: int = 7777
+var normalized_display: String = ""
+var original_display: String = ""
+var error_message: String = ""
+
+
+func is_valid() -> bool:
+ return error_message.is_empty() and not host.is_empty() and port in range(1, 65536)
diff --git a/network/connection_endpoint.gd.uid b/network/connection_endpoint.gd.uid
new file mode 100644
index 0000000..c0103c2
--- /dev/null
+++ b/network/connection_endpoint.gd.uid
@@ -0,0 +1 @@
+uid://c0ssdkdd35qgf
diff --git a/network/connection_route.gd b/network/connection_route.gd
new file mode 100644
index 0000000..af9e625
--- /dev/null
+++ b/network/connection_route.gd
@@ -0,0 +1,24 @@
+class_name ConnectionRoute
+extends RefCounted
+
+enum Kind {
+ DIRECT,
+}
+
+var kind: Kind = Kind.DIRECT
+var direct_endpoint: ConnectionEndpoint
+var display_description: String = ""
+
+
+static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute:
+ var route := ConnectionRoute.new()
+ route.kind = Kind.DIRECT
+ route.direct_endpoint = endpoint
+ route.display_description = (
+ endpoint.normalized_display if endpoint != null else ""
+ )
+ return route
+
+
+func is_valid() -> bool:
+ return kind == Kind.DIRECT and direct_endpoint != null and direct_endpoint.is_valid()
diff --git a/network/connection_route.gd.uid b/network/connection_route.gd.uid
new file mode 100644
index 0000000..2f2ca91
--- /dev/null
+++ b/network/connection_route.gd.uid
@@ -0,0 +1 @@
+uid://dquhcebc57bg8
diff --git a/network/direct_enet_transport.gd b/network/direct_enet_transport.gd
new file mode 100644
index 0000000..067b86c
--- /dev/null
+++ b/network/direct_enet_transport.gd
@@ -0,0 +1,61 @@
+class_name DirectEnetTransport
+extends NetworkTransport
+
+
+func start_host(
+ port: int,
+ max_clients: int,
+ bind_address: String = "*",
+) -> Error:
+ disconnect_transport()
+ var enet_peer := ENetMultiplayerPeer.new()
+ if not bind_address.is_empty() and bind_address != "*":
+ enet_peer.set_bind_ip(bind_address)
+ var error: Error = enet_peer.create_server(
+ port, max_clients, NetworkProtocol.ENET_CHANNEL_COUNT
+ )
+ if error != OK:
+ transport_error.emit("Unable to host UDP port %d." % port)
+ return error
+ _peer = enet_peer
+ _route_description = "UDP *:%d" % port
+ return OK
+
+
+func connect_to_route(route: ConnectionRoute) -> Error:
+ disconnect_transport()
+ if route == null or not route.is_valid():
+ transport_error.emit("The direct connection route is invalid.")
+ return ERR_INVALID_PARAMETER
+ var endpoint: ConnectionEndpoint = route.direct_endpoint
+ var enet_peer := ENetMultiplayerPeer.new()
+ var error: Error = enet_peer.create_client(
+ endpoint.host, endpoint.port, NetworkProtocol.ENET_CHANNEL_COUNT
+ )
+ if error != OK:
+ transport_error.emit(
+ "Unable to connect to %s." % endpoint.normalized_display
+ )
+ return error
+ _peer = enet_peer
+ _route_description = endpoint.normalized_display
+ return OK
+
+
+func send_raw_packet(
+ destination_address: String,
+ destination_port: int,
+ packet: PackedByteArray,
+) -> bool:
+ var enet_peer := _peer as ENetMultiplayerPeer
+ if (
+ enet_peer == null
+ or enet_peer.host == null
+ or destination_address.is_empty()
+ or destination_port < 1
+ or destination_port > 65535
+ or packet.is_empty()
+ ):
+ return false
+ enet_peer.host.socket_send(destination_address, destination_port, packet)
+ return true
diff --git a/network/direct_enet_transport.gd.uid b/network/direct_enet_transport.gd.uid
new file mode 100644
index 0000000..09f5d1b
--- /dev/null
+++ b/network/direct_enet_transport.gd.uid
@@ -0,0 +1 @@
+uid://d4jn8ubxqkqa4
diff --git a/network/discovery_client.gd b/network/discovery_client.gd
new file mode 100644
index 0000000..bf15bbf
--- /dev/null
+++ b/network/discovery_client.gd
@@ -0,0 +1,1089 @@
+class_name DiscoveryClient
+extends Node
+
+signal rooms_updated(rooms: Array[Dictionary])
+signal browse_status_changed(message: String, is_error: bool)
+signal host_settings_changed(room_name: String, discoverable: bool)
+signal host_status_changed(message: String, is_error: bool)
+signal host_state_changed(state: int)
+signal public_join_prepared(endpoint: String)
+signal public_join_status_changed(message: String, is_error: bool)
+signal public_join_state_changed(state: int)
+
+const BASE_URL_SETTING: String = "network/discovery/base_url"
+const BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL"
+const SETTINGS_PATH: String = "user://network_discovery.cfg"
+const DEFAULT_ROOM_NAME: String = "Player's Server"
+const LEGACY_DEFAULT_ROOM_NAME: String = "NETfishing Room"
+const LEGACY_DEDICATED_DEFAULT_ROOM_NAME: String = (
+ "NETfishing Dedicated Server"
+)
+const ROOM_NAME_SUFFIX: String = "'s Server"
+const MAX_ROOM_NAME_LENGTH: int = 48
+const HEARTBEAT_INTERVAL_SECONDS: float = 15.0
+const REQUEST_TIMEOUT_SECONDS: float = 8.0
+const TRAVERSAL_POLL_INTERVAL_SECONDS: float = 1.0
+const JOIN_PROBE_INTERVAL_SECONDS: float = 0.35
+const TRAVERSAL_PACKET_PREFIX: String = "NETFISHING_TRAVERSAL_V1 "
+const UPNP_MAPPING_DURATION_SECONDS: int = 3600
+const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0
+const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0
+
+enum HostState {
+ UNAVAILABLE,
+ CLOSED,
+ PRIVATE,
+ OPENING_PORT,
+ REGISTERING,
+ VERIFYING,
+ PUBLIC,
+ ERROR,
+}
+
+enum PublicJoinState {
+ IDLE,
+ REQUESTING_ROUTE,
+ CONNECTING,
+ ERROR,
+}
+
+enum HostRequestKind {
+ NONE,
+ CREATE,
+ UPDATE,
+ DELETE,
+}
+
+var _session: NetworkSession
+var _base_url: String = ""
+var _room_name: String = DEFAULT_ROOM_NAME
+var _room_name_uses_default: bool = true
+var _host_settings_persistence_enabled: bool = true
+var _discoverable: bool = false
+var _host_status_message: String = ""
+var _host_status_is_error: bool = false
+var _host_state: HostState = HostState.CLOSED
+var _public_join_state: PublicJoinState = PublicJoinState.IDLE
+var _lease_room_id: String = ""
+var _lease_token: String = ""
+var _host_request_kind: HostRequestKind = HostRequestKind.NONE
+var _host_request_in_flight: bool = false
+var _host_sync_queued: bool = false
+var _browse_request_in_flight: bool = false
+var _host_request: HTTPRequest
+var _browse_request: HTTPRequest
+var _heartbeat: Timer
+var _traversal_request: HTTPRequest
+var _join_request: HTTPRequest
+var _traversal_timer: Timer
+var _join_probe_timer: Timer
+var _host_verified: bool = false
+var _traversal_host: String = ""
+var _traversal_port: int = 0
+var _host_verification_token: String = ""
+var _join_request_in_flight: bool = false
+var _pending_join_endpoint: String = ""
+var _pending_join_token: String = ""
+var _pending_join_room_id: String = ""
+var _preserve_pending_join_on_inactive: bool = false
+var _upnp_thread: Thread
+var _upnp_mapping_in_progress: bool = false
+var _upnp_operation_is_renewal: bool = false
+var _upnp_renew_timer: Timer
+var _upnp: UPNP
+var _upnp_mapped_port: int = 0
+
+
+func _ready() -> void:
+ _host_request = HTTPRequest.new()
+ _host_request.name = "HostLeaseRequest"
+ _host_request.timeout = REQUEST_TIMEOUT_SECONDS
+ add_child(_host_request)
+ _host_request.request_completed.connect(_on_host_request_completed)
+
+ _browse_request = HTTPRequest.new()
+ _browse_request.name = "RoomBrowseRequest"
+ _browse_request.timeout = REQUEST_TIMEOUT_SECONDS
+ add_child(_browse_request)
+ _browse_request.request_completed.connect(_on_browse_request_completed)
+
+ _traversal_request = HTTPRequest.new()
+ _traversal_request.name = "HostTraversalRequest"
+ _traversal_request.timeout = REQUEST_TIMEOUT_SECONDS
+ add_child(_traversal_request)
+ _traversal_request.request_completed.connect(
+ _on_traversal_request_completed
+ )
+
+ _join_request = HTTPRequest.new()
+ _join_request.name = "PublicJoinPreparationRequest"
+ _join_request.timeout = REQUEST_TIMEOUT_SECONDS
+ add_child(_join_request)
+ _join_request.request_completed.connect(_on_join_request_completed)
+
+ _heartbeat = Timer.new()
+ _heartbeat.name = "HostLeaseHeartbeat"
+ _heartbeat.wait_time = HEARTBEAT_INTERVAL_SECONDS
+ _heartbeat.one_shot = false
+ add_child(_heartbeat)
+ _heartbeat.timeout.connect(_on_heartbeat_timeout)
+
+ _upnp_renew_timer = Timer.new()
+ _upnp_renew_timer.name = "HostUPnPRenewal"
+ _upnp_renew_timer.one_shot = true
+ add_child(_upnp_renew_timer)
+ _upnp_renew_timer.timeout.connect(_on_upnp_renew_timer_timeout)
+
+ _traversal_timer = Timer.new()
+ _traversal_timer.name = "HostTraversalPoll"
+ _traversal_timer.wait_time = TRAVERSAL_POLL_INTERVAL_SECONDS
+ _traversal_timer.one_shot = false
+ add_child(_traversal_timer)
+ _traversal_timer.timeout.connect(_on_traversal_timer_timeout)
+
+ _join_probe_timer = Timer.new()
+ _join_probe_timer.name = "PublicJoinTraversalProbe"
+ _join_probe_timer.wait_time = JOIN_PROBE_INTERVAL_SECONDS
+ _join_probe_timer.one_shot = false
+ add_child(_join_probe_timer)
+ _join_probe_timer.timeout.connect(_send_pending_join_probe)
+ _load_settings()
+ _base_url = _configured_base_url()
+
+
+func setup(session: NetworkSession) -> void:
+ _session = session
+ if _room_name_uses_default:
+ _room_name = _default_room_name(_session.get_local_display_name())
+ _save_settings()
+ _session.set_session_display_name(_room_name)
+ if not _session.state_changed.is_connected(_on_session_state_changed):
+ _session.state_changed.connect(_on_session_state_changed)
+ if not _session.peer_count_changed.is_connected(_on_peer_count_changed):
+ _session.peer_count_changed.connect(_on_peer_count_changed)
+ if not _session.host_openness_changed.is_connected(_on_host_openness_changed):
+ _session.host_openness_changed.connect(_on_host_openness_changed)
+ host_settings_changed.emit(_room_name, _discoverable)
+ if not is_configured():
+ _set_host_state(HostState.UNAVAILABLE)
+ _set_host_status("Room discovery is not configured in this build.", true)
+ elif _session.is_open_host():
+ _set_host_state(HostState.PRIVATE)
+ _set_host_status("Room is open but unlisted.", false)
+ else:
+ _set_host_state(HostState.CLOSED)
+ _set_host_status("Open the game before listing it publicly.", false)
+
+
+func is_configured() -> bool:
+ return not _base_url.is_empty()
+
+
+func get_base_url() -> String:
+ return _base_url
+
+
+func set_base_url_override(value: String) -> bool:
+ var normalized: String = value.strip_edges()
+ while normalized.ends_with("/"):
+ normalized = normalized.left(normalized.length() - 1)
+ if not normalized.is_empty() and not (
+ normalized.begins_with("https://")
+ or normalized.begins_with("http://")
+ ):
+ return false
+ _base_url = normalized
+ return true
+
+
+func get_room_name() -> String:
+ return _room_name
+
+
+func is_discoverable() -> bool:
+ return _discoverable
+
+
+func get_host_status_message() -> String:
+ return _host_status_message
+
+
+func host_status_is_error() -> bool:
+ return _host_status_is_error
+
+
+func get_host_state() -> HostState:
+ return _host_state
+
+
+func get_public_join_state() -> PublicJoinState:
+ return _public_join_state
+
+
+func configure_dedicated_runtime(room_name: String) -> bool:
+ _host_settings_persistence_enabled = false
+ return set_room_name(room_name)
+
+
+func set_room_name(value: String) -> bool:
+ var cleaned: String = _sanitize_room_name(value)
+ if cleaned.is_empty():
+ _set_host_state(HostState.ERROR)
+ _set_host_status("Room name cannot be empty.", true)
+ return false
+ if cleaned == _room_name:
+ if _room_name_uses_default:
+ _room_name_uses_default = false
+ _save_settings()
+ return true
+ _room_name = cleaned
+ _room_name_uses_default = false
+ _save_settings()
+ if _session != null:
+ _session.set_session_display_name(_room_name)
+ host_settings_changed.emit(_room_name, _discoverable)
+ if _discoverable:
+ _synchronize_host_lease()
+ return true
+
+
+func set_discoverable(enabled: bool) -> bool:
+ if enabled and (
+ _session == null
+ or not _session.is_open_host()
+ or not is_configured()
+ ):
+ _set_host_state(HostState.ERROR)
+ _set_host_status(
+ "Open the game before enabling discovery."
+ if is_configured()
+ else "Room discovery is not configured in this build.",
+ true,
+ )
+ return false
+ if _discoverable == enabled:
+ return true
+ _discoverable = enabled
+ host_settings_changed.emit(_room_name, _discoverable)
+ if enabled:
+ _heartbeat.start()
+ _set_host_state(
+ HostState.REGISTERING
+ if _session.is_dedicated_host()
+ else HostState.OPENING_PORT
+ )
+ _set_host_status("Opening public room…", false)
+ if _session.is_dedicated_host():
+ _synchronize_host_lease()
+ else:
+ _begin_upnp_mapping(_session.get_host_port())
+ else:
+ _heartbeat.stop()
+ _upnp_renew_timer.stop()
+ _traversal_timer.stop()
+ _remove_host_lease()
+ _begin_upnp_cleanup()
+ _set_host_state(HostState.PRIVATE)
+ _set_host_status("Room is open but unlisted.", false)
+ return true
+
+
+func is_public_join_preparing() -> bool:
+ return _join_request_in_flight
+
+
+func preserve_public_join_for_session_switch() -> bool:
+ _preserve_pending_join_on_inactive = not _pending_join_token.is_empty()
+ return _preserve_pending_join_on_inactive
+
+
+func cancel_pending_public_join() -> void:
+ _preserve_pending_join_on_inactive = false
+ _set_public_join_state(PublicJoinState.IDLE)
+ _clear_pending_join()
+
+
+func prepare_public_join(room: Dictionary) -> bool:
+ if _join_request_in_flight:
+ return false
+ if _session == null or not is_configured():
+ _set_public_join_state(PublicJoinState.ERROR)
+ return false
+ if is_own_room(room):
+ _set_public_join_state(PublicJoinState.ERROR)
+ public_join_status_changed.emit(
+ "You are already hosting this room.", true
+ )
+ return false
+ var endpoint: String = room_endpoint(room)
+ var room_id: String = str(room.get("room_id", "")).strip_edges()
+ if endpoint.is_empty() or room_id.is_empty():
+ _set_public_join_state(PublicJoinState.ERROR)
+ return false
+ _pending_join_endpoint = endpoint
+ _pending_join_room_id = room_id
+ _pending_join_token = ""
+ var url: String = "%s/v1/rooms/%s/join-attempts" % [
+ _base_url,
+ room_id.uri_encode(),
+ ]
+ var error: Error = _join_request.request(
+ url,
+ PackedStringArray(),
+ HTTPClient.METHOD_POST,
+ "",
+ )
+ if error != OK:
+ _set_public_join_state(PublicJoinState.ERROR)
+ _clear_pending_join()
+ return false
+ _join_request_in_flight = true
+ _set_public_join_state(PublicJoinState.REQUESTING_ROUTE)
+ public_join_status_changed.emit("Opening a route to the room…", false)
+ return true
+
+
+func request_rooms() -> bool:
+ if not is_configured():
+ rooms_updated.emit([])
+ browse_status_changed.emit(
+ "Public room discovery is not configured in this build.", true
+ )
+ return false
+ if _browse_request_in_flight:
+ return true
+ var game_version: String = str(
+ ProjectSettings.get_setting("application/config/version", "unknown")
+ )
+ var url: String = "%s/v1/rooms?game_version=%s&protocol_version=%d" % [
+ _base_url,
+ game_version.uri_encode(),
+ NetworkProtocol.PROTOCOL_VERSION,
+ ]
+ var error: Error = _browse_request.request(url)
+ if error != OK:
+ rooms_updated.emit([])
+ browse_status_changed.emit("Could not request public rooms.", true)
+ return false
+ _browse_request_in_flight = true
+ browse_status_changed.emit("Looking for public rooms…", false)
+ return true
+
+
+func room_endpoint(room: Dictionary) -> String:
+ var address: String = str(room.get("address", "")).strip_edges()
+ var port: int = int(room.get("port", 0))
+ if address.is_empty() or port < 1 or port > 65535:
+ return ""
+ if ":" in address and not address.begins_with("["):
+ address = "[%s]" % address
+ return "%s:%d" % [address, port]
+
+
+func is_own_room(room: Dictionary) -> bool:
+ return (
+ not _lease_room_id.is_empty()
+ and str(room.get("room_id", "")) == _lease_room_id
+ )
+
+
+func _configured_base_url() -> String:
+ var value: String = OS.get_environment(BASE_URL_ENVIRONMENT).strip_edges()
+ if value.is_empty():
+ value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges()
+ while value.ends_with("/"):
+ value = value.left(value.length() - 1)
+ if not value.is_empty() and not (
+ value.begins_with("https://") or value.begins_with("http://")
+ ):
+ push_warning("Ignored invalid NETfishing discovery URL.")
+ return ""
+ return value
+
+
+func _on_heartbeat_timeout() -> void:
+ _synchronize_host_lease()
+ # NAT bindings can change during a long host session. Re-sending the signed
+ # probe lets discovery refresh the authoritative public endpoint in place.
+ _send_host_verification_probe()
+
+
+func _synchronize_host_lease() -> void:
+ if not _should_advertise():
+ if not _lease_room_id.is_empty():
+ _remove_host_lease()
+ return
+ if _host_request_in_flight:
+ _host_sync_queued = true
+ return
+ var method: HTTPClient.Method = HTTPClient.METHOD_POST
+ var url: String = "%s/v1/rooms" % _base_url
+ var headers := PackedStringArray(["Content-Type: application/json"])
+ _host_request_kind = HostRequestKind.CREATE
+ if not _lease_room_id.is_empty():
+ method = HTTPClient.METHOD_PUT
+ url = "%s/v1/rooms/%s" % [_base_url, _lease_room_id.uri_encode()]
+ headers.append("Authorization: Bearer %s" % _lease_token)
+ _host_request_kind = HostRequestKind.UPDATE
+ elif _host_state != HostState.REGISTERING:
+ _set_host_state(HostState.REGISTERING)
+ _start_host_request(url, headers, method, JSON.stringify(_host_payload()))
+
+
+func _remove_host_lease() -> void:
+ if _lease_room_id.is_empty():
+ return
+ if _host_request_in_flight:
+ _host_sync_queued = true
+ return
+ var url: String = "%s/v1/rooms/%s" % [
+ _base_url, _lease_room_id.uri_encode()
+ ]
+ var headers := PackedStringArray([
+ "Authorization: Bearer %s" % _lease_token,
+ ])
+ _host_request_kind = HostRequestKind.DELETE
+ _start_host_request(url, headers, HTTPClient.METHOD_DELETE, "")
+
+
+func _start_host_request(
+ url: String,
+ headers: PackedStringArray,
+ method: HTTPClient.Method,
+ body: String,
+) -> void:
+ var error: Error = _host_request.request(url, headers, method, body)
+ if error != OK:
+ _host_request_kind = HostRequestKind.NONE
+ _set_host_state(HostState.ERROR)
+ _set_host_status("Could not contact room discovery.", true)
+ return
+ _host_request_in_flight = true
+
+
+func _host_payload() -> Dictionary:
+ return {
+ "room_name": _room_name,
+ "port": _session.get_host_port(),
+ "current_players": _session.get_player_count(),
+ "max_players": _session.get_session_max_players(),
+ "game_version": str(
+ ProjectSettings.get_setting("application/config/version", "unknown")
+ ),
+ "protocol_version": NetworkProtocol.PROTOCOL_VERSION,
+ }
+
+
+func _should_advertise() -> bool:
+ return (
+ _discoverable
+ and is_configured()
+ and _session != null
+ and _session.is_open_host()
+ and (
+ not _upnp_mapping_in_progress
+ or _upnp_operation_is_renewal
+ )
+ )
+
+
+func _on_host_request_completed(
+ result: int,
+ response_code: int,
+ _headers: PackedStringArray,
+ body: PackedByteArray,
+) -> void:
+ var completed_kind: HostRequestKind = _host_request_kind
+ _host_request_kind = HostRequestKind.NONE
+ _host_request_in_flight = false
+ var response: Dictionary = _parse_response_dictionary(body)
+ var transport_ok: bool = result == HTTPRequest.RESULT_SUCCESS
+ match completed_kind:
+ HostRequestKind.CREATE:
+ if transport_ok and response_code == HTTPClient.RESPONSE_CREATED:
+ var room: Dictionary = response.get("room", {})
+ _lease_room_id = str(room.get("room_id", ""))
+ _lease_token = str(response.get("lease_token", ""))
+ if _lease_room_id.is_empty() or _lease_token.is_empty():
+ _clear_lease()
+ _set_host_state(HostState.ERROR)
+ _set_host_status("Discovery returned an invalid lease.", true)
+ else:
+ _apply_traversal_response(response)
+ _host_verified = bool(room.get("verified", false))
+ _traversal_timer.start()
+ _send_host_verification_probe()
+ _set_host_state(
+ HostState.PUBLIC
+ if _host_verified
+ else HostState.VERIFYING
+ )
+ _set_host_status(
+ "Room is listed publicly."
+ if _host_verified
+ else "Checking the public route…",
+ false,
+ )
+ else:
+ _set_host_state(HostState.ERROR)
+ _set_host_status(_request_failure(response), true)
+ HostRequestKind.UPDATE:
+ if transport_ok and response_code == HTTPClient.RESPONSE_OK:
+ var room: Dictionary = response.get("room", {})
+ _host_verified = bool(room.get("verified", false))
+ _set_host_state(
+ HostState.PUBLIC
+ if _host_verified
+ else HostState.VERIFYING
+ )
+ _set_host_status(
+ "Room is listed publicly."
+ if _host_verified
+ else "Checking the public route…",
+ false,
+ )
+ elif response_code in [
+ HTTPClient.RESPONSE_UNAUTHORIZED,
+ HTTPClient.RESPONSE_NOT_FOUND,
+ ]:
+ _clear_lease()
+ _host_sync_queued = true
+ else:
+ _set_host_state(HostState.ERROR)
+ _set_host_status(_request_failure(response), true)
+ HostRequestKind.DELETE:
+ _clear_lease()
+ if not transport_ok or response_code not in [
+ HTTPClient.RESPONSE_NO_CONTENT,
+ HTTPClient.RESPONSE_NOT_FOUND,
+ ]:
+ _set_host_state(HostState.ERROR)
+ _set_host_status(_request_failure(response), true)
+ _:
+ pass
+ if not _should_advertise() and not _lease_room_id.is_empty():
+ _host_sync_queued = true
+ if _host_sync_queued:
+ _host_sync_queued = false
+ call_deferred("_synchronize_host_lease")
+
+
+func _on_join_request_completed(
+ result: int,
+ response_code: int,
+ _headers: PackedStringArray,
+ body: PackedByteArray,
+) -> void:
+ _join_request_in_flight = false
+ var response: Dictionary = _parse_response_dictionary(body)
+ if (
+ result != HTTPRequest.RESULT_SUCCESS
+ or response_code != HTTPClient.RESPONSE_CREATED
+ ):
+ _set_public_join_state(PublicJoinState.ERROR)
+ public_join_status_changed.emit(_request_failure(response), true)
+ _clear_pending_join()
+ return
+ _pending_join_token = str(response.get("join_token", ""))
+ _apply_traversal_response(response)
+ if (
+ _pending_join_token.is_empty()
+ or _traversal_host.is_empty()
+ or _traversal_port < 1
+ ):
+ _set_public_join_state(PublicJoinState.ERROR)
+ public_join_status_changed.emit(
+ "Discovery returned an invalid traversal route.", true
+ )
+ _clear_pending_join()
+ return
+ _set_public_join_state(PublicJoinState.CONNECTING)
+ public_join_status_changed.emit("Connecting…", false)
+ public_join_prepared.emit(_pending_join_endpoint)
+
+
+func _on_traversal_timer_timeout() -> void:
+ if not _should_advertise() or _lease_room_id.is_empty():
+ _traversal_timer.stop()
+ return
+ if not _host_verified:
+ _send_host_verification_probe()
+ _synchronize_host_lease()
+ return
+ if _traversal_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
+ return
+ var url: String = "%s/v1/rooms/%s/join-attempts" % [
+ _base_url,
+ _lease_room_id.uri_encode(),
+ ]
+ var headers := PackedStringArray([
+ "Authorization: Bearer %s" % _lease_token,
+ ])
+ var error: Error = _traversal_request.request(url, headers)
+ if error != OK:
+ _set_host_state(HostState.ERROR)
+ _set_host_status("Could not check incoming public connections.", true)
+
+
+func _on_traversal_request_completed(
+ result: int,
+ response_code: int,
+ _headers: PackedStringArray,
+ body: PackedByteArray,
+) -> void:
+ if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
+ return
+ var response: Dictionary = _parse_response_dictionary(body)
+ var endpoints: Variant = response.get("endpoints", [])
+ if typeof(endpoints) != TYPE_ARRAY:
+ return
+ for value: Variant in endpoints:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var endpoint: Dictionary = value
+ var address: String = str(endpoint.get("address", ""))
+ var port: int = int(endpoint.get("port", 0))
+ if address.is_empty() or port < 1 or port > 65535:
+ continue
+ var punch: PackedByteArray = "NETFISHING_PUNCH_V1".to_utf8_buffer()
+ for _attempt: int in 3:
+ _session.send_traversal_packet(address, port, punch)
+
+
+func _apply_traversal_response(response: Dictionary) -> void:
+ var traversal: Variant = response.get("traversal", {})
+ if typeof(traversal) != TYPE_DICTIONARY:
+ return
+ var details: Dictionary = traversal
+ _traversal_host = str(details.get("host", "")).strip_edges()
+ _traversal_port = int(details.get("port", 0))
+ _host_verification_token = str(
+ details.get("verification_token", "")
+ )
+
+
+func _send_host_verification_probe() -> void:
+ if (
+ _session == null
+ or _lease_room_id.is_empty()
+ or _host_verification_token.is_empty()
+ ):
+ return
+ _send_traversal_probe({
+ "kind": "host",
+ "room_id": _lease_room_id,
+ "token": _host_verification_token,
+ })
+
+
+func _send_pending_join_probe() -> void:
+ if (
+ _session == null
+ or _pending_join_token.is_empty()
+ or _session.state not in [
+ NetworkSession.State.CONNECTING,
+ NetworkSession.State.AUTHENTICATING,
+ ]
+ ):
+ _join_probe_timer.stop()
+ return
+ _send_traversal_probe({
+ "kind": "join",
+ "room_id": _pending_join_room_id,
+ "token": _pending_join_token,
+ })
+
+
+func _send_traversal_probe(payload: Dictionary) -> void:
+ if _traversal_host.is_empty() or _traversal_port < 1:
+ return
+ var packet: PackedByteArray = (
+ TRAVERSAL_PACKET_PREFIX + JSON.stringify(payload)
+ ).to_utf8_buffer()
+ _session.send_traversal_packet(_traversal_host, _traversal_port, packet)
+
+
+func _begin_upnp_mapping(port: int, is_renewal: bool = false) -> void:
+ if _upnp_mapping_in_progress or port < 1:
+ if not is_renewal:
+ _synchronize_host_lease()
+ return
+ _upnp_mapping_in_progress = true
+ _upnp_operation_is_renewal = is_renewal
+ _upnp_thread = Thread.new()
+ var error: Error = _upnp_thread.start(
+ _configure_upnp_mapping.bind(port)
+ )
+ if error != OK:
+ _upnp_thread = null
+ _upnp_mapping_in_progress = false
+ _upnp_operation_is_renewal = false
+ if is_renewal:
+ _schedule_upnp_renewal(UPNP_RETRY_INTERVAL_SECONDS)
+ _synchronize_host_lease()
+
+
+func _configure_upnp_mapping(port: int) -> Dictionary:
+ var upnp := UPNP.new()
+ var discovery_result: int = upnp.discover()
+ if discovery_result != UPNP.UPNP_RESULT_SUCCESS:
+ return {"success": false, "upnp": upnp, "port": port}
+ var mapping_result: int = upnp.add_port_mapping(
+ port,
+ port,
+ "NETfishing",
+ "UDP",
+ UPNP_MAPPING_DURATION_SECONDS,
+ )
+ if mapping_result == UPNP.UPNP_RESULT_ONLY_PERMANENT_LEASE_SUPPORTED:
+ mapping_result = upnp.add_port_mapping(
+ port, port, "NETfishing", "UDP", 0
+ )
+ return {
+ "success": mapping_result == UPNP.UPNP_RESULT_SUCCESS,
+ "upnp": upnp,
+ "port": port,
+ }
+
+
+func _process(_delta: float) -> void:
+ if _upnp_thread == null or _upnp_thread.is_alive():
+ return
+ var was_renewal: bool = _upnp_operation_is_renewal
+ var result: Variant = _upnp_thread.wait_to_finish()
+ _upnp_thread = null
+ _upnp_mapping_in_progress = false
+ _upnp_operation_is_renewal = false
+ var mapping_succeeded: bool = false
+ if typeof(result) == TYPE_DICTIONARY:
+ var details: Dictionary = result
+ if bool(details.get("success", false)):
+ _upnp = details.get("upnp") as UPNP
+ _upnp_mapped_port = int(details.get("port", 0))
+ mapping_succeeded = true
+ if not _discoverable:
+ _begin_upnp_cleanup()
+ return
+ if mapping_succeeded:
+ _schedule_upnp_renewal(UPNP_RENEW_INTERVAL_SECONDS)
+ elif was_renewal:
+ _schedule_upnp_renewal(UPNP_RETRY_INTERVAL_SECONDS)
+ _synchronize_host_lease()
+
+
+func _schedule_upnp_renewal(delay_seconds: float) -> void:
+ if (
+ _upnp_mapped_port < 1
+ or not _discoverable
+ or _session == null
+ or _session.is_dedicated_host()
+ ):
+ _upnp_renew_timer.stop()
+ return
+ _upnp_renew_timer.start(delay_seconds)
+
+
+func _on_upnp_renew_timer_timeout() -> void:
+ if (
+ not _discoverable
+ or _session == null
+ or not _session.is_open_host()
+ or _session.is_dedicated_host()
+ or _upnp_mapped_port < 1
+ ):
+ return
+ _begin_upnp_mapping(_upnp_mapped_port, true)
+
+
+func _begin_upnp_cleanup() -> void:
+ _upnp_renew_timer.stop()
+ if _upnp == null or _upnp_mapped_port < 1:
+ return
+ _upnp.delete_port_mapping(_upnp_mapped_port, "UDP")
+ _upnp = null
+ _upnp_mapped_port = 0
+
+
+func _on_browse_request_completed(
+ result: int,
+ response_code: int,
+ _headers: PackedStringArray,
+ body: PackedByteArray,
+) -> void:
+ _browse_request_in_flight = false
+ var response: Dictionary = _parse_response_dictionary(body)
+ if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
+ rooms_updated.emit([])
+ browse_status_changed.emit(_request_failure(response), true)
+ return
+ var raw_rooms: Variant = response.get("rooms", [])
+ if typeof(raw_rooms) != TYPE_ARRAY:
+ rooms_updated.emit([])
+ browse_status_changed.emit("Discovery returned an invalid room list.", true)
+ return
+ var rooms: Array[Dictionary] = []
+ for value: Variant in raw_rooms:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var room: Dictionary = value
+ if _valid_public_room(room):
+ rooms.append(room.duplicate(true))
+ rooms_updated.emit(rooms)
+ browse_status_changed.emit(
+ "No public rooms are available."
+ if rooms.is_empty()
+ else "%d public room%s found." % [rooms.size(), "" if rooms.size() == 1 else "s"],
+ false,
+ )
+
+
+func _valid_public_room(room: Dictionary) -> bool:
+ var expected_version: String = str(
+ ProjectSettings.get_setting("application/config/version", "unknown")
+ )
+ return (
+ typeof(room.get("room_id")) == TYPE_STRING
+ and typeof(room.get("room_name")) == TYPE_STRING
+ and typeof(room.get("address")) == TYPE_STRING
+ and typeof(room.get("game_version")) == TYPE_STRING
+ and str(room.get("game_version")) == expected_version
+ and _valid_json_integer(room.get("port"), 1, 65535)
+ and _valid_json_integer(room.get("current_players"), 0, 128)
+ and _valid_json_integer(room.get("max_players"), 1, 128)
+ and int(room["current_players"]) <= int(room["max_players"])
+ and _valid_json_integer(
+ room.get("protocol_version"),
+ NetworkProtocol.PROTOCOL_VERSION,
+ NetworkProtocol.PROTOCOL_VERSION,
+ )
+ )
+
+
+func _valid_json_integer(value: Variant, minimum: int, maximum: int) -> bool:
+ if typeof(value) == TYPE_INT:
+ return int(value) >= minimum and int(value) <= maximum
+ if typeof(value) != TYPE_FLOAT:
+ return false
+ var number: float = float(value)
+ return (
+ is_finite(number)
+ and number == floor(number)
+ and number >= float(minimum)
+ and number <= float(maximum)
+ )
+
+
+func _parse_response_dictionary(body: PackedByteArray) -> Dictionary:
+ if body.is_empty():
+ return {}
+ var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
+ return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
+
+
+func _request_failure(response: Dictionary) -> String:
+ var error: Variant = response.get("error", {})
+ if typeof(error) == TYPE_DICTIONARY:
+ var details := error as Dictionary
+ if str(details.get("code", "")) == "game_version_mismatch":
+ var required_version: String = str(
+ details.get("required_game_version", "")
+ ).strip_edges()
+ if not required_version.is_empty():
+ return _discovery_version_mismatch_message(required_version)
+ var message: String = str(details.get("message", "")).strip_edges()
+ if not message.is_empty():
+ return message
+ return "Room discovery is temporarily unavailable."
+
+
+func _discovery_version_mismatch_message(required_version: String) -> String:
+ var local_version: String = NetworkProtocol.game_version()
+ var rejection: NetworkProtocol.RejectionCode = (
+ NetworkProtocol.game_version_rejection(
+ local_version,
+ required_version,
+ )
+ )
+ match rejection:
+ NetworkProtocol.RejectionCode.CLIENT_OUTDATED:
+ return (
+ "Your NETfishing version is out of date. Update to %s to "
+ + "enable public discovery. This room will not be listed "
+ + "until you update."
+ ) % required_version
+ NetworkProtocol.RejectionCode.SERVER_OUTDATED:
+ return (
+ "Public discovery is still on NETfishing %s. This room "
+ + "will not be listed until discovery is updated for %s."
+ ) % [required_version, local_version]
+ _:
+ return (
+ "Public discovery requires NETfishing %s. This room will "
+ + "not be listed until both versions match."
+ ) % required_version
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty():
+ _preserve_pending_join_on_inactive = false
+ _set_public_join_state(PublicJoinState.CONNECTING)
+ _join_probe_timer.start()
+ call_deferred("_send_pending_join_probe")
+ elif state in [
+ NetworkSession.State.JOINED_CLIENT,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _preserve_pending_join_on_inactive = false
+ _set_public_join_state(PublicJoinState.IDLE)
+ _join_probe_timer.stop()
+ _clear_pending_join()
+ elif state == NetworkSession.State.INACTIVE:
+ if (
+ _preserve_pending_join_on_inactive
+ and not _pending_join_token.is_empty()
+ ):
+ _preserve_pending_join_on_inactive = false
+ else:
+ _set_public_join_state(PublicJoinState.IDLE)
+ _join_probe_timer.stop()
+ _clear_pending_join()
+ elif state == NetworkSession.State.CONNECTION_FAILED:
+ _preserve_pending_join_on_inactive = false
+ _set_public_join_state(PublicJoinState.ERROR)
+ _join_probe_timer.stop()
+ _clear_pending_join()
+ if state == NetworkSession.State.OPEN_HOST:
+ if _discoverable:
+ _heartbeat.start()
+ _synchronize_host_lease()
+ else:
+ _set_host_state(HostState.PRIVATE)
+ _set_host_status("Room is open but unlisted.", false)
+ return
+ if state in [
+ NetworkSession.State.PRIVATE_HOST,
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ if _discoverable:
+ _discoverable = false
+ host_settings_changed.emit(_room_name, false)
+ _heartbeat.stop()
+ _upnp_renew_timer.stop()
+ _remove_host_lease()
+ _begin_upnp_cleanup()
+ if state == NetworkSession.State.PRIVATE_HOST:
+ _set_host_state(HostState.CLOSED)
+ _set_host_status("Open the game before listing it publicly.", false)
+ else:
+ _set_host_state(HostState.CLOSED)
+
+
+func _on_host_openness_changed(is_open: bool) -> void:
+ if not is_open and _discoverable:
+ set_discoverable(false)
+
+
+func _on_peer_count_changed(_player_count: int, _max_players: int) -> void:
+ if _discoverable:
+ _synchronize_host_lease()
+
+
+func _clear_lease() -> void:
+ _lease_room_id = ""
+ _lease_token = ""
+ _host_verified = false
+ _host_verification_token = ""
+ _traversal_timer.stop()
+
+
+func _clear_pending_join() -> void:
+ _pending_join_endpoint = ""
+ _pending_join_token = ""
+ _pending_join_room_id = ""
+ _join_probe_timer.stop()
+
+
+func _exit_tree() -> void:
+ _upnp_renew_timer.stop()
+ if _upnp_thread != null:
+ var result: Variant = _upnp_thread.wait_to_finish()
+ _upnp_thread = null
+ if typeof(result) == TYPE_DICTIONARY:
+ var details: Dictionary = result
+ if bool(details.get("success", false)):
+ _upnp = details.get("upnp") as UPNP
+ _upnp_mapped_port = int(details.get("port", 0))
+ _begin_upnp_cleanup()
+
+
+func _set_host_status(message: String, is_error: bool) -> void:
+ _host_status_message = message
+ _host_status_is_error = is_error
+ host_status_changed.emit(message, is_error)
+
+
+func _set_host_state(state: HostState) -> void:
+ if _host_state == state:
+ return
+ _host_state = state
+ host_state_changed.emit(int(state))
+
+
+func _set_public_join_state(state: PublicJoinState) -> void:
+ if _public_join_state == state:
+ return
+ _public_join_state = state
+ public_join_state_changed.emit(int(state))
+
+
+func _sanitize_room_name(value: String) -> String:
+ var cleaned: String = value.strip_edges().replace("\n", " ").replace("\r", " ")
+ cleaned = cleaned.replace("\t", " ")
+ while " " in cleaned:
+ cleaned = cleaned.replace(" ", " ")
+ return cleaned.left(MAX_ROOM_NAME_LENGTH)
+
+
+func _default_room_name(player_name: String) -> String:
+ var cleaned_name: String = player_name.strip_edges()
+ if cleaned_name.is_empty():
+ return DEFAULT_ROOM_NAME
+ return "%s%s" % [
+ cleaned_name.left(MAX_ROOM_NAME_LENGTH - ROOM_NAME_SUFFIX.length()),
+ ROOM_NAME_SUFFIX,
+ ]
+
+
+func _load_settings() -> void:
+ var config := ConfigFile.new()
+ if config.load(SETTINGS_PATH) == OK:
+ var saved_name: String = _sanitize_room_name(
+ str(config.get_value("host", "room_name", DEFAULT_ROOM_NAME))
+ )
+ if not saved_name.is_empty():
+ _room_name = saved_name
+ _room_name_uses_default = bool(config.get_value(
+ "host",
+ "room_name_uses_default",
+ saved_name in [
+ DEFAULT_ROOM_NAME,
+ LEGACY_DEFAULT_ROOM_NAME,
+ LEGACY_DEDICATED_DEFAULT_ROOM_NAME,
+ ],
+ ))
+
+
+func _save_settings() -> void:
+ if not _host_settings_persistence_enabled:
+ return
+ var config := ConfigFile.new()
+ config.set_value("host", "room_name", _room_name)
+ config.set_value(
+ "host", "room_name_uses_default", _room_name_uses_default
+ )
+ var error: Error = config.save(SETTINGS_PATH)
+ if error != OK:
+ push_warning("Could not save the local NETfishing room name.")
diff --git a/network/discovery_client.gd.uid b/network/discovery_client.gd.uid
new file mode 100644
index 0000000..eebfd6a
--- /dev/null
+++ b/network/discovery_client.gd.uid
@@ -0,0 +1 @@
+uid://d0jv8n2nfqia0
diff --git a/network/endpoint_parser.gd b/network/endpoint_parser.gd
new file mode 100644
index 0000000..fdaebbe
--- /dev/null
+++ b/network/endpoint_parser.gd
@@ -0,0 +1,69 @@
+class_name EndpointParser
+extends RefCounted
+
+const DEFAULT_PORT: int = 7777
+
+
+static func parse(value: String, default_port: int = DEFAULT_PORT) -> ConnectionEndpoint:
+ var result := ConnectionEndpoint.new()
+ result.original_display = value.strip_edges()
+ result.port = default_port
+ if default_port < 1 or default_port > 65535:
+ result.error_message = "The configured default port is invalid."
+ return result
+ var input: String = result.original_display
+ if input.is_empty():
+ result.error_message = "Enter a hostname or IP address."
+ return result
+
+ var host: String = ""
+ var port_text: String = ""
+ if input.begins_with("["):
+ var closing: int = input.find("]")
+ if closing < 0:
+ result.error_message = "The IPv6 address is missing a closing bracket."
+ return result
+ host = input.substr(1, closing - 1).strip_edges()
+ var suffix: String = input.substr(closing + 1).strip_edges()
+ if not suffix.is_empty():
+ if not suffix.begins_with(":") or suffix.length() == 1:
+ result.error_message = "Use [IPv6]:port for an IPv6 port."
+ return result
+ port_text = suffix.substr(1).strip_edges()
+ elif input.contains("[") or input.contains("]"):
+ result.error_message = "IPv6 brackets are malformed."
+ return result
+ else:
+ var colon_count: int = input.count(":")
+ if colon_count == 1:
+ var separator: int = input.rfind(":")
+ host = input.substr(0, separator).strip_edges()
+ port_text = input.substr(separator + 1).strip_edges()
+ else:
+ # Zero colons is a hostname/IPv4 host. More than one is a raw
+ # IPv6 host; an explicit port for it must use brackets.
+ host = input.strip_edges()
+
+ if host.is_empty():
+ result.error_message = "The hostname or IP address is empty."
+ return result
+ if host.contains(" ") or host.contains("\t") or host.contains("\n"):
+ result.error_message = "The hostname or IP address contains whitespace."
+ return result
+ if not port_text.is_empty():
+ if not port_text.is_valid_int():
+ result.error_message = "The port must be a number from 1 to 65535."
+ return result
+ result.port = int(port_text)
+ if result.port < 1 or result.port > 65535:
+ result.error_message = "The port must be from 1 to 65535."
+ return result
+
+ result.host = host.to_lower()
+ var is_ipv6: bool = result.host.contains(":")
+ result.normalized_display = (
+ "[%s]:%d" % [result.host, result.port]
+ if is_ipv6
+ else "%s:%d" % [result.host, result.port]
+ )
+ return result
diff --git a/network/endpoint_parser.gd.uid b/network/endpoint_parser.gd.uid
new file mode 100644
index 0000000..6b14810
--- /dev/null
+++ b/network/endpoint_parser.gd.uid
@@ -0,0 +1 @@
+uid://2drengcleq7e
diff --git a/network/host_ban_store.gd b/network/host_ban_store.gd
new file mode 100644
index 0000000..f8c4bf9
--- /dev/null
+++ b/network/host_ban_store.gd
@@ -0,0 +1,138 @@
+class_name HostBanStore
+extends Node
+
+signal bans_changed
+
+const FORMAT_VERSION := 1
+const MAX_BANS := 500
+
+var _namespaces: Dictionary = {}
+var _loaded := false
+var _write_blocked := false
+var _store_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _store_path = path
+ _data_root = data_root
+
+
+func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool:
+ _ensure_loaded()
+ return Dictionary(_namespaces.get(host_fingerprint, {})).has(target_fingerprint)
+
+
+func ban(
+ host_fingerprint: String,
+ target_fingerprint: String,
+ display_name: String,
+) -> bool:
+ if (
+ not NetworkIdentityCrypto.valid_fingerprint(host_fingerprint)
+ or not NetworkIdentityCrypto.valid_fingerprint(target_fingerprint)
+ or not NetworkProfilePreferences.is_valid_display_name(display_name)
+ ):
+ return false
+ _ensure_loaded()
+ if _write_blocked:
+ return false
+ var previous_namespace: Dictionary = Dictionary(
+ _namespaces.get(host_fingerprint, {})
+ ).duplicate(true)
+ var records: Dictionary = _namespaces.get(host_fingerprint, {})
+ records[target_fingerprint] = {
+ "host_fingerprint": host_fingerprint,
+ "target_fingerprint": target_fingerprint,
+ "last_known_display_name": display_name,
+ "banned_unix": int(Time.get_unix_time_from_system()),
+ "reason": "host_ban",
+ }
+ while records.size() > MAX_BANS:
+ records.erase(records.keys().front())
+ _namespaces[host_fingerprint] = records
+ if not _save():
+ _namespaces[host_fingerprint] = previous_namespace
+ return false
+ bans_changed.emit()
+ return true
+
+
+func unban(host_fingerprint: String, target_fingerprint: String) -> bool:
+ _ensure_loaded()
+ if _write_blocked:
+ return false
+ var previous_namespace: Dictionary = Dictionary(
+ _namespaces.get(host_fingerprint, {})
+ ).duplicate(true)
+ var records: Dictionary = _namespaces.get(host_fingerprint, {})
+ records.erase(target_fingerprint)
+ _namespaces[host_fingerprint] = records
+ if not _save():
+ _namespaces[host_fingerprint] = previous_namespace
+ return false
+ bans_changed.emit()
+ return true
+
+
+func get_bans(host_fingerprint: String) -> Array[Dictionary]:
+ _ensure_loaded()
+ var result: Array[Dictionary] = []
+ for value: Dictionary in Dictionary(_namespaces.get(host_fingerprint, {})).values():
+ result.append(value.duplicate(true))
+ result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ return str(a["last_known_display_name"]).naturalnocasecmp_to(
+ str(b["last_known_display_name"])
+ ) < 0
+ )
+ return result
+
+
+func _ensure_loaded() -> void:
+ if _loaded:
+ return
+ _loaded = true
+ if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
+ return
+ var file := FileAccess.open(_store_path, FileAccess.READ)
+ if file == null:
+ return
+ var json := JSON.new()
+ if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return
+ var data: Dictionary = json.data
+ if data.get("format_version") != FORMAT_VERSION:
+ _write_blocked = true
+ return
+ for value: Variant in data.get("records", []):
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var record: Dictionary = value
+ var host := str(record.get("host_fingerprint", ""))
+ var target := str(record.get("target_fingerprint", ""))
+ if not NetworkIdentityCrypto.valid_fingerprint(host) or not NetworkIdentityCrypto.valid_fingerprint(target):
+ continue
+ var records: Dictionary = _namespaces.get(host, {})
+ records[target] = record.duplicate(true)
+ _namespaces[host] = records
+ _expected_hash = PortableFileGuard.hash_file(_store_path)
+
+
+func _save() -> bool:
+ var values: Array = []
+ for records: Dictionary in _namespaces.values():
+ values.append_array(records.values())
+ var bytes := JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "records": values,
+ }, "\t").to_utf8_buffer()
+ var result := PortableFileGuard.write_guarded(
+ _store_path, bytes, _expected_hash, _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
diff --git a/network/host_ban_store.gd.uid b/network/host_ban_store.gd.uid
new file mode 100644
index 0000000..54c03fc
--- /dev/null
+++ b/network/host_ban_store.gd.uid
@@ -0,0 +1 @@
+uid://c71hostbans000001
diff --git a/network/host_identity_store.gd b/network/host_identity_store.gd
new file mode 100644
index 0000000..a45dd1c
--- /dev/null
+++ b/network/host_identity_store.gd
@@ -0,0 +1,6 @@
+class_name HostIdentityStore
+extends "res://network/local_signing_identity_store.gd"
+
+
+func _ready() -> void:
+ configure("host_identity")
diff --git a/network/host_identity_store.gd.uid b/network/host_identity_store.gd.uid
new file mode 100644
index 0000000..12d7557
--- /dev/null
+++ b/network/host_identity_store.gd.uid
@@ -0,0 +1 @@
+uid://dtyoh0v77x52f
diff --git a/network/identity_backup_service.gd b/network/identity_backup_service.gd
new file mode 100644
index 0000000..ff7396c
--- /dev/null
+++ b/network/identity_backup_service.gd
@@ -0,0 +1,213 @@
+class_name IdentityBackupService
+extends Node
+
+signal operation_finished(success: bool, message: String)
+
+const MAGIC := "NETFISHING_IDENTITY_BACKUP"
+const FORMAT_VERSION := 1
+const MAX_BACKUP_BYTES := 256 * 1024
+const MIN_PASSPHRASE_LENGTH := 12
+const MAX_PASSPHRASE_LENGTH := 256
+
+var _data_root: PlayerDataRoot
+var _player_identity: PlayerIdentityStore
+var _host_identity: HostIdentityStore
+
+
+func setup(
+ data_root: PlayerDataRoot,
+ player_identity: PlayerIdentityStore,
+ host_identity: HostIdentityStore,
+) -> void:
+ _data_root = data_root
+ _player_identity = player_identity
+ _host_identity = host_identity
+
+
+func default_export_path(identity_type: String) -> String:
+ var store: LocalSigningIdentityStore = _store(identity_type)
+ if store == null:
+ return ""
+ if not store.is_ready() and not store.load_or_create():
+ return ""
+ var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
+ return _data_root.identity_backup_directory().path_join(
+ "%s-%s-%s.nfidentity" % [
+ identity_type,
+ NetworkIdentityCrypto.compact_suffix(store.fingerprint),
+ timestamp,
+ ]
+ )
+
+
+func export_backup(
+ identity_type: String,
+ path: String,
+ passphrase: String,
+ confirmation: String,
+) -> bool:
+ if not _valid_passphrase(passphrase) or passphrase != confirmation:
+ return _finish(false, "Passphrases must match and contain at least 12 characters.")
+ if FileAccess.file_exists(path):
+ return _finish(false, "Choose a new backup filename.")
+ var store: LocalSigningIdentityStore = _store(identity_type)
+ if store == null or (not store.is_ready() and not store.load_or_create()):
+ return _finish(false, "The active identity is unavailable.")
+ var material: Dictionary = store.export_identity_material()
+ var proof: PackedByteArray = store.sign("identity_backup_self_test", [
+ identity_type, material["fingerprint"],
+ ])
+ var envelope: Dictionary = {
+ "magic": MAGIC,
+ "format_version": FORMAT_VERSION,
+ "identity_type": identity_type,
+ "algorithm": NetworkIdentityCrypto.ALGORITHM,
+ "private_pem": material["private_pem"],
+ "public_pem": material["public_pem"],
+ "fingerprint": material["fingerprint"],
+ "created_at_unix": int(Time.get_unix_time_from_system()),
+ "source_device_id": _data_root.device_id,
+ "self_signature": Marshalls.raw_to_base64(proof),
+ }
+ DirAccess.make_dir_recursive_absolute(path.get_base_dir())
+ var file: FileAccess = FileAccess.open_encrypted_with_pass(
+ path, FileAccess.WRITE, passphrase
+ )
+ if file == null:
+ return _finish(false, "Could not write this identity backup.")
+ file.store_string(JSON.stringify(envelope))
+ file.flush()
+ var ok: bool = file.get_error() == OK
+ file.close()
+ if not ok:
+ return _finish(false, "Could not write this identity backup.")
+ var verified: Dictionary = inspect_backup(path, passphrase, identity_type)
+ if not bool(verified.get("ok", false)):
+ DirAccess.remove_absolute(path)
+ return _finish(false, "Could not verify this identity backup.")
+ return _finish(true, "Encrypted identity backup created.")
+
+
+func inspect_backup(
+ path: String,
+ passphrase: String,
+ expected_type: String,
+) -> Dictionary:
+ if (
+ not _valid_passphrase(passphrase)
+ or not FileAccess.file_exists(path)
+ or FileAccess.get_size(path) > MAX_BACKUP_BYTES
+ ):
+ return {"ok": false}
+ var file: FileAccess = FileAccess.open_encrypted_with_pass(
+ path, FileAccess.READ, passphrase
+ )
+ if file == null:
+ return {"ok": false}
+ var text: String = file.get_as_text()
+ file.close()
+ var json: JSON = JSON.new()
+ if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return {"ok": false}
+ var data: Dictionary = json.data
+ if (
+ data.get("magic") != MAGIC
+ or data.get("format_version") != FORMAT_VERSION
+ or data.get("identity_type") != expected_type
+ or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
+ ):
+ return {"ok": false}
+ var private_pem: String = str(data.get("private_pem", ""))
+ var public_pem: String = NetworkIdentityCrypto.normalize_public_pem(
+ str(data.get("public_pem", ""))
+ )
+ var fingerprint: String = str(data.get("fingerprint", ""))
+ if (
+ private_pem.length() > 128 * 1024
+ or public_pem.length() > 32 * 1024
+ or NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
+ ):
+ return {"ok": false}
+ var key: CryptoKey = CryptoKey.new()
+ if key.load_from_string(private_pem) != OK:
+ return {"ok": false}
+ var signature: PackedByteArray = Marshalls.base64_to_raw(
+ str(data.get("self_signature", ""))
+ )
+ if not NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(public_pem),
+ "identity_backup_self_test",
+ [expected_type, fingerprint],
+ signature,
+ ):
+ return {"ok": false}
+ var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields(
+ key, "identity_import_self_test", [fingerprint]
+ )
+ if not NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(public_pem),
+ "identity_import_self_test",
+ [fingerprint],
+ fresh,
+ ):
+ return {"ok": false}
+ return {
+ "ok": true,
+ "identity_type": expected_type,
+ "fingerprint": fingerprint,
+ "private_pem": private_pem,
+ "public_pem": public_pem,
+ }
+
+
+func import_backup(
+ identity_type: String,
+ path: String,
+ passphrase: String,
+ confirmed_replacement: bool,
+) -> Dictionary:
+ var inspected: Dictionary = inspect_backup(path, passphrase, identity_type)
+ if not bool(inspected.get("ok", false)):
+ _finish(false, "Could not open this identity backup.")
+ return {"ok": false}
+ var store: LocalSigningIdentityStore = _store(identity_type)
+ var incoming: String = str(inspected["fingerprint"])
+ if store.fingerprint == incoming:
+ _finish(true, "This identity is already active.")
+ return {"ok": true, "same": true}
+ if not confirmed_replacement:
+ return {
+ "ok": false,
+ "requires_confirmation": true,
+ "current_fingerprint": store.fingerprint,
+ "incoming_fingerprint": incoming,
+ }
+ var result: Dictionary = store.install_identity_material(
+ str(inspected["private_pem"]),
+ str(inspected["public_pem"]),
+ incoming,
+ )
+ _finish(
+ bool(result.get("ok", false)),
+ "Identity imported. Restart NETfishing before multiplayer."
+ if bool(result.get("ok", false))
+ else "Could not install this identity backup.",
+ )
+ return result
+
+
+func _store(identity_type: String) -> LocalSigningIdentityStore:
+ if identity_type == "player":
+ return _player_identity
+ if identity_type == "host":
+ return _host_identity
+ return null
+
+
+func _valid_passphrase(value: String) -> bool:
+ return value.length() >= MIN_PASSPHRASE_LENGTH and value.length() <= MAX_PASSPHRASE_LENGTH
+
+
+func _finish(success: bool, message: String) -> bool:
+ operation_finished.emit(success, message)
+ return success
diff --git a/network/identity_backup_service.gd.uid b/network/identity_backup_service.gd.uid
new file mode 100644
index 0000000..05f1de0
--- /dev/null
+++ b/network/identity_backup_service.gd.uid
@@ -0,0 +1 @@
+uid://bu5q4a8jfvxkf
diff --git a/network/known_player_store.gd b/network/known_player_store.gd
new file mode 100644
index 0000000..0c6b859
--- /dev/null
+++ b/network/known_player_store.gd
@@ -0,0 +1,121 @@
+class_name KnownPlayerStore
+extends Node
+
+const FORMAT_VERSION: int = 1
+const MAX_RECORDS: int = 500
+
+var _records: Dictionary = {}
+var _loaded: bool = false
+var _write_blocked: bool = false
+var _store_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _store_path = path
+ _data_root = data_root
+
+
+func observe(fingerprint: String, display_name: String) -> String:
+ _ensure_loaded()
+ if (
+ not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+ or not NetworkProfilePreferences.is_valid_display_name(display_name)
+ ):
+ return ""
+ var now := int(Time.get_unix_time_from_system())
+ var status := "New identity"
+ var record: Dictionary = _records.get(fingerprint, {})
+ if not record.is_empty():
+ status = "Known player"
+ else:
+ for value: Dictionary in _records.values():
+ if str(value.get("last_known_display_name", "")).nocasecmp_to(display_name) == 0:
+ status = "New identity using a familiar name"
+ break
+ record = {
+ "fingerprint": fingerprint,
+ "first_seen_unix": now,
+ "locally_verified": false,
+ }
+ record["last_known_display_name"] = display_name
+ record["last_seen_unix"] = now
+ _records[fingerprint] = record
+ _bound_records()
+ _save()
+ return status
+
+
+func get_record(fingerprint: String) -> Dictionary:
+ _ensure_loaded()
+ return Dictionary(_records.get(fingerprint, {})).duplicate(true)
+
+
+func identity_status(fingerprint: String, display_name: String) -> String:
+ _ensure_loaded()
+ var record: Dictionary = _records.get(fingerprint, {})
+ if not record.is_empty():
+ return "Known player"
+ for value: Dictionary in _records.values():
+ if str(value.get("last_known_display_name", "")).nocasecmp_to(display_name) == 0:
+ return "New identity using a familiar name"
+ return "New identity"
+
+
+func _ensure_loaded() -> void:
+ if _loaded:
+ return
+ _loaded = true
+ if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
+ return
+ var file := FileAccess.open(_store_path, FileAccess.READ)
+ if file == null:
+ return
+ var json := JSON.new()
+ if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return
+ var data: Dictionary = json.data
+ if data.get("format_version") != FORMAT_VERSION:
+ _write_blocked = true
+ return
+ if typeof(data.get("records")) != TYPE_ARRAY:
+ return
+ for value: Variant in data["records"]:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var record: Dictionary = value
+ var fingerprint := str(record.get("fingerprint", ""))
+ if NetworkIdentityCrypto.valid_fingerprint(fingerprint):
+ _records[fingerprint] = record.duplicate(true)
+ _expected_hash = PortableFileGuard.hash_file(_store_path)
+
+
+func _save() -> bool:
+ if _write_blocked:
+ return false
+ var bytes := JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "records": _records.values(),
+ }, "\t").to_utf8_buffer()
+ var result := PortableFileGuard.write_guarded(
+ _store_path, bytes, _expected_hash, _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
+
+
+func _bound_records() -> void:
+ while _records.size() > MAX_RECORDS:
+ var oldest_key := ""
+ var oldest_time := 9223372036854775807
+ for key: String in _records:
+ var seen := int(_records[key].get("last_seen_unix", 0))
+ if seen < oldest_time:
+ oldest_time = seen
+ oldest_key = key
+ _records.erase(oldest_key)
diff --git a/network/known_player_store.gd.uid b/network/known_player_store.gd.uid
new file mode 100644
index 0000000..85c44ec
--- /dev/null
+++ b/network/known_player_store.gd.uid
@@ -0,0 +1 @@
+uid://dossvvvh4yuid
diff --git a/network/local_signing_identity_store.gd b/network/local_signing_identity_store.gd
new file mode 100644
index 0000000..a8ac7a0
--- /dev/null
+++ b/network/local_signing_identity_store.gd
@@ -0,0 +1,291 @@
+class_name LocalSigningIdentityStore
+extends Node
+
+const FORMAT_VERSION: int = 1
+
+var fingerprint: String = ""
+var public_pem: String = ""
+var error_message: String = ""
+var _private_key: CryptoKey
+var _prefix: String = ""
+var _allow_generation: bool = true
+var _storage_directory: String = ""
+
+
+func configure(
+ prefix: String,
+ allow_generation: bool = true,
+ storage_directory: String = "",
+) -> void:
+ _prefix = prefix
+ _allow_generation = allow_generation
+ _storage_directory = storage_directory.strip_edges()
+
+
+func load_or_create() -> bool:
+ if _prefix.is_empty():
+ error_message = "Identity storage is not configured."
+ return false
+ var key_path := _path(".key")
+ var public_path := _path(".pub")
+ var metadata_path := _path(".json")
+ var any_exists := (
+ FileAccess.file_exists(key_path)
+ or FileAccess.file_exists(public_path)
+ or FileAccess.file_exists(metadata_path)
+ )
+ if any_exists:
+ return _load_existing()
+ if not _allow_generation:
+ return false
+ return _generate_new()
+
+
+func is_ready() -> bool:
+ return _private_key != null and NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+
+
+func sign(domain: String, fields: Array) -> PackedByteArray:
+ return NetworkIdentityCrypto.sign_fields(_private_key, domain, fields)
+
+
+func verify(domain: String, fields: Array, signature: PackedByteArray) -> bool:
+ return NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(public_pem),
+ domain,
+ fields,
+ signature,
+ )
+
+
+func export_identity_material() -> Dictionary:
+ if not is_ready():
+ return {}
+ return {
+ "private_pem": _private_key.save_to_string(),
+ "public_pem": public_pem,
+ "fingerprint": fingerprint,
+ }
+
+
+func install_identity_material(
+ private_pem: String,
+ public_value: String,
+ expected_fingerprint: String,
+) -> Dictionary:
+ var normalized_public := NetworkIdentityCrypto.normalize_public_pem(public_value)
+ var derived := NetworkIdentityCrypto.fingerprint_public_pem(normalized_public)
+ var key := CryptoKey.new()
+ if (
+ derived != expected_fingerprint
+ or key.load_from_string(private_pem) != OK
+ ):
+ return {"ok": false}
+ var probe := NetworkIdentityCrypto.sign_fields(
+ key, "identity_import_self_test", [derived]
+ )
+ if not NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(normalized_public),
+ "identity_import_self_test",
+ [derived],
+ probe,
+ ):
+ return {"ok": false}
+ if derived == fingerprint:
+ return {"ok": true, "same": true, "archive_path": ""}
+ var had_active_identity := is_ready()
+ var archive := _archive_current_identity() if had_active_identity else ""
+ if had_active_identity and archive.is_empty():
+ return {"ok": false}
+ var metadata := {
+ "format_version": FORMAT_VERSION,
+ "algorithm": NetworkIdentityCrypto.ALGORITHM,
+ "fingerprint": derived,
+ "created_at_unix": int(Time.get_unix_time_from_system()),
+ }
+ if (
+ not _write_atomic(_path(".key"), private_pem)
+ or not _write_atomic(_path(".pub"), normalized_public)
+ or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t"))
+ ):
+ if not archive.is_empty():
+ _restore_archive(archive)
+ _load_existing()
+ return {"ok": false}
+ FileAccess.set_unix_permissions(_path(".key"), 384)
+ if not _load_existing() or fingerprint != derived:
+ if not archive.is_empty():
+ _restore_archive(archive)
+ _load_existing()
+ return {"ok": false}
+ return {"ok": true, "same": false, "archive_path": archive}
+
+
+func _generate_new() -> bool:
+ var key := Crypto.new().generate_rsa(3072)
+ if key == null:
+ error_message = "A signing identity could not be generated."
+ return false
+ var private_text := key.save_to_string()
+ var public_text := NetworkIdentityCrypto.normalize_public_pem(
+ key.save_to_string(true)
+ )
+ var derived := NetworkIdentityCrypto.fingerprint_public_pem(public_text)
+ var probe := NetworkIdentityCrypto.sign_fields(key, "identity_self_test", [derived])
+ var public_key := NetworkIdentityCrypto.load_public_key(public_text)
+ if (
+ not NetworkIdentityCrypto.valid_fingerprint(derived)
+ or not NetworkIdentityCrypto.verify_fields(
+ public_key, "identity_self_test", [derived], probe
+ )
+ ):
+ error_message = "The generated signing identity failed verification."
+ return false
+ var metadata := {
+ "format_version": FORMAT_VERSION,
+ "algorithm": NetworkIdentityCrypto.ALGORITHM,
+ "fingerprint": derived,
+ "created_at_unix": int(Time.get_unix_time_from_system()),
+ }
+ if (
+ not _write_atomic(_path(".key"), private_text)
+ or not _write_atomic(_path(".pub"), public_text)
+ or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t"))
+ ):
+ error_message = "The signing identity could not be stored."
+ return false
+ FileAccess.set_unix_permissions(_path(".key"), 384)
+ return _load_existing()
+
+
+func _load_existing() -> bool:
+ var required := [_path(".key"), _path(".pub"), _path(".json")]
+ for path: String in required:
+ if not FileAccess.file_exists(path):
+ error_message = "Identity recovery is required. An identity file is missing."
+ return false
+ var private_file := FileAccess.open(_path(".key"), FileAccess.READ)
+ var public_file := FileAccess.open(_path(".pub"), FileAccess.READ)
+ var metadata_file := FileAccess.open(_path(".json"), FileAccess.READ)
+ if private_file == null or public_file == null or metadata_file == null:
+ error_message = "Identity recovery is required. Identity files could not be read."
+ return false
+ var private_text := private_file.get_as_text()
+ var loaded_public := NetworkIdentityCrypto.normalize_public_pem(public_file.get_as_text())
+ var metadata_text := metadata_file.get_as_text()
+ var json := JSON.new()
+ if json.parse(metadata_text) != OK or typeof(json.data) != TYPE_DICTIONARY:
+ error_message = "Identity recovery is required. Identity metadata is damaged."
+ return false
+ var metadata: Dictionary = json.data
+ var derived := NetworkIdentityCrypto.fingerprint_public_pem(loaded_public)
+ if (
+ metadata.get("format_version") != FORMAT_VERSION
+ or metadata.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
+ or metadata.get("fingerprint") != derived
+ ):
+ error_message = "Identity recovery is required. Identity files do not match."
+ return false
+ var key := CryptoKey.new()
+ if key.load_from_string(private_text) != OK:
+ error_message = "Identity recovery is required. The private key is damaged."
+ return false
+ var probe := NetworkIdentityCrypto.sign_fields(key, "identity_self_test", [derived])
+ if not NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(loaded_public),
+ "identity_self_test",
+ [derived],
+ probe,
+ ):
+ error_message = "Identity recovery is required. The key pair does not match."
+ return false
+ _private_key = key
+ public_pem = loaded_public
+ fingerprint = derived
+ error_message = ""
+ return true
+
+
+func _write_atomic(path: String, content: String) -> bool:
+ var temporary := path + ".tmp"
+ var file := FileAccess.open(temporary, FileAccess.WRITE)
+ if file == null:
+ return false
+ file.store_string(content)
+ file.flush()
+ var write_error := file.get_error()
+ file.close()
+ if write_error != OK:
+ return false
+ if FileAccess.file_exists(path):
+ var backup := path + ".backup"
+ _remove_if_present(backup)
+ if not _rename(path, backup):
+ return false
+ if not _rename(temporary, path):
+ _rename(backup, path)
+ return false
+ _remove_if_present(backup)
+ return true
+ return _rename(temporary, path)
+
+
+func _path(extension: String) -> String:
+ var filename: String = "%s%s" % [_prefix, extension]
+ return (
+ _storage_directory.path_join(filename)
+ if not _storage_directory.is_empty()
+ else "user://%s" % filename
+ )
+
+
+func identity_type() -> String:
+ return "player" if _prefix == "player_identity" else "host"
+
+
+func _archive_current_identity() -> String:
+ var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
+ var archive := ProjectSettings.globalize_path(
+ "user://identity-recovery/%s-%s" % [_prefix, timestamp]
+ )
+ if DirAccess.make_dir_recursive_absolute(archive) != OK:
+ return ""
+ for extension: String in [".key", ".pub", ".json"]:
+ var source := _path(extension)
+ var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024)
+ if bytes.is_empty():
+ return ""
+ var file := FileAccess.open(archive.path_join(_prefix + extension), FileAccess.WRITE)
+ if file == null:
+ return ""
+ file.store_buffer(bytes)
+ file.close()
+ FileAccess.set_unix_permissions(
+ archive.path_join(_prefix + ".key"), 384
+ )
+ return archive
+
+
+func _restore_archive(archive: String) -> void:
+ for extension: String in [".key", ".pub", ".json"]:
+ var source := archive.path_join(_prefix + extension)
+ var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024)
+ if bytes.is_empty():
+ continue
+ var file := FileAccess.open(_path(extension), FileAccess.WRITE)
+ if file != null:
+ file.store_buffer(bytes)
+ file.close()
+ FileAccess.set_unix_permissions(_path(".key"), 384)
+
+
+func _rename(from_path: String, to_path: String) -> bool:
+ return DirAccess.rename_absolute(
+ ProjectSettings.globalize_path(from_path),
+ ProjectSettings.globalize_path(to_path),
+ ) == OK
+
+
+func _remove_if_present(path: String) -> void:
+ if FileAccess.file_exists(path):
+ DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
diff --git a/network/local_signing_identity_store.gd.uid b/network/local_signing_identity_store.gd.uid
new file mode 100644
index 0000000..44c6907
--- /dev/null
+++ b/network/local_signing_identity_store.gd.uid
@@ -0,0 +1 @@
+uid://1h1kh0jlhc87
diff --git a/network/network_chat_protocol.gd b/network/network_chat_protocol.gd
new file mode 100644
index 0000000..ef7294d
--- /dev/null
+++ b/network/network_chat_protocol.gd
@@ -0,0 +1,80 @@
+class_name NetworkChatProtocol
+extends RefCounted
+
+const CAPABILITY: StringName = &"chat_v1"
+const RELIABLE_CHANNEL: int = NetworkProtocol.CHAT_RELIABLE_CHANNEL
+const MAX_VISIBLE_CHARACTERS: int = 256
+const MAX_UTF8_BYTES: int = 1024
+const MAX_HISTORY: int = 100
+const LATE_JOIN_HISTORY: int = 50
+const MAX_ID_LENGTH: int = 96
+
+enum Kind { PLAYER, SYSTEM }
+
+
+static func sanitize_body(value: Variant) -> String:
+ if typeof(value) != TYPE_STRING:
+ return ""
+ var result: String = str(value).replace("\r", " ").replace("\n", " ")
+ result = result.replace("\t", " ").strip_edges()
+ if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS:
+ return ""
+ if result.to_utf8_buffer().size() > MAX_UTF8_BYTES:
+ return ""
+ for index: int in result.length():
+ var codepoint: int = result.unicode_at(index)
+ if codepoint < 32 or codepoint == 127:
+ return ""
+ return result
+
+
+static func validate_request(data: Variant) -> bool:
+ return (
+ typeof(data) == TYPE_DICTIONARY
+ and _valid_id(data.get("request_id"))
+ and _valid_id(data.get("session_id"))
+ and not sanitize_body(data.get("body")).is_empty()
+ and NetworkIdentityCrypto.valid_fingerprint(
+ data.get("sender_fingerprint")
+ )
+ and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
+ and PackedByteArray(data["sender_signature"]).size()
+ <= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
+ )
+
+
+static func validate_message(data: Variant) -> bool:
+ return (
+ typeof(data) == TYPE_DICTIONARY
+ and _valid_id(data.get("message_id"))
+ and _valid_id(data.get("session_id"))
+ and typeof(data.get("sequence")) == TYPE_INT
+ and int(data["sequence"]) >= 0
+ and typeof(data.get("kind")) == TYPE_INT
+ and int(data["kind"]) in [Kind.PLAYER, Kind.SYSTEM]
+ and typeof(data.get("sender_peer_id")) == TYPE_INT
+ and typeof(data.get("sender_display_name")) == TYPE_STRING
+ and str(data["sender_display_name"]).length() <= 24
+ and not sanitize_body(data.get("body")).is_empty()
+ and NetworkIdentityCrypto.valid_fingerprint(
+ data.get("sender_fingerprint")
+ )
+ and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
+ )
+
+
+static func signature_fields(data: Dictionary) -> Array:
+ return [
+ str(data.get("session_id", "")),
+ str(data.get("request_id", "")),
+ str(data.get("sender_fingerprint", "")),
+ sanitize_body(data.get("body")),
+ ]
+
+
+static func _valid_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_ID_LENGTH
+ )
diff --git a/network/network_chat_protocol.gd.uid b/network/network_chat_protocol.gd.uid
new file mode 100644
index 0000000..916bd77
--- /dev/null
+++ b/network/network_chat_protocol.gd.uid
@@ -0,0 +1 @@
+uid://c3hg65v27hi2g
diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd
new file mode 100644
index 0000000..50c47d3
--- /dev/null
+++ b/network/network_chat_service.gd
@@ -0,0 +1,687 @@
+class_name NetworkChatService
+extends Node
+
+const BURST_COUNT: int = 3
+const WINDOW_COUNT: int = 5
+const WINDOW_SECONDS: float = 10.0
+const CALL_COOLDOWN_MILLISECONDS: int = 180
+const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500
+const CALL_PITCH_VARIANTS: Array[float] = [
+ 0.96,
+ 1.03,
+ 0.985,
+ 1.055,
+ 1.0,
+ 0.975,
+ 1.04,
+]
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+
+signal message_received(message: Dictionary)
+signal local_message_confirmed(message: Dictionary)
+signal send_rejected(message: String)
+signal history_replaced(messages: Array[Dictionary])
+signal world_command_finished(success: bool, message: String)
+signal character_call_received(
+ peer_id: int,
+ call_id: String,
+ pitch_scale: float,
+)
+
+var _session: NetworkSession
+var _history: Array[Dictionary] = []
+var _seen_messages: Dictionary[String, bool] = {}
+var _request_ledgers: Dictionary[int, Dictionary] = {}
+var _rate_times: Dictionary[int, Array] = {}
+var _last_call_msec: Dictionary[int, int] = {}
+var _call_variant_indices: Dictionary[int, int] = {}
+var _last_world_command_msec: Dictionary[int, int] = {}
+var _sequence: int = 0
+var _peer_names: Dictionary[int, String] = {}
+var _relationships: PlayerRelationshipStore
+var _world_time: WorldTimeService
+var _world_weather: WorldWeatherService
+
+
+func setup(
+ session: NetworkSession,
+ world_time: WorldTimeService,
+ world_weather: WorldWeatherService,
+) -> void:
+ _session = session
+ _world_time = world_time
+ _world_weather = world_weather
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ _session.peer_removed.connect(_on_peer_removed)
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.peer_display_name_changed.connect(
+ func(peer_id: int, display_name: String) -> void:
+ _peer_names[peer_id] = display_name
+ )
+
+
+func set_relationship_store(store: PlayerRelationshipStore) -> void:
+ _relationships = store
+
+
+func refresh_relationship_filters() -> void:
+ history_replaced.emit(get_history())
+
+
+func is_sender_filtered(fingerprint: String) -> bool:
+ return (
+ _relationships != null
+ and _relationships.is_muted(fingerprint)
+ )
+
+
+func send_local_message(body: String) -> bool:
+ if (
+ _session == null
+ or not _session.is_gameplay_session_active()
+ or (
+ not _session.is_host()
+ and not _session.supports_server_capability(
+ NetworkChatProtocol.CAPABILITY
+ )
+ )
+ ):
+ send_rejected.emit("Chat is unavailable.")
+ return false
+ var clean := NetworkChatProtocol.sanitize_body(body)
+ if clean.is_empty():
+ send_rejected.emit("Message is empty or too long.")
+ return false
+ var request := {
+ "request_id": _new_id("chat_request"),
+ "session_id": _session.get_session_id(),
+ "body": clean,
+ "sender_fingerprint": _session.get_local_identity_fingerprint(),
+ }
+ request["sender_signature"] = _session.sign_local_action(
+ "chat_send", NetworkChatProtocol.signature_fields(request)
+ )
+ if _session.is_host():
+ _handle_request(_session.get_local_peer_id(), request)
+ else:
+ submit_chat_message.rpc_id(1, request)
+ return true
+
+
+func send_local_character_call(call_id: String) -> bool:
+ if (
+ _session == null
+ or not _session.is_gameplay_session_active()
+ or not VoiceProfilesType.is_valid_call(call_id)
+ or (
+ not _session.is_host()
+ and not _session.supports_server_capability(
+ NetworkChatProtocol.CAPABILITY
+ )
+ )
+ ):
+ return false
+ var request := {
+ "request_id": _new_id("character_call"),
+ "session_id": _session.get_session_id(),
+ "call_id": call_id,
+ "sender_fingerprint": _session.get_local_identity_fingerprint(),
+ }
+ request["sender_signature"] = _session.sign_local_action(
+ "character_call", _character_call_signature_fields(request)
+ )
+ if _session.is_host():
+ _handle_character_call(_session.get_local_peer_id(), request)
+ else:
+ submit_character_call.rpc_id(1, request)
+ return true
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func submit_character_call(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_character_call(sender_id, data)
+
+
+func _handle_character_call(peer_id: int, data: Dictionary) -> void:
+ if (
+ typeof(data.get("request_id")) != TYPE_STRING
+ or str(data["request_id"]).is_empty()
+ or str(data["request_id"]).length() > 64
+ or typeof(data.get("session_id")) != TYPE_STRING
+ or str(data["session_id"]) != _session.get_session_id()
+ or typeof(data.get("call_id")) != TYPE_STRING
+ or not VoiceProfilesType.is_valid_call(str(data["call_id"]))
+ or typeof(data.get("sender_fingerprint")) != TYPE_STRING
+ or typeof(data.get("sender_signature")) != TYPE_PACKED_BYTE_ARRAY
+ ):
+ return
+ var record := _session.get_peer_record(peer_id)
+ if (
+ record == null
+ or record.identity_fingerprint != str(data["sender_fingerprint"])
+ or not _session.verify_peer_action(
+ peer_id,
+ "character_call",
+ _character_call_signature_fields(data),
+ data["sender_signature"],
+ )
+ or not _consume_character_call_rate(peer_id)
+ ):
+ return
+ var pitch_scale: float = _next_character_call_pitch(peer_id)
+ _apply_character_call(peer_id, str(data["call_id"]), pitch_scale)
+ receive_character_call.rpc(
+ peer_id,
+ str(data["call_id"]),
+ pitch_scale,
+ )
+
+
+@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func receive_character_call(
+ peer_id: int,
+ call_id: String,
+ pitch_scale: float,
+) -> void:
+ _apply_character_call(peer_id, call_id, pitch_scale)
+
+
+func _apply_character_call(
+ peer_id: int,
+ call_id: String,
+ pitch_scale: float,
+) -> void:
+ if (
+ not VoiceProfilesType.is_valid_call(call_id)
+ or pitch_scale < 0.9
+ or pitch_scale > 1.1
+ or _session == null
+ or not _session.is_gameplay_session_active()
+ ):
+ return
+ var record := _session.get_peer_record(peer_id)
+ if record == null or is_sender_filtered(record.identity_fingerprint):
+ return
+ character_call_received.emit(peer_id, call_id, pitch_scale)
+
+
+func _next_character_call_pitch(peer_id: int) -> float:
+ var variant_index: int = _call_variant_indices.get(peer_id, 0)
+ var pitch_scale: float = CALL_PITCH_VARIANTS[
+ variant_index % CALL_PITCH_VARIANTS.size()
+ ]
+ _call_variant_indices[peer_id] = variant_index + 1
+ return pitch_scale
+
+
+func _consume_character_call_rate(peer_id: int) -> bool:
+ var now_msec: int = Time.get_ticks_msec()
+ var last_msec: int = _last_call_msec.get(
+ peer_id, now_msec - CALL_COOLDOWN_MILLISECONDS
+ )
+ if now_msec - last_msec < CALL_COOLDOWN_MILLISECONDS:
+ return false
+ _last_call_msec[peer_id] = now_msec
+ return true
+
+
+func _character_call_signature_fields(data: Dictionary) -> Array:
+ return [
+ str(data.get("session_id", "")),
+ str(data.get("request_id", "")),
+ str(data.get("sender_fingerprint", "")),
+ str(data.get("call_id", "")),
+ ]
+
+
+func broadcast_system_message(body: String) -> bool:
+ if _session == null or not _session.is_host():
+ return false
+ var clean := NetworkChatProtocol.sanitize_body(body)
+ if clean.is_empty():
+ return false
+ _broadcast(_make_message(
+ NetworkChatProtocol.Kind.SYSTEM,
+ 0,
+ "",
+ clean,
+ ))
+ return true
+
+
+func request_world_time_change(phase_name: String) -> bool:
+ var normalized: String = phase_name.strip_edges().to_lower()
+ if not _valid_time_phase(normalized) or _session == null:
+ return false
+ if _session.is_host():
+ return _apply_world_time_change(normalized)
+ if (
+ not _session.is_joined_client()
+ or not _session.is_local_operator()
+ or not _session.supports_server_capability(
+ NetworkProtocol.WORLD_TIME_CAPABILITY
+ )
+ ):
+ return false
+ submit_world_time_command.rpc_id(1, normalized)
+ return true
+
+
+func request_world_weather_change(weather_name: String) -> bool:
+ var normalized: String = _normalized_weather_name(weather_name)
+ if normalized.is_empty() or _session == null:
+ return false
+ if _session.is_host():
+ return _apply_world_weather_change(normalized)
+ if (
+ not _session.is_joined_client()
+ or not _session.is_local_operator()
+ or not _session.supports_server_capability(
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ )
+ ):
+ return false
+ submit_world_weather_command.rpc_id(1, normalized)
+ return true
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func submit_world_time_command(phase_name: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _valid_world_command_sender(sender_id):
+ _send_world_command_result(
+ sender_id, false, "Only the host or an operator can change world time."
+ )
+ return
+ if not _consume_world_command_rate(sender_id):
+ _send_world_command_result(sender_id, false, "Slow down.")
+ return
+ var success: bool = _apply_world_time_change(
+ phase_name.strip_edges().to_lower()
+ )
+ _send_world_command_result(
+ sender_id,
+ success,
+ "" if success else "World time could not be changed.",
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func submit_world_weather_command(weather_name: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _valid_world_command_sender(sender_id):
+ _send_world_command_result(
+ sender_id,
+ false,
+ "Only the host or an operator can change world weather.",
+ )
+ return
+ if not _consume_world_command_rate(sender_id):
+ _send_world_command_result(sender_id, false, "Slow down.")
+ return
+ var success: bool = _apply_world_weather_change(
+ _normalized_weather_name(weather_name)
+ )
+ _send_world_command_result(
+ sender_id,
+ success,
+ "" if success else "World weather could not be changed.",
+ )
+
+
+@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func receive_world_command_result(success: bool, message: String) -> void:
+ if _session == null or not _session.is_joined_client():
+ return
+ world_command_finished.emit(success, message.left(120))
+
+
+func _valid_world_command_sender(peer_id: int) -> bool:
+ return (
+ _session != null
+ and _session.is_host()
+ and peer_id > 1
+ and _session.is_authenticated_peer(peer_id)
+ and _session.is_peer_operator(peer_id)
+ )
+
+
+func _apply_world_time_change(phase_name: String) -> bool:
+ if _world_time == null or not _valid_time_phase(phase_name):
+ return false
+ var target_hour: float = _time_phase_hour(phase_name)
+ if not _world_time.set_authoritative_time(target_hour):
+ return false
+ broadcast_system_message(
+ "World time set to %s (%s)."
+ % [phase_name, _world_time.get_clock_text()]
+ )
+ return true
+
+
+func _apply_world_weather_change(weather_name: String) -> bool:
+ if _world_weather == null:
+ return false
+ var normalized: String = _normalized_weather_name(weather_name)
+ if normalized.is_empty():
+ return false
+ var target_weather: WorldWeatherService.Weather = (
+ _weather_for_name(normalized)
+ )
+ if not _world_weather.set_authoritative_weather(target_weather):
+ return false
+ broadcast_system_message("World weather set to %s." % normalized)
+ return true
+
+
+func _send_world_command_result(
+ peer_id: int,
+ success: bool,
+ message: String,
+) -> void:
+ if peer_id > 1 and _session.is_authenticated_peer(peer_id):
+ receive_world_command_result.rpc_id(peer_id, success, message.left(120))
+
+
+func _consume_world_command_rate(peer_id: int) -> bool:
+ var now_msec: int = Time.get_ticks_msec()
+ var last_msec: int = _last_world_command_msec.get(
+ peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS
+ )
+ if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS:
+ return false
+ _last_world_command_msec[peer_id] = now_msec
+ return true
+
+
+static func _valid_time_phase(phase_name: String) -> bool:
+ return phase_name in ["dawn", "day", "dusk", "night"]
+
+
+static func _time_phase_hour(phase_name: String) -> float:
+ match phase_name:
+ "dawn":
+ return WorldTimeService.DAWN_START_HOUR
+ "day":
+ return WorldTimeService.DAWN_END_HOUR
+ "dusk":
+ return WorldTimeService.DUSK_START_HOUR
+ "night":
+ return WorldTimeService.DUSK_END_HOUR
+ return -1.0
+
+
+static func _normalized_weather_name(weather_name: String) -> String:
+ var normalized: String = weather_name.strip_edges().to_lower()
+ return "clear" if normalized == "sunny" else normalized if normalized in [
+ "clear", "cloudy", "rainy", "foggy"
+ ] else ""
+
+
+static func _weather_for_name(
+ weather_name: String,
+) -> WorldWeatherService.Weather:
+ match weather_name:
+ "cloudy":
+ return WorldWeatherService.Weather.CLOUDY
+ "rainy":
+ return WorldWeatherService.Weather.RAINY
+ "foggy":
+ return WorldWeatherService.Weather.FOGGY
+ return WorldWeatherService.Weather.SUNNY
+
+
+func get_history() -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for message: Dictionary in _history:
+ if _message_is_visible(message):
+ result.append(message.duplicate(true))
+ return result
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func submit_chat_message(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_request(sender_id, data)
+
+
+func _handle_request(peer_id: int, data: Dictionary) -> void:
+ if (
+ not NetworkChatProtocol.validate_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ _send_rejection(peer_id, "Message could not be sent.")
+ return
+ var record := _session.get_peer_record(peer_id)
+ if (
+ record == null
+ or record.identity_fingerprint != str(data["sender_fingerprint"])
+ or not _session.verify_peer_action(
+ peer_id,
+ "chat_send",
+ NetworkChatProtocol.signature_fields(data),
+ data["sender_signature"],
+ )
+ ):
+ _send_rejection(peer_id, "Message identity could not be verified.")
+ return
+ var request_id: String = data["request_id"]
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ if ledger.has(request_id):
+ _send_message(peer_id, ledger[request_id])
+ return
+ if not _consume_rate(peer_id):
+ _send_rejection(peer_id, "Slow down.")
+ return
+ var message := _make_message(
+ NetworkChatProtocol.Kind.PLAYER,
+ peer_id,
+ record.display_name,
+ NetworkChatProtocol.sanitize_body(data["body"])
+ )
+ message["request_id"] = request_id
+ message["sender_fingerprint"] = data["sender_fingerprint"]
+ message["sender_signature"] = data["sender_signature"]
+ ledger[request_id] = message.duplicate(true)
+ while ledger.size() > 64:
+ ledger.erase(ledger.keys().front())
+ _request_ledgers[peer_id] = ledger
+ _broadcast(message)
+
+
+func _make_message(
+ kind: int,
+ peer_id: int,
+ display_name: String,
+ body: String,
+) -> Dictionary:
+ _sequence += 1
+ var message := {
+ "message_id": _new_id("chat"),
+ "request_id": _new_id("chat_system"),
+ "session_id": _session.get_session_id(),
+ "sequence": _sequence,
+ "kind": kind,
+ "sender_peer_id": peer_id,
+ "sender_display_name": display_name.left(24),
+ "body": body,
+ "sender_fingerprint": _session.get_host_identity_fingerprint(),
+ }
+ if kind == NetworkChatProtocol.Kind.SYSTEM:
+ message["sender_signature"] = _session.sign_host_action(
+ "chat_system", NetworkChatProtocol.signature_fields(message)
+ )
+ return message
+
+
+func _broadcast(message: Dictionary) -> void:
+ _apply_message(message)
+ receive_chat_message.rpc(message)
+
+
+func _send_message(peer_id: int, message: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _apply_message(message)
+ else:
+ receive_chat_message.rpc_id(peer_id, message)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func receive_chat_message(data: Dictionary) -> void:
+ _apply_message(data)
+
+
+func _apply_message(data: Dictionary) -> void:
+ if (
+ not NetworkChatProtocol.validate_message(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or _seen_messages.has(str(data["message_id"]))
+ ):
+ return
+ var kind := int(data["kind"])
+ var valid_signature := false
+ if kind == NetworkChatProtocol.Kind.SYSTEM:
+ valid_signature = _session.verify_host_action(
+ "chat_system",
+ NetworkChatProtocol.signature_fields(data),
+ data["sender_signature"],
+ )
+ else:
+ var sender_id := int(data["sender_peer_id"])
+ var record := _session.get_peer_record(sender_id)
+ valid_signature = (
+ record != null
+ and record.identity_fingerprint == str(data["sender_fingerprint"])
+ and _session.verify_peer_action(
+ sender_id,
+ "chat_send",
+ NetworkChatProtocol.signature_fields(data),
+ data["sender_signature"],
+ )
+ )
+ if not valid_signature:
+ return
+ _seen_messages[data["message_id"]] = true
+ var stored_message := data.duplicate(true)
+ # Suppression is immutable for this received copy. Relationship changes
+ # later must never resurrect text that was hidden on arrival.
+ stored_message["locally_suppressed"] = (
+ kind == NetworkChatProtocol.Kind.PLAYER
+ and is_sender_filtered(str(data.get("sender_fingerprint", "")))
+ )
+ _history.append(stored_message)
+ while _history.size() > NetworkChatProtocol.MAX_HISTORY:
+ _history.pop_front()
+ if _message_is_visible(stored_message):
+ message_received.emit(stored_message.duplicate(true))
+ if (
+ kind == NetworkChatProtocol.Kind.PLAYER
+ and int(stored_message["sender_peer_id"])
+ == _session.get_local_peer_id()
+ ):
+ local_message_confirmed.emit(stored_message.duplicate(true))
+
+
+func _send_rejection(peer_id: int, message: String) -> void:
+ if peer_id == _session.get_local_peer_id():
+ send_rejected.emit(message)
+ else:
+ receive_chat_rejection.rpc_id(peer_id, message.left(80))
+
+
+@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func receive_chat_rejection(message: String) -> void:
+ send_rejected.emit(message.left(80))
+
+
+func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
+ if not _session.is_host():
+ return
+ _peer_names[peer_id] = display_name
+ var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY)
+ receive_chat_history.rpc_id(peer_id, _history.slice(start))
+ _broadcast(_make_message(
+ NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name
+ ))
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ _request_ledgers.erase(peer_id)
+ _rate_times.erase(peer_id)
+ _last_call_msec.erase(peer_id)
+ _call_variant_indices.erase(peer_id)
+ _last_world_command_msec.erase(peer_id)
+ if not _session.is_host():
+ return
+ var display_name: String = _peer_names.get(peer_id, "Player")
+ _peer_names.erase(peer_id)
+ _broadcast(_make_message(
+ NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % display_name
+ ))
+
+
+@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
+func receive_chat_history(values: Array) -> void:
+ if values.size() > NetworkChatProtocol.LATE_JOIN_HISTORY:
+ return
+ _history.clear()
+ _seen_messages.clear()
+ for value: Variant in values:
+ if NetworkChatProtocol.validate_message(value):
+ _apply_message(value)
+ history_replaced.emit(get_history())
+
+
+func _message_is_visible(message: Dictionary) -> bool:
+ if bool(message.get("locally_suppressed", false)):
+ return false
+ if int(message.get("kind", -1)) == NetworkChatProtocol.Kind.SYSTEM:
+ return true
+ return true
+
+
+func _consume_rate(peer_id: int) -> bool:
+ var now := Time.get_ticks_msec() / 1000.0
+ var times: Array = _rate_times.get(peer_id, [])
+ while not times.is_empty() and now - float(times.front()) > WINDOW_SECONDS:
+ times.pop_front()
+ var recent_burst := 0
+ for value: Variant in times:
+ if now - float(value) <= 1.0:
+ recent_burst += 1
+ if recent_burst >= BURST_COUNT or times.size() >= WINDOW_COUNT:
+ _rate_times[peer_id] = times
+ return false
+ times.append(now)
+ _rate_times[peer_id] = times
+ return true
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _history.clear()
+ _seen_messages.clear()
+ _request_ledgers.clear()
+ _rate_times.clear()
+ _last_call_msec.clear()
+ _call_variant_indices.clear()
+ _last_world_command_msec.clear()
+ _peer_names.clear()
+ _sequence = 0
+ history_replaced.emit([])
+
+
+func _new_id(prefix: String) -> String:
+ return "%s:%s" % [
+ prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
+ ]
diff --git a/network/network_chat_service.gd.uid b/network/network_chat_service.gd.uid
new file mode 100644
index 0000000..228279d
--- /dev/null
+++ b/network/network_chat_service.gd.uid
@@ -0,0 +1 @@
+uid://b10jayoog5dc2
diff --git a/network/network_fish_showcase_protocol.gd b/network/network_fish_showcase_protocol.gd
new file mode 100644
index 0000000..d69e9dd
--- /dev/null
+++ b/network/network_fish_showcase_protocol.gd
@@ -0,0 +1,61 @@
+class_name NetworkFishShowcaseProtocol
+extends RefCounted
+
+const CAPABILITY: StringName = &"fish_showcase_v1"
+const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
+const MAX_SESSION_ID_LENGTH: int = 96
+const MAX_FISH_ID_LENGTH: int = 96
+const MAX_WEIGHT_LB: float = 1000.0
+const MAX_DISPLAY_SCALE: float = 20.0
+const FishQualityType = preload("res://fish/fish_quality.gd")
+
+
+static func validate_state(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ for key: String in [
+ "session_id",
+ "owner_peer_id",
+ "visible",
+ "fish_id",
+ "weight_lb",
+ "display_scale",
+ "quality",
+ "revision",
+ ]:
+ if not value.has(key):
+ return false
+ if (
+ typeof(value["session_id"]) != TYPE_STRING
+ or str(value["session_id"]).is_empty()
+ or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH
+ or typeof(value["owner_peer_id"]) != TYPE_INT
+ or int(value["owner_peer_id"]) <= 0
+ or typeof(value["visible"]) != TYPE_BOOL
+ or typeof(value["fish_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or str(value["fish_id"]).length() > MAX_FISH_ID_LENGTH
+ or typeof(value["weight_lb"]) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(value["weight_lb"]))
+ or typeof(value["display_scale"]) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(value["display_scale"]))
+ or typeof(value["quality"]) != TYPE_INT
+ or not FishQualityType.is_valid(int(value["quality"]))
+ or typeof(value["revision"]) != TYPE_INT
+ or int(value["revision"]) < 0
+ ):
+ return false
+ if bool(value["visible"]):
+ return (
+ not str(value["fish_id"]).is_empty()
+ and float(value["weight_lb"]) > 0.0
+ and float(value["weight_lb"]) <= MAX_WEIGHT_LB
+ and float(value["display_scale"]) > 0.0
+ and float(value["display_scale"]) <= MAX_DISPLAY_SCALE
+ )
+ return (
+ str(value["fish_id"]).is_empty()
+ and is_zero_approx(float(value["weight_lb"]))
+ and is_zero_approx(float(value["display_scale"]))
+ and int(value["quality"]) == FishQualityType.Tier.BORING
+ )
diff --git a/network/network_fish_showcase_protocol.gd.uid b/network/network_fish_showcase_protocol.gd.uid
new file mode 100644
index 0000000..322418d
--- /dev/null
+++ b/network/network_fish_showcase_protocol.gd.uid
@@ -0,0 +1 @@
+uid://b3h0h47ilh3nm
diff --git a/network/network_fish_showcase_service.gd b/network/network_fish_showcase_service.gd
new file mode 100644
index 0000000..068ee7d
--- /dev/null
+++ b/network/network_fish_showcase_service.gd
@@ -0,0 +1,274 @@
+class_name NetworkFishShowcaseService
+extends Node
+
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
+
+var _session: NetworkSession
+var _spawn_service: PlayerSpawnService
+var _fish_catalog: FishPoolType
+var _local_inventory: FishInventoryType
+var _local_hotbar: PlayerHotbarType
+var _states: Dictionary[int, Dictionary] = {}
+var _local_revision: int = 0
+var _local_visible: bool = false
+var _local_catch_id: StringName
+
+
+func setup(
+ session: NetworkSession,
+ spawn_service: PlayerSpawnService,
+ fish_catalog: FishPoolType,
+ local_inventory: FishInventoryType,
+ local_hotbar: PlayerHotbarType,
+) -> void:
+ _session = session
+ _spawn_service = spawn_service
+ _fish_catalog = fish_catalog
+ _local_inventory = local_inventory
+ _local_hotbar = local_hotbar
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ _session.peer_removed.connect(_on_peer_removed)
+ _session.state_changed.connect(_on_session_state_changed)
+ _spawn_service.avatar_spawned.connect(_on_avatar_spawned)
+ _local_inventory.catches_changed.connect(_on_local_inventory_changed)
+ _local_hotbar.selected_assignment_changed.connect(
+ _on_selected_assignment_changed
+ )
+ var selected_kind: int = (
+ _local_hotbar.get_selected_assignment_kind()
+ )
+ var selected_identity: StringName = StringName()
+ if selected_kind == PlayerHotbarType.AssignmentKind.ITEM:
+ selected_identity = _local_hotbar.get_selected_item_id()
+ elif selected_kind == PlayerHotbarType.AssignmentKind.FISH:
+ selected_identity = _local_hotbar.get_selected_fish_catch_id()
+ _on_selected_assignment_changed(
+ _local_hotbar.get_selected_slot(),
+ selected_kind,
+ selected_identity,
+ )
+
+
+func toggle_selected_fish() -> bool:
+ if _local_hotbar == null or _local_inventory == null:
+ return false
+ var catch_id: StringName = _local_hotbar.get_selected_fish_catch_id()
+ var fish_catch: FishCatchType = _local_inventory.get_catch_by_id(catch_id)
+ if fish_catch == null or not fish_catch.is_valid():
+ return false
+ if _local_visible and _local_catch_id == catch_id:
+ _submit_local_state(null, false)
+ else:
+ _submit_local_state(fish_catch, true)
+ return true
+
+
+func is_local_showcase_visible() -> bool:
+ return _local_visible
+
+
+func get_local_showcase_catch_id() -> StringName:
+ return _local_catch_id
+
+
+func _submit_local_state(fish_catch: FishCatchType, should_show: bool) -> void:
+ _local_revision += 1
+ var local_peer_id: int = (
+ _session.get_local_peer_id() if _session != null else 1
+ )
+ var data: Dictionary = {
+ "session_id": (
+ _session.get_session_id()
+ if _session != null and not _session.get_session_id().is_empty()
+ else "local"
+ ),
+ "owner_peer_id": local_peer_id,
+ "visible": should_show and fish_catch != null,
+ "fish_id": String(fish_catch.fish_id) if fish_catch != null else "",
+ "weight_lb": fish_catch.weight_lb if fish_catch != null else 0.0,
+ "display_scale": (
+ fish_catch.display_scale if fish_catch != null else 0.0
+ ),
+ "quality": (
+ fish_catch.quality
+ if fish_catch != null
+ else FishQualityType.Tier.BORING
+ ),
+ "revision": _local_revision,
+ }
+ _local_visible = bool(data["visible"])
+ _local_catch_id = (
+ fish_catch.catch_id if _local_visible else StringName()
+ )
+ _apply_state(data)
+ if _session == null or not _session.is_gameplay_session_active():
+ return
+ data["session_id"] = _session.get_session_id()
+ if _session.is_host():
+ _handle_showcase_state(local_peer_id, data)
+ elif _session.supports_server_capability(
+ NetworkFishShowcaseProtocol.CAPABILITY
+ ):
+ submit_showcase_state.rpc_id(1, data)
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ NetworkFishShowcaseProtocol.RELIABLE_CHANNEL,
+)
+func submit_showcase_state(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_showcase_state(sender_id, data)
+
+
+func _handle_showcase_state(peer_id: int, data: Dictionary) -> void:
+ if (
+ not _session.is_host()
+ or not NetworkFishShowcaseProtocol.validate_state(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or int(data["owner_peer_id"]) != peer_id
+ or (
+ peer_id != _session.get_local_peer_id()
+ and not _session.is_authenticated_peer(peer_id)
+ )
+ ):
+ return
+ var previous: Dictionary = _states.get(peer_id, {})
+ if int(data["revision"]) <= int(previous.get("revision", -1)):
+ return
+ if not _state_matches_catalog(data):
+ return
+ var sanitized: Dictionary = data.duplicate(true)
+ _states[peer_id] = sanitized
+ _apply_state(sanitized)
+ receive_showcase_state.rpc(sanitized)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ NetworkFishShowcaseProtocol.RELIABLE_CHANNEL,
+)
+func receive_showcase_state(data: Dictionary) -> void:
+ if (
+ not NetworkFishShowcaseProtocol.validate_state(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or not _state_matches_catalog(data)
+ ):
+ return
+ var peer_id: int = int(data["owner_peer_id"])
+ var previous: Dictionary = _states.get(peer_id, {})
+ if int(data["revision"]) <= int(previous.get("revision", -1)):
+ return
+ _states[peer_id] = data.duplicate(true)
+ _apply_state(data)
+
+
+func _state_matches_catalog(data: Dictionary) -> bool:
+ if not bool(data["visible"]):
+ return true
+ var fish_id: StringName = StringName(str(data["fish_id"]))
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id)
+ if fish == null or not fish.is_selectable():
+ return false
+ var weight_lb: float = float(data["weight_lb"])
+ return (
+ weight_lb >= fish.get_minimum_weight()
+ and weight_lb <= fish.get_maximum_weight()
+ )
+
+
+func _apply_state(data: Dictionary) -> void:
+ var peer_id: int = int(data["owner_peer_id"])
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ return
+ if not bool(data["visible"]):
+ avatar.set_held_fish(null, 1.0, false, true)
+ return
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(
+ StringName(str(data["fish_id"]))
+ )
+ avatar.set_held_fish(
+ fish,
+ fish.get_display_scale_for_weight(float(data["weight_lb"])),
+ true,
+ )
+
+
+func _on_selected_assignment_changed(
+ _slot_index: int,
+ kind: int,
+ identity: StringName,
+) -> void:
+ if kind == PlayerHotbarType.AssignmentKind.FISH:
+ if _local_visible and identity == _local_catch_id:
+ return
+ var fish_catch: FishCatchType = _local_inventory.get_catch_by_id(identity)
+ if fish_catch != null and fish_catch.is_valid():
+ _submit_local_state(fish_catch, true)
+ elif _local_visible:
+ _submit_local_state(null, false)
+ return
+ if _local_visible:
+ _submit_local_state(null, false)
+
+
+func _on_local_inventory_changed() -> void:
+ if (
+ _local_visible
+ and not _local_inventory.contains_catch_id(_local_catch_id)
+ ):
+ _submit_local_state(null, false)
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ if not _session.is_host():
+ return
+ for state: Dictionary in _states.values():
+ if bool(state.get("visible", false)):
+ receive_showcase_state.rpc_id(peer_id, state)
+
+
+func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void:
+ var state: Dictionary = _states.get(peer_id, {})
+ if not state.is_empty():
+ _apply_state(state)
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar != null:
+ avatar.set_held_fish(null, 1.0, false)
+ _states.erase(peer_id)
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state not in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ return
+ for peer_id: int in _states:
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar != null:
+ avatar.set_held_fish(null, 1.0, false)
+ _states.clear()
+ _local_visible = false
+ _local_catch_id = StringName()
+ var local_avatar: Player = _spawn_service.get_avatar(
+ _session.get_local_peer_id()
+ )
+ if local_avatar != null:
+ local_avatar.set_held_fish(null, 1.0, false)
diff --git a/network/network_fish_showcase_service.gd.uid b/network/network_fish_showcase_service.gd.uid
new file mode 100644
index 0000000..0a6c086
--- /dev/null
+++ b/network/network_fish_showcase_service.gd.uid
@@ -0,0 +1 @@
+uid://m4n0bn38f34i
diff --git a/network/network_fishing_attempt.gd b/network/network_fishing_attempt.gd
new file mode 100644
index 0000000..3a1b1de
--- /dev/null
+++ b/network/network_fishing_attempt.gd
@@ -0,0 +1,41 @@
+class_name NetworkFishingAttempt
+extends RefCounted
+
+enum Phase {
+ CASTING,
+ WAITING_FOR_BITE,
+ FIGHTING,
+ PENDING_CAPACITY,
+ CAUGHT,
+ ESCAPED,
+ CANCELLED,
+}
+
+var owner_peer_id: int = 0
+var request_id: String = ""
+var attempt_id: String = ""
+var result_id: String = ""
+var session_id: String = ""
+var phase: Phase = Phase.CASTING
+var origin: Vector3
+var target: Vector3
+var bobber_position: Vector3
+var fish_id: StringName
+var bait_tags: Array[StringName] = []
+var lure_effects: Array[StringName] = []
+var encounter_seed: int = 0
+var bite_time_remaining: float = 0.0
+var bite_confirmation_pending: bool = false
+var withdrawal_progress: float = 0.0
+var reel_speed: float = 0.0
+var barrier_damage: int = 1
+var input_held: bool = false
+var last_input_sequence: int = 0
+var catch_payload: Dictionary = {}
+var capacity_nonce: String = ""
+var capacity_deadline: float = 0.0
+var controller: CatchController
+
+
+func is_terminal() -> bool:
+ return phase in [Phase.CAUGHT, Phase.ESCAPED, Phase.CANCELLED]
diff --git a/network/network_fishing_attempt.gd.uid b/network/network_fishing_attempt.gd.uid
new file mode 100644
index 0000000..549ffcf
--- /dev/null
+++ b/network/network_fishing_attempt.gd.uid
@@ -0,0 +1 @@
+uid://c4c00yp1546af
diff --git a/network/network_fishing_protocol.gd b/network/network_fishing_protocol.gd
new file mode 100644
index 0000000..2e9a342
--- /dev/null
+++ b/network/network_fishing_protocol.gd
@@ -0,0 +1,147 @@
+class_name NetworkFishingProtocol
+extends RefCounted
+
+const MAX_ID_LENGTH: int = 96
+const MAX_EVIDENCE_FISH_IDS: int = 256
+const MAX_CAST_DISTANCE: float = 30.0
+const MAX_REEL_SPEED: float = 10.0
+const MAX_BARRIER_DAMAGE: int = 100
+const INPUT_CHANNEL: int = 3
+const SNAPSHOT_CHANNEL: int = 4
+const INPUT_RATE: float = 1.0 / 30.0
+const SNAPSHOT_RATE: float = 1.0 / 20.0
+
+enum Outcome {
+ CATCH,
+ ESCAPE,
+ CANCELLED,
+}
+
+
+static func validate_cast_request(data: Variant) -> String:
+ if typeof(data) != TYPE_DICTIONARY:
+ return "Malformed fishing request."
+ var payload: Dictionary = data
+ for key: String in [
+ "request_id",
+ "session_id",
+ "origin",
+ "target",
+ "charge",
+ "rod_id",
+ "reel_speed",
+ "barrier_damage",
+ "bite_multiplier",
+ "rarity_multipliers",
+ "discovered_fish_ids",
+ "capacity_available",
+ ]:
+ if not payload.has(key):
+ return "Malformed fishing request."
+ if (
+ typeof(payload["request_id"]) != TYPE_STRING
+ or typeof(payload["session_id"]) != TYPE_STRING
+ or typeof(payload["origin"]) != TYPE_ARRAY
+ or typeof(payload["target"]) != TYPE_ARRAY
+ or typeof(payload["charge"]) not in [TYPE_FLOAT, TYPE_INT]
+ or typeof(payload["rod_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or typeof(payload["reel_speed"]) not in [TYPE_FLOAT, TYPE_INT]
+ or typeof(payload["barrier_damage"]) != TYPE_INT
+ or typeof(payload["bite_multiplier"]) not in [TYPE_FLOAT, TYPE_INT]
+ or typeof(payload["rarity_multipliers"]) != TYPE_ARRAY
+ or typeof(payload["discovered_fish_ids"]) != TYPE_ARRAY
+ or typeof(payload["capacity_available"]) != TYPE_BOOL
+ ):
+ return "Malformed fishing request."
+ if payload.has("bait_id") and typeof(payload["bait_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ return "Malformed fishing request."
+ if payload.has("lure_id") and typeof(payload["lure_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ return "Malformed fishing request."
+ var request_id: String = payload["request_id"]
+ var session_id: String = payload["session_id"]
+ var origin: Array = payload["origin"]
+ var target: Array = payload["target"]
+ var charge: float = float(payload["charge"])
+ var reel_speed: float = float(payload["reel_speed"])
+ var barrier_damage: int = payload["barrier_damage"]
+ var bite_multiplier: float = float(payload["bite_multiplier"])
+ var rarity: Array = payload["rarity_multipliers"]
+ var discovered: Array = payload["discovered_fish_ids"]
+ if (
+ request_id.is_empty()
+ or request_id.length() > MAX_ID_LENGTH
+ or session_id.is_empty()
+ or session_id.length() > MAX_ID_LENGTH
+ or origin.size() != 3
+ or target.size() != 3
+ or not _array_is_finite_vector3(origin)
+ or not _array_is_finite_vector3(target)
+ or not is_finite(charge)
+ or charge < 0.0
+ or charge > 1.01
+ or not is_finite(reel_speed)
+ or reel_speed <= 0.0
+ or reel_speed > MAX_REEL_SPEED
+ or barrier_damage < 1
+ or barrier_damage > MAX_BARRIER_DAMAGE
+ or not is_finite(bite_multiplier)
+ or bite_multiplier < 0.1
+ or bite_multiplier > 10.0
+ or rarity.size() > 16
+ or discovered.size() > MAX_EVIDENCE_FISH_IDS
+ or str(payload["rod_id"]).is_empty()
+ or str(payload["rod_id"]).length() > 96
+ or str(payload.get("lure_id", "")).length() > 96
+ ):
+ return "Fishing request values are outside allowed limits."
+ for value: Variant in rarity:
+ if (
+ typeof(value) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(value))
+ or float(value) < 0.0
+ or float(value) > 10.0
+ ):
+ return "Fishing rarity evidence is invalid."
+ for value: Variant in discovered:
+ if (
+ typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or str(value).is_empty()
+ or str(value).length() > 96
+ ):
+ return "Fishing discovery evidence is invalid."
+ return ""
+
+
+static func validate_input(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var payload: Dictionary = data
+ return (
+ typeof(payload.get("attempt_id")) == TYPE_STRING
+ and not str(payload["attempt_id"]).is_empty()
+ and str(payload["attempt_id"]).length() <= MAX_ID_LENGTH
+ and typeof(payload.get("sequence")) == TYPE_INT
+ and int(payload["sequence"]) > 0
+ and typeof(payload.get("held")) == TYPE_BOOL
+ and typeof(payload.get("pressed")) == TYPE_BOOL
+ )
+
+
+static func vector3_to_array(value: Vector3) -> Array[float]:
+ return [value.x, value.y, value.z]
+
+
+static func array_to_vector3(value: Array) -> Vector3:
+ if value.size() != 3:
+ return Vector3.INF
+ return Vector3(float(value[0]), float(value[1]), float(value[2]))
+
+
+static func _array_is_finite_vector3(value: Array) -> bool:
+ for component: Variant in value:
+ if (
+ typeof(component) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(component))
+ ):
+ return false
+ return true
diff --git a/network/network_fishing_protocol.gd.uid b/network/network_fishing_protocol.gd.uid
new file mode 100644
index 0000000..2826314
--- /dev/null
+++ b/network/network_fishing_protocol.gd.uid
@@ -0,0 +1 @@
+uid://cbihol2i6e2iw
diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd
new file mode 100644
index 0000000..2015681
--- /dev/null
+++ b/network/network_fishing_service.gd
@@ -0,0 +1,1304 @@
+class_name NetworkFishingService
+extends Node
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishSelectorType = preload("res://fish/fish_selector.gd")
+const FishingContextType = preload("res://fishing/fishing_context.gd")
+const CollectionLogType = preload("res://collection/collection_log.gd")
+const FishExperienceType = preload("res://fish/fish_experience.gd")
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
+const RemotePresentationType = preload(
+ "res://fishing/remote_fishing_presentation.gd"
+)
+const ItemDataType = preload("res://items/item_data.gd")
+
+const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
+const CAST_ORIGIN_TOLERANCE: float = 2.5
+const CAPACITY_RESPONSE_TIMEOUT: float = 5.0
+const MIN_CAST_INTERVAL: float = 0.25
+
+signal local_cast_accepted(attempt_id: String, target: Vector3)
+signal local_cast_rejected(message: String)
+signal local_bite_started(attempt_id: String)
+signal local_bite_pending(attempt_id: String)
+signal local_snapshot_received(snapshot: Dictionary)
+signal local_catch_received(fish_catch: FishCatch)
+signal local_attempt_ended(outcome: StringName, message: String)
+
+var _session: NetworkSession
+var _spawn_service: PlayerSpawnService
+var _fishing_spot: FishingSpot
+var _local_inventory: FishInventory
+var _local_bag: PlayerBag
+var _local_collection: CollectionLog
+var _local_capacity: PlayerCoolerCapacity
+var _local_experience: PlayerExperienceType
+var _save_manager: PlayerSaveManager
+var _item_catalog: ItemCatalog
+var _fish_catalog: FishPoolType
+var _item_use: NetworkItemUseService
+var _attempts: Dictionary[int, NetworkFishingAttempt] = {}
+var _request_ledgers: Dictionary[int, Dictionary] = {}
+var _result_ledgers: Dictionary[String, bool] = {}
+var _result_acknowledgements: Dictionary[String, String] = {}
+var _last_cast_time: Dictionary[int, float] = {}
+var _last_input_time: Dictionary[int, float] = {}
+var _remote_presentations: Dictionary[int, RemoteFishingPresentation] = {}
+var _pending_local_bait_by_request: Dictionary[String, StringName] = {}
+var _snapshot_accumulator: float = 0.0
+var _local_input_sequence: int = 0
+
+
+func setup(
+ session: NetworkSession,
+ spawn_service: PlayerSpawnService,
+ fishing_spot: FishingSpot,
+ local_inventory: FishInventory,
+ local_bag: PlayerBag,
+ local_collection: CollectionLog,
+ local_capacity: PlayerCoolerCapacity,
+ local_experience: PlayerExperienceType,
+ save_manager: PlayerSaveManager,
+ item_catalog: ItemCatalog,
+ fish_catalog: FishPoolType,
+ item_use: NetworkItemUseService,
+) -> void:
+ _session = session
+ _spawn_service = spawn_service
+ _fishing_spot = fishing_spot
+ _local_inventory = local_inventory
+ _local_bag = local_bag
+ _local_collection = local_collection
+ _local_capacity = local_capacity
+ _local_experience = local_experience
+ _save_manager = save_manager
+ _item_catalog = item_catalog
+ _fish_catalog = fish_catalog
+ _item_use = item_use
+ if not _session.peer_removed.is_connected(_on_peer_removed):
+ _session.peer_removed.connect(_on_peer_removed)
+ if not _session.state_changed.is_connected(_on_session_state_changed):
+ _session.state_changed.connect(_on_session_state_changed)
+ if not _spawn_service.avatar_removed.is_connected(_on_avatar_removed):
+ _spawn_service.avatar_removed.connect(_on_avatar_removed)
+
+
+func request_local_cast(
+ origin: Vector3,
+ target: Vector3,
+ charge: float,
+ evidence: Dictionary,
+) -> String:
+ if (
+ _session == null
+ or not _session.is_gameplay_session_active()
+ or not origin.is_finite()
+ or not target.is_finite()
+ ):
+ local_cast_rejected.emit("Fishing attempt ended.")
+ return ""
+ var bait_id := StringName(str(evidence.get("bait_id", "")))
+ if not bait_id.is_empty() and (
+ _local_bag == null or not _local_bag.owns_item(bait_id)
+ ):
+ local_cast_rejected.emit("No bait available.")
+ return ""
+ var lure_id := StringName(str(evidence.get("lure_id", "")))
+ if not lure_id.is_empty() and (
+ _local_bag == null or not _local_bag.owns_item(lure_id)
+ ):
+ local_cast_rejected.emit("Lure is unavailable.")
+ return ""
+ var request_id: String = _new_id("cast")
+ var data: Dictionary = {
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "origin": NetworkFishingProtocol.vector3_to_array(origin),
+ "target": NetworkFishingProtocol.vector3_to_array(target),
+ "charge": charge,
+ "rod_id": str(evidence.get("rod_id", "")),
+ "reel_speed": float(evidence.get("reel_speed", 0.0)),
+ "barrier_damage": int(evidence.get("barrier_damage", 0)),
+ "bite_multiplier": float(evidence.get("bite_multiplier", 1.0)),
+ "rarity_multipliers": evidence.get("rarity_multipliers", []),
+ "discovered_fish_ids": evidence.get("discovered_fish_ids", []),
+ "capacity_available": bool(evidence.get("capacity_available", false)),
+ "bait_id": str(bait_id),
+ "lure_id": str(lure_id),
+ }
+ if _session.is_host():
+ _handle_cast_request(_session.get_local_peer_id(), data)
+ else:
+ if not bait_id.is_empty():
+ _pending_local_bait_by_request[request_id] = bait_id
+ submit_cast_request.rpc_id(1, data)
+ return request_id
+
+
+func submit_local_input(held: bool, pressed: bool) -> void:
+ var peer_id: int = _session.get_local_peer_id()
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null:
+ return
+ _local_input_sequence += 1
+ var data: Dictionary = {
+ "attempt_id": attempt.attempt_id,
+ "sequence": _local_input_sequence,
+ "held": held,
+ "pressed": pressed,
+ }
+ if _session.is_host():
+ _handle_fishing_input(peer_id, data)
+ else:
+ submit_fishing_input.rpc_id(1, data)
+
+
+func cancel_local_attempt(reason: String = "Fishing attempt ended.") -> void:
+ if _session == null or not _session.is_gameplay_session_active():
+ return
+ var peer_id: int = _session.get_local_peer_id()
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null:
+ return
+ if _session.is_host():
+ _cancel_attempt(peer_id, reason)
+ else:
+ submit_cancel_request.rpc_id(1, attempt.attempt_id)
+
+
+func cancel_peer_attempt(peer_id: int, reason: String) -> void:
+ if _session != null and _session.is_host() and _attempts.has(peer_id):
+ _cancel_attempt(peer_id, reason)
+
+
+func has_local_attempt() -> bool:
+ return (
+ _session != null
+ and _attempts.has(_session.get_local_peer_id())
+ )
+
+
+func has_peer_attempt(peer_id: int) -> bool:
+ return _attempts.has(peer_id)
+
+
+func _process(delta: float) -> void:
+ if _session == null or not _session.is_host():
+ return
+ var now: float = Time.get_ticks_msec() / 1000.0
+ for peer_id: int in _attempts.keys():
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null:
+ continue
+ match attempt.phase:
+ NetworkFishingAttempt.Phase.WAITING_FOR_BITE:
+ if attempt.bite_confirmation_pending:
+ continue
+ _update_waiting_attempt(attempt, delta)
+ if not _attempts.has(peer_id):
+ continue
+ attempt.bite_time_remaining -= delta
+ if attempt.bite_time_remaining <= 0.0:
+ if &"deferred_fight" in attempt.lure_effects:
+ _set_bite_pending(attempt)
+ else:
+ _start_bite(attempt)
+ NetworkFishingAttempt.Phase.PENDING_CAPACITY:
+ if now >= attempt.capacity_deadline:
+ _cancel_attempt(peer_id, "Fishing attempt ended.")
+ _snapshot_accumulator += delta
+ if _snapshot_accumulator >= NetworkFishingProtocol.SNAPSHOT_RATE:
+ _snapshot_accumulator = fmod(
+ _snapshot_accumulator,
+ NetworkFishingProtocol.SNAPSHOT_RATE
+ )
+ _broadcast_snapshots()
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_cast_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _session.is_host() or not _session.is_authenticated_peer(sender_id):
+ return
+ _handle_cast_request(sender_id, data)
+
+
+func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
+ var validation_error: String = NetworkFishingProtocol.validate_cast_request(
+ data
+ )
+ if not validation_error.is_empty():
+ _send_cast_rejected(peer_id, str(data.get("request_id", "")),
+ validation_error)
+ return
+ var request_id: String = data["request_id"]
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ if ledger.has(request_id):
+ _resend_request_response(peer_id, ledger[request_id])
+ return
+ if str(data["session_id"]) != _session.get_session_id():
+ _record_and_reject(peer_id, request_id, "Fishing attempt ended.")
+ return
+ var now: float = Time.get_ticks_msec() / 1000.0
+ if now - float(_last_cast_time.get(peer_id, -INF)) < MIN_CAST_INTERVAL:
+ _record_and_reject(peer_id, request_id, "Already fishing.")
+ return
+ _last_cast_time[peer_id] = now
+ if _attempts.has(peer_id):
+ _record_and_reject(peer_id, request_id, "Already fishing.")
+ return
+ if not bool(data["capacity_available"]):
+ _record_and_reject(peer_id, request_id, "Cooler is full.")
+ return
+ var rod: ItemData = _item_catalog.get_item_by_id(
+ StringName(str(data["rod_id"]))
+ )
+ if rod == null or rod.category != ItemData.Category.ROD:
+ _record_and_reject(peer_id, request_id, "Select a fishing rod to cast.")
+ return
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null or avatar.is_water_recovery_active():
+ _record_and_reject(peer_id, request_id, "Fishing attempt ended.")
+ return
+ var origin: Vector3 = NetworkFishingProtocol.array_to_vector3(data["origin"])
+ var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"])
+ var authoritative_origin: Vector3 = avatar.get_cast_origin_position()
+ if origin.distance_to(authoritative_origin) > CAST_ORIGIN_TOLERANCE:
+ _record_and_reject(peer_id, request_id, "Cannot fish here.")
+ return
+ var cast_offset: Vector3 = target - authoritative_origin
+ cast_offset.y = 0.0
+ var expected_distance: float = lerpf(
+ _fishing_spot.minimum_cast_distance,
+ _fishing_spot.maximum_cast_distance,
+ float(data["charge"])
+ )
+ var facing: Vector3 = avatar.get_facing_direction()
+ facing.y = 0.0
+ if (
+ cast_offset.length() < _fishing_spot.minimum_cast_distance - 0.25
+ or cast_offset.length() > _fishing_spot.maximum_cast_distance + 0.5
+ or absf(cast_offset.length() - expected_distance) > 1.25
+ or (
+ not facing.is_zero_approx()
+ and facing.normalized().dot(cast_offset.normalized()) < 0.2
+ )
+ ):
+ _record_and_reject(peer_id, request_id, "Cannot fish here.")
+ return
+ var surface: FishingSurfaceSample = _fishing_spot.resolve_fishing_surface(
+ target,
+ authoritative_origin.y,
+ )
+ var region: FishableWaterRegion = surface.water_region
+ var authoritative_target: Vector3 = surface.position
+ if (
+ not surface.is_fishable()
+ or region == null
+ or region.fish_pool == null
+ or not _fishing_spot.is_cast_path_clear(
+ authoritative_origin,
+ authoritative_target
+ )
+ ):
+ _record_and_reject(peer_id, request_id, "Cannot fish here.")
+ return
+ var effects: PlayerItemEffects = (
+ _item_use.get_effects_for_peer(peer_id)
+ if _item_use != null else null
+ )
+ var selected_fish: FishDataType = _select_authoritative_fish(
+ region, data, effects
+ )
+ if selected_fish == null or not selected_fish.is_allowed_in_water(
+ region.water_type
+ ):
+ _record_and_reject(peer_id, request_id, "Nothing is biting here.")
+ return
+ var bait_id: StringName = StringName(str(data.get("bait_id", "")))
+ var avatar_bag: PlayerBag = avatar.bag
+ var owns_authoritative_bag: bool = (
+ peer_id == _session.get_local_peer_id()
+ )
+ var bait: ItemDataType = (
+ _item_catalog.get_item_by_id(bait_id)
+ if not bait_id.is_empty() and _item_catalog != null
+ else null
+ )
+ var lure_id: StringName = StringName(str(data.get("lure_id", "")))
+ var lure: ItemDataType = (
+ _item_catalog.get_item_by_id(lure_id)
+ if not lure_id.is_empty() and _item_catalog != null
+ else null
+ )
+ if (
+ not lure_id.is_empty()
+ and (
+ lure == null
+ or not lure.is_lure()
+ or (
+ owns_authoritative_bag
+ and (
+ avatar_bag == null
+ or not avatar_bag.owns_item(lure_id)
+ )
+ )
+ )
+ ):
+ _record_and_reject(peer_id, request_id, "Lure is unavailable.")
+ return
+ if not bait_id.is_empty() and (
+ bait == null
+ or not bait.is_bait()
+ or (
+ owns_authoritative_bag
+ and (
+ avatar_bag == null
+ or not avatar_bag.remove_item(bait_id, 1)
+ )
+ )
+ ):
+ _record_and_reject(peer_id, request_id, "No bait available.")
+ return
+ var attempt := NetworkFishingAttempt.new()
+ attempt.owner_peer_id = peer_id
+ attempt.request_id = request_id
+ attempt.attempt_id = _new_id("attempt")
+ attempt.session_id = _session.get_session_id()
+ attempt.phase = NetworkFishingAttempt.Phase.WAITING_FOR_BITE
+ attempt.origin = authoritative_origin
+ attempt.target = authoritative_target
+ attempt.bobber_position = authoritative_target
+ attempt.fish_id = selected_fish.id
+ attempt.bait_tags = _bait_tags_for_request(data)
+ attempt.lure_effects.clear()
+ if lure != null:
+ for effect_id: StringName in lure.lure_effects:
+ attempt.lure_effects.append(effect_id)
+ attempt.reel_speed = float(data["reel_speed"]) * (
+ effects.get_reel_multiplier() if effects != null else 1.0
+ )
+ attempt.barrier_damage = int(data["barrier_damage"]) + (
+ effects.get_barrier_bonus() if effects != null else 0
+ )
+ attempt.bite_time_remaining = _fishing_spot.roll_bite_wait_time() * float(
+ effects.get_bite_time_multiplier() if effects != null else 1.0
+ )
+ attempt.controller = CatchController.new()
+ add_child(attempt.controller)
+ attempt.controller.encounter_updated.connect(
+ _on_encounter_updated.bind(peer_id)
+ )
+ attempt.controller.caught.connect(_on_attempt_caught.bind(peer_id))
+ attempt.controller.escaped.connect(_on_attempt_escaped.bind(peer_id))
+ attempt.set_meta("snapshot", _make_waiting_snapshot(attempt))
+ _attempts[peer_id] = attempt
+ avatar.set_movement_enabled(false)
+ var response: Dictionary = _make_cast_accepted(attempt)
+ _record_request_response(peer_id, request_id, response)
+ _broadcast_cast_accepted(response)
+
+
+func _update_waiting_attempt(
+ attempt: NetworkFishingAttempt,
+ delta: float,
+) -> void:
+ if not attempt.input_held:
+ return
+ var flat_offset: Vector3 = attempt.target - attempt.origin
+ flat_offset.y = 0.0
+ var withdrawable_distance: float = (
+ flat_offset.length() - _fishing_spot.withdrawal_cancel_distance
+ )
+ if withdrawable_distance <= 0.0:
+ _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal")
+ return
+ attempt.withdrawal_progress = minf(
+ attempt.withdrawal_progress
+ + _fishing_spot.withdrawal_rate * delta / withdrawable_distance,
+ 1.0
+ )
+ var endpoint: Vector3 = attempt.origin + (
+ flat_offset.normalized()
+ * _fishing_spot.withdrawal_cancel_distance
+ )
+ endpoint.y = attempt.target.y
+ var desired: Vector3 = attempt.target.lerp(
+ endpoint,
+ attempt.withdrawal_progress
+ )
+ var withdrawal_surface: FishingSurfaceSample = (
+ _fishing_spot.resolve_safe_withdrawal_surface(
+ attempt.bobber_position,
+ desired,
+ endpoint - attempt.bobber_position,
+ )
+ )
+ if not withdrawal_surface.is_fishable():
+ _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal")
+ return
+ attempt.bobber_position = withdrawal_surface.position
+ attempt.set_meta("snapshot", _make_waiting_snapshot(attempt))
+ if is_equal_approx(attempt.withdrawal_progress, 1.0):
+ _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal")
+
+
+func _make_waiting_snapshot(
+ attempt: NetworkFishingAttempt,
+) -> Dictionary:
+ return {
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": attempt.owner_peer_id,
+ "phase": int(attempt.phase),
+ "progress": 0.0,
+ "chase_progress": 0.0,
+ "barrier_positions": [],
+ "barrier_health": [],
+ "barrier_max_health": [],
+ "active_barrier_index": -1,
+ "visible": false,
+ "acknowledged_input_sequence": attempt.last_input_sequence,
+ "bobber_position": NetworkFishingProtocol.vector3_to_array(
+ attempt.bobber_position
+ ),
+ }
+
+
+func _select_authoritative_fish(
+ region: FishableWaterRegion,
+ data: Dictionary,
+ effects: PlayerItemEffects,
+) -> FishDataType:
+ var evidence_log := CollectionLogType.new()
+ for value: Variant in data["discovered_fish_ids"]:
+ var fish_id := StringName(str(value))
+ if _fish_catalog.get_fish_by_id(fish_id) == null:
+ return null
+ evidence_log.mark_discovered(fish_id)
+ var selector := FishSelectorType.new()
+ selector.undiscovered_weight_multiplier = (
+ _fishing_spot.undiscovered_weight_multiplier
+ )
+ for rarity: int in range(FishDataType.Rarity.size()):
+ selector.rarity_weight_multipliers.append(
+ effects.get_rarity_weight_multiplier(rarity)
+ if effects != null else 1.0
+ )
+ selector.begin_roll()
+ var context: FishingContextType = _fishing_spot.build_network_context(region)
+ context.active_bait_tags = _bait_tags_for_request(data)
+ var selected_fish := selector.select_fish(
+ region.fish_pool, context, evidence_log
+ )
+ evidence_log.free()
+ return selected_fish
+
+
+func _bait_tags_for_request(data: Dictionary) -> Array[StringName]:
+ var bait_id: StringName = StringName(str(data.get("bait_id", "")))
+ if bait_id.is_empty() or _item_catalog == null:
+ return []
+ var bait: ItemDataType = _item_catalog.get_item_by_id(bait_id)
+ return bait.bait_tags.duplicate() if bait != null and bait.is_bait() else []
+
+
+func _set_bite_pending(attempt: NetworkFishingAttempt) -> void:
+ if (
+ attempt.phase != NetworkFishingAttempt.Phase.WAITING_FOR_BITE
+ or attempt.bite_confirmation_pending
+ ):
+ return
+ attempt.bite_confirmation_pending = true
+ attempt.bite_time_remaining = 0.0
+ attempt.input_held = false
+ var data: Dictionary = {
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": attempt.owner_peer_id,
+ }
+ _apply_bite_pending(data)
+ receive_bite_pending.rpc(data)
+
+
+func _start_bite(attempt: NetworkFishingAttempt) -> void:
+ if attempt.phase != NetworkFishingAttempt.Phase.WAITING_FOR_BITE:
+ return
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(attempt.fish_id)
+ if fish == null or fish.catch_profile == null:
+ _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.")
+ return
+ attempt.bite_confirmation_pending = false
+ attempt.phase = NetworkFishingAttempt.Phase.FIGHTING
+ attempt.encounter_seed = _new_seed()
+ var selector := FishSelectorType.new()
+ selector.use_deterministic_test_seed = true
+ selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF
+ selector.begin_roll()
+ var fish_catch: FishCatch = selector.create_catch(fish, attempt.bait_tags)
+ if fish_catch == null or not fish_catch.is_valid():
+ _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.")
+ return
+ attempt.catch_payload = fish_catch.to_network_dict()
+ attempt.controller.start_authoritative_encounter(
+ fish.catch_profile,
+ attempt.reel_speed,
+ attempt.barrier_damage,
+ attempt.encounter_seed,
+ fish_catch.quality,
+ )
+ var data: Dictionary = {
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": attempt.owner_peer_id,
+ }
+ _apply_bite_started(data)
+ receive_bite_started.rpc(data)
+
+
+@rpc("any_peer", "call_remote", "unreliable_ordered", 3)
+func submit_fishing_input(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _session.is_host() or not _session.is_authenticated_peer(sender_id):
+ return
+ _handle_fishing_input(sender_id, data)
+
+
+func _handle_fishing_input(peer_id: int, data: Dictionary) -> void:
+ if not NetworkFishingProtocol.validate_input(data):
+ return
+ var now: float = Time.get_ticks_msec() / 1000.0
+ if now - float(_last_input_time.get(peer_id, -INF)) < 1.0 / 120.0:
+ return
+ _last_input_time[peer_id] = now
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if (
+ attempt == null
+ or attempt.attempt_id != str(data["attempt_id"])
+ or int(data["sequence"]) <= attempt.last_input_sequence
+ ):
+ return
+ attempt.last_input_sequence = int(data["sequence"])
+ attempt.input_held = bool(data["held"])
+ if (
+ attempt.phase == NetworkFishingAttempt.Phase.WAITING_FOR_BITE
+ and attempt.bite_confirmation_pending
+ ):
+ if bool(data["pressed"]):
+ attempt.input_held = false
+ _start_bite(attempt)
+ return
+ if attempt.phase != NetworkFishingAttempt.Phase.FIGHTING:
+ return
+ attempt.controller.set_reel_input(attempt.input_held)
+ if bool(data["pressed"]):
+ attempt.controller.handle_primary_pressed()
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_cancel_request(attempt_id: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ var attempt: NetworkFishingAttempt = _attempts.get(sender_id)
+ if (
+ not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ or attempt == null
+ or attempt.attempt_id != attempt_id
+ ):
+ return
+ _cancel_attempt(sender_id, "")
+
+
+func _on_encounter_updated(
+ progress: float,
+ chase_progress: float,
+ barrier_positions: PackedFloat32Array,
+ barrier_health: PackedInt32Array,
+ barrier_max_health: PackedInt32Array,
+ active_barrier_index: int,
+ visible: bool,
+ peer_id: int,
+) -> void:
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null or attempt.phase != NetworkFishingAttempt.Phase.FIGHTING:
+ return
+ attempt.set_meta("snapshot", {
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": peer_id,
+ "phase": int(attempt.phase),
+ "progress": progress,
+ "chase_progress": chase_progress,
+ "barrier_positions": Array(barrier_positions),
+ "barrier_health": Array(barrier_health),
+ "barrier_max_health": Array(barrier_max_health),
+ "active_barrier_index": active_barrier_index,
+ "visible": visible,
+ "acknowledged_input_sequence": attempt.last_input_sequence,
+ "bobber_position": NetworkFishingProtocol.vector3_to_array(
+ attempt.bobber_position
+ ),
+ })
+
+
+func _broadcast_snapshots() -> void:
+ var snapshots: Array[Dictionary] = []
+ for attempt: NetworkFishingAttempt in _attempts.values():
+ var snapshot: Dictionary = attempt.get_meta("snapshot", {})
+ if not snapshot.is_empty():
+ snapshots.append(snapshot)
+ if snapshots.is_empty():
+ return
+ _apply_snapshots(snapshots)
+ receive_fishing_snapshots.rpc(snapshots)
+
+
+@rpc("authority", "call_remote", "unreliable_ordered", 4)
+func receive_fishing_snapshots(snapshots: Array) -> void:
+ _apply_snapshots(snapshots)
+
+
+func _apply_snapshots(snapshots: Array) -> void:
+ var local_peer_id: int = _session.get_local_peer_id()
+ for value: Variant in snapshots:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var snapshot: Dictionary = value
+ if not _valid_snapshot(snapshot):
+ continue
+ var owner_peer_id: int = int(snapshot["owner_peer_id"])
+ if owner_peer_id == local_peer_id:
+ local_snapshot_received.emit(snapshot)
+ else:
+ var presentation := _get_remote_presentation(owner_peer_id)
+ if presentation != null:
+ presentation.update_bobber(
+ NetworkFishingProtocol.array_to_vector3(
+ snapshot["bobber_position"]
+ )
+ )
+
+
+func _valid_snapshot(data: Dictionary) -> bool:
+ if not (
+ typeof(data.get("attempt_id")) == TYPE_STRING
+ and typeof(data.get("owner_peer_id")) == TYPE_INT
+ and typeof(data.get("phase")) == TYPE_INT
+ and typeof(data.get("progress")) in [TYPE_FLOAT, TYPE_INT]
+ and typeof(data.get("chase_progress")) in [TYPE_FLOAT, TYPE_INT]
+ and typeof(data.get("barrier_positions")) == TYPE_ARRAY
+ and typeof(data.get("barrier_health")) == TYPE_ARRAY
+ and typeof(data.get("barrier_max_health")) == TYPE_ARRAY
+ and typeof(data.get("active_barrier_index")) == TYPE_INT
+ and typeof(data.get("visible")) == TYPE_BOOL
+ and typeof(data.get("bobber_position")) == TYPE_ARRAY
+ and NetworkFishingProtocol.array_to_vector3(
+ data["bobber_position"]
+ ).is_finite()
+ ):
+ return false
+ var positions: Array = data["barrier_positions"]
+ var health: Array = data["barrier_health"]
+ var maximum_health: Array = data["barrier_max_health"]
+ if (
+ positions.size() > 32
+ or health.size() != positions.size()
+ or maximum_health.size() != positions.size()
+ ):
+ return false
+ var progress: float = float(data["progress"])
+ var chase: float = float(data["chase_progress"])
+ if (
+ not is_finite(progress)
+ or not is_finite(chase)
+ or progress < -0.01
+ or progress > 1.01
+ or chase < -10.0
+ or chase > 1.01
+ ):
+ return false
+ for index: int in range(positions.size()):
+ if (
+ typeof(positions[index]) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(positions[index]))
+ or typeof(health[index]) != TYPE_INT
+ or typeof(maximum_health[index]) != TYPE_INT
+ or int(health[index]) < 0
+ or int(maximum_health[index]) < 0
+ or int(health[index]) > int(maximum_health[index])
+ ):
+ return false
+ return true
+
+
+func _on_attempt_caught(peer_id: int) -> void:
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null or attempt.phase != NetworkFishingAttempt.Phase.FIGHTING:
+ return
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(attempt.fish_id)
+ if fish == null:
+ _cancel_attempt(peer_id, "Fishing attempt ended.")
+ return
+ var fish_catch: FishCatch = FishCatchType.from_network_dict(
+ attempt.catch_payload,
+ fish,
+ )
+ if fish_catch == null or not fish_catch.is_valid():
+ _cancel_attempt(peer_id, "Fishing attempt ended.")
+ return
+ attempt.phase = NetworkFishingAttempt.Phase.PENDING_CAPACITY
+ attempt.result_id = _new_id("result")
+ attempt.catch_payload = fish_catch.to_network_dict()
+ attempt.capacity_nonce = _new_id("capacity")
+ attempt.capacity_deadline = (
+ Time.get_ticks_msec() / 1000.0 + CAPACITY_RESPONSE_TIMEOUT
+ )
+ var probe: Dictionary = {
+ "attempt_id": attempt.attempt_id,
+ "capacity_nonce": attempt.capacity_nonce,
+ "catch_id": str(fish_catch.catch_id),
+ }
+ if peer_id == _session.get_local_peer_id():
+ _handle_local_capacity_probe(probe)
+ else:
+ receive_capacity_probe.rpc_id(peer_id, probe)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_capacity_probe(data: Dictionary) -> void:
+ _handle_local_capacity_probe(data)
+
+
+func _handle_local_capacity_probe(data: Dictionary) -> void:
+ if (
+ typeof(data.get("attempt_id")) != TYPE_STRING
+ or typeof(data.get("capacity_nonce")) != TYPE_STRING
+ or typeof(data.get("catch_id")) != TYPE_STRING
+ ):
+ return
+ var can_accept: bool = (
+ _local_inventory != null
+ and _local_capacity != null
+ and (
+ _local_inventory.contains_catch_id(StringName(data["catch_id"]))
+ or _local_inventory.get_all_catches().size()
+ < _local_capacity.get_capacity()
+ )
+ )
+ if _session.is_host():
+ _handle_capacity_response(
+ _session.get_local_peer_id(),
+ str(data["attempt_id"]),
+ str(data["capacity_nonce"]),
+ can_accept
+ )
+ else:
+ submit_capacity_response.rpc_id(
+ 1,
+ str(data["attempt_id"]),
+ str(data["capacity_nonce"]),
+ can_accept
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_capacity_response(
+ attempt_id: String,
+ capacity_nonce: String,
+ can_accept: bool,
+) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _session.is_host() or not _session.is_authenticated_peer(sender_id):
+ return
+ _handle_capacity_response(
+ sender_id, attempt_id, capacity_nonce, can_accept
+ )
+
+
+func _handle_capacity_response(
+ peer_id: int,
+ attempt_id: String,
+ capacity_nonce: String,
+ can_accept: bool,
+) -> void:
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if (
+ attempt == null
+ or attempt.phase != NetworkFishingAttempt.Phase.PENDING_CAPACITY
+ or attempt.attempt_id != attempt_id
+ or attempt.capacity_nonce != capacity_nonce
+ ):
+ return
+ if not can_accept:
+ _cancel_attempt(peer_id, "Cooler is full.")
+ return
+ _finalize_catch(attempt)
+
+
+func _finalize_catch(attempt: NetworkFishingAttempt) -> void:
+ attempt.phase = NetworkFishingAttempt.Phase.CAUGHT
+ var outcome: Dictionary = {
+ "result_id": attempt.result_id,
+ "request_id": attempt.request_id,
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": attempt.owner_peer_id,
+ "session_id": attempt.session_id,
+ "outcome": int(NetworkFishingProtocol.Outcome.CATCH),
+ "catch": attempt.catch_payload.duplicate(true),
+ }
+ if attempt.owner_peer_id == _session.get_local_peer_id():
+ _apply_target_outcome(outcome)
+ else:
+ receive_target_outcome.rpc_id(attempt.owner_peer_id, outcome)
+ _broadcast_public_outcome(
+ attempt,
+ &"catch",
+ "",
+ attempt.catch_payload,
+ )
+ _dispose_attempt(attempt.owner_peer_id)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_target_outcome(data: Dictionary) -> void:
+ _apply_target_outcome(data)
+
+
+func _apply_target_outcome(data: Dictionary) -> void:
+ if not _validate_target_outcome(data):
+ return
+ var result_id: String = data["result_id"]
+ var catch_data: Dictionary = data["catch"]
+ var catch_id := StringName(str(catch_data.get("catch_id", "")))
+ if _result_ledgers.has(result_id):
+ _acknowledge_result(result_id, catch_id)
+ return
+ var fish_id := StringName(str(catch_data.get("fish_id", "")))
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id)
+ var fish_catch: FishCatch = FishCatchType.from_network_dict(
+ catch_data, fish
+ )
+ if fish_catch == null:
+ return
+ var already_owned: bool = _local_inventory.contains_catch_id(catch_id)
+ if (
+ not already_owned
+ and _local_inventory.get_all_catches().size()
+ >= _local_capacity.get_capacity()
+ ):
+ return
+ if not already_owned:
+ var experience_award: int = (
+ FishExperienceType.calculate_for_collection(
+ fish_catch,
+ _local_collection,
+ )
+ )
+ _local_inventory.add_catch(fish_catch)
+ _local_collection.mark_quality_discovered(
+ fish_id,
+ fish_catch.quality,
+ )
+ _local_experience.award_experience(experience_award)
+ if not _save_manager.save_if_dirty():
+ return
+ _result_ledgers[result_id] = true
+ _bound_result_ledger()
+ if not _session.is_host():
+ _attempts.erase(_session.get_local_peer_id())
+ local_catch_received.emit(fish_catch)
+ _acknowledge_result(result_id, catch_id)
+
+
+func _validate_target_outcome(data: Dictionary) -> bool:
+ return (
+ typeof(data.get("result_id")) == TYPE_STRING
+ and typeof(data.get("request_id")) == TYPE_STRING
+ and typeof(data.get("attempt_id")) == TYPE_STRING
+ and typeof(data.get("owner_peer_id")) == TYPE_INT
+ and int(data["owner_peer_id"]) == _session.get_local_peer_id()
+ and typeof(data.get("session_id")) == TYPE_STRING
+ and str(data["session_id"]) == _session.get_session_id()
+ and int(data.get("outcome", -1))
+ == NetworkFishingProtocol.Outcome.CATCH
+ and typeof(data.get("catch")) == TYPE_DICTIONARY
+ )
+
+
+func _acknowledge_result(result_id: String, catch_id: StringName) -> void:
+ if _session.is_host():
+ return
+ acknowledge_fishing_result.rpc_id(1, result_id, str(catch_id))
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func acknowledge_fishing_result(result_id: String, catch_id: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ or result_id.is_empty()
+ or catch_id.is_empty()
+ ):
+ return
+ _result_acknowledgements[result_id] = catch_id
+ while _result_acknowledgements.size() > MAX_LEDGER_ENTRIES_PER_PEER:
+ _result_acknowledgements.erase(
+ _result_acknowledgements.keys().front()
+ )
+
+
+func _on_attempt_escaped(peer_id: int) -> void:
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null:
+ return
+ attempt.phase = NetworkFishingAttempt.Phase.ESCAPED
+ _broadcast_public_outcome(attempt, &"escape", "")
+ _dispose_attempt(peer_id)
+
+
+func _cancel_attempt(
+ peer_id: int,
+ message: String,
+ outcome: StringName = &"cancelled",
+) -> void:
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ if attempt == null:
+ return
+ attempt.phase = NetworkFishingAttempt.Phase.CANCELLED
+ _broadcast_public_outcome(attempt, outcome, message)
+ _dispose_attempt(peer_id)
+
+
+func _broadcast_public_outcome(
+ attempt: NetworkFishingAttempt,
+ outcome: StringName,
+ message: String,
+ catch_payload: Dictionary = {},
+) -> void:
+ var data: Dictionary = {
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": attempt.owner_peer_id,
+ "outcome": str(outcome),
+ "message": message.left(128),
+ }
+ if outcome == &"catch" and not catch_payload.is_empty():
+ data["catch"] = catch_payload.duplicate(true)
+ _apply_public_outcome(data)
+ receive_public_outcome.rpc(data)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_public_outcome(data: Dictionary) -> void:
+ _apply_public_outcome(data)
+
+
+func _apply_public_outcome(data: Dictionary) -> void:
+ if (
+ typeof(data.get("owner_peer_id")) != TYPE_INT
+ or typeof(data.get("outcome")) != TYPE_STRING
+ or typeof(data.get("message")) != TYPE_STRING
+ ):
+ return
+ var peer_id: int = data["owner_peer_id"]
+ if peer_id == _session.get_local_peer_id():
+ if str(data["outcome"]) != "catch":
+ if not _session.is_host():
+ _attempts.erase(peer_id)
+ local_attempt_ended.emit(
+ StringName(str(data["outcome"])),
+ str(data["message"])
+ )
+ else:
+ var outcome := StringName(str(data["outcome"]))
+ if outcome in [&"catch", &"escape", &"withdrawal"]:
+ _return_remote_presentation(peer_id, outcome, data)
+ else:
+ _cleanup_remote_presentation(peer_id)
+
+
+func _make_cast_accepted(attempt: NetworkFishingAttempt) -> Dictionary:
+ return {
+ "accepted": true,
+ "request_id": attempt.request_id,
+ "attempt_id": attempt.attempt_id,
+ "owner_peer_id": attempt.owner_peer_id,
+ "origin": NetworkFishingProtocol.vector3_to_array(attempt.origin),
+ "target": NetworkFishingProtocol.vector3_to_array(attempt.target),
+ }
+
+
+func _broadcast_cast_accepted(data: Dictionary) -> void:
+ _apply_cast_accepted(data)
+ receive_cast_accepted.rpc(data)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_cast_accepted(data: Dictionary) -> void:
+ _apply_cast_accepted(data)
+
+
+func _apply_cast_accepted(data: Dictionary) -> void:
+ if (
+ typeof(data.get("attempt_id")) != TYPE_STRING
+ or typeof(data.get("owner_peer_id")) != TYPE_INT
+ or typeof(data.get("origin")) != TYPE_ARRAY
+ or typeof(data.get("target")) != TYPE_ARRAY
+ ):
+ return
+ var peer_id: int = data["owner_peer_id"]
+ var origin: Vector3 = NetworkFishingProtocol.array_to_vector3(
+ data.get("origin", [])
+ )
+ var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"])
+ if not origin.is_finite() or not target.is_finite():
+ return
+ if peer_id == _session.get_local_peer_id():
+ var attempt := NetworkFishingAttempt.new()
+ attempt.owner_peer_id = peer_id
+ attempt.request_id = str(data.get("request_id", ""))
+ attempt.attempt_id = str(data["attempt_id"])
+ attempt.session_id = _session.get_session_id()
+ attempt.phase = NetworkFishingAttempt.Phase.WAITING_FOR_BITE
+ attempt.target = target
+ # On the host this replaces the same authoritative value with itself.
+ if not _session.is_host():
+ _attempts[peer_id] = attempt
+ var pending_bait_id: StringName = (
+ _pending_local_bait_by_request.get(
+ attempt.request_id,
+ StringName(),
+ )
+ )
+ _pending_local_bait_by_request.erase(attempt.request_id)
+ if not pending_bait_id.is_empty() and (
+ _local_bag == null
+ or not _local_bag.remove_item(pending_bait_id, 1)
+ ):
+ cancel_local_attempt("No bait available.")
+ local_cast_rejected.emit("No bait available.")
+ return
+ local_cast_accepted.emit(attempt.attempt_id, target)
+ else:
+ var presentation := _get_remote_presentation(peer_id)
+ if presentation != null:
+ presentation.show_cast(origin, target)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_bite_pending(data: Dictionary) -> void:
+ _apply_bite_pending(data)
+
+
+func _apply_bite_pending(data: Dictionary) -> void:
+ if (
+ typeof(data.get("attempt_id")) != TYPE_STRING
+ or typeof(data.get("owner_peer_id")) != TYPE_INT
+ ):
+ return
+ if int(data["owner_peer_id"]) == _session.get_local_peer_id():
+ local_bite_pending.emit(str(data["attempt_id"]))
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_bite_started(data: Dictionary) -> void:
+ _apply_bite_started(data)
+
+
+func _apply_bite_started(data: Dictionary) -> void:
+ if (
+ typeof(data.get("attempt_id")) != TYPE_STRING
+ or typeof(data.get("owner_peer_id")) != TYPE_INT
+ ):
+ return
+ var peer_id: int = data["owner_peer_id"]
+ if peer_id == _session.get_local_peer_id():
+ local_bite_started.emit(str(data["attempt_id"]))
+ else:
+ var presentation := _get_remote_presentation(peer_id)
+ if presentation != null:
+ presentation.show_bite()
+
+
+func _record_and_reject(
+ peer_id: int,
+ request_id: String,
+ message: String,
+) -> void:
+ var response: Dictionary = {
+ "accepted": false,
+ "request_id": request_id,
+ "message": message.left(128),
+ }
+ _record_request_response(peer_id, request_id, response)
+ _send_cast_rejected(peer_id, request_id, message)
+
+
+func _send_cast_rejected(
+ peer_id: int,
+ request_id: String,
+ message: String,
+) -> void:
+ if peer_id == _session.get_local_peer_id():
+ local_cast_rejected.emit(message)
+ else:
+ receive_cast_rejected.rpc_id(peer_id, request_id, message.left(128))
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_cast_rejected(request_id: String, message: String) -> void:
+ _pending_local_bait_by_request.erase(request_id)
+ local_cast_rejected.emit(message)
+
+
+func _record_request_response(
+ peer_id: int,
+ request_id: String,
+ response: Dictionary,
+) -> void:
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ ledger[request_id] = response.duplicate(true)
+ while ledger.size() > MAX_LEDGER_ENTRIES_PER_PEER:
+ ledger.erase(ledger.keys().front())
+ _request_ledgers[peer_id] = ledger
+
+
+func _resend_request_response(peer_id: int, response: Dictionary) -> void:
+ if bool(response.get("accepted", false)):
+ if peer_id == _session.get_local_peer_id():
+ _apply_cast_accepted(response)
+ else:
+ receive_cast_accepted.rpc_id(peer_id, response)
+ else:
+ _send_cast_rejected(
+ peer_id,
+ str(response.get("request_id", "")),
+ str(response.get("message", "Fishing attempt ended."))
+ )
+
+
+func _get_remote_presentation(
+ peer_id: int,
+) -> RemoteFishingPresentation:
+ var existing: RemoteFishingPresentation = _remote_presentations.get(peer_id)
+ if existing != null and is_instance_valid(existing):
+ return existing
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ return null
+ var presentation := RemotePresentationType.new()
+ presentation.name = "RemoteFishing_%d" % peer_id
+ add_child(presentation)
+ presentation.setup(avatar)
+ _remote_presentations[peer_id] = presentation
+ return presentation
+
+
+func _cleanup_remote_presentation(peer_id: int) -> void:
+ var presentation: RemoteFishingPresentation = _remote_presentations.get(peer_id)
+ _remote_presentations.erase(peer_id)
+ if presentation != null and is_instance_valid(presentation):
+ presentation.cleanup()
+ presentation.queue_free()
+
+
+func _return_remote_presentation(
+ peer_id: int,
+ outcome: StringName,
+ data: Dictionary,
+) -> void:
+ var presentation: RemoteFishingPresentation = _remote_presentations.get(
+ peer_id
+ )
+ _remote_presentations.erase(peer_id)
+ if presentation == null or not is_instance_valid(presentation):
+ return
+ presentation.return_completed.connect(
+ presentation.queue_free,
+ CONNECT_ONE_SHOT,
+ )
+ var showcase_catch: FishCatch = null
+ if outcome == &"catch" and typeof(data.get("catch")) == TYPE_DICTIONARY:
+ var catch_data: Dictionary = data["catch"]
+ var fish_id := StringName(str(catch_data.get("fish_id", "")))
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id)
+ showcase_catch = FishCatchType.from_network_dict(catch_data, fish)
+ presentation.play_return(showcase_catch)
+
+
+func _dispose_attempt(peer_id: int) -> void:
+ var attempt: NetworkFishingAttempt = _attempts.get(peer_id)
+ _attempts.erase(peer_id)
+ if attempt != null and attempt.controller != null:
+ attempt.controller.reset()
+ attempt.controller.queue_free()
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ var local_return_is_active: bool = (
+ peer_id == _session.get_local_peer_id()
+ and _fishing_spot != null
+ and _fishing_spot.is_returning()
+ )
+ if avatar != null and not local_return_is_active:
+ avatar.set_movement_enabled(true)
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ if _session.is_host() and _attempts.has(peer_id):
+ _cancel_attempt(peer_id, "Fishing attempt ended.")
+ else:
+ _attempts.erase(peer_id)
+ _cleanup_remote_presentation(peer_id)
+ _request_ledgers.erase(peer_id)
+ _last_cast_time.erase(peer_id)
+ _last_input_time.erase(peer_id)
+
+
+func _on_avatar_removed(peer_id: int) -> void:
+ _cleanup_remote_presentation(peer_id)
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _clear_all()
+
+
+func _clear_all() -> void:
+ for peer_id: int in _attempts.keys():
+ var attempt: NetworkFishingAttempt = _attempts[peer_id]
+ if attempt != null and attempt.controller != null:
+ attempt.controller.queue_free()
+ _attempts.clear()
+ for peer_id: int in _remote_presentations.keys():
+ _cleanup_remote_presentation(peer_id)
+ _request_ledgers.clear()
+ _result_ledgers.clear()
+ _result_acknowledgements.clear()
+ _last_cast_time.clear()
+ _last_input_time.clear()
+ _snapshot_accumulator = 0.0
+ _local_input_sequence = 0
+
+
+func _bound_result_ledger() -> void:
+ while _result_ledgers.size() > MAX_LEDGER_ENTRIES_PER_PEER:
+ _result_ledgers.erase(_result_ledgers.keys().front())
+
+
+func _new_id(prefix: String) -> String:
+ return "%s:%s" % [
+ prefix,
+ Crypto.new().generate_random_bytes(16).hex_encode(),
+ ]
+
+
+func _new_seed() -> int:
+ var bytes: PackedByteArray = Crypto.new().generate_random_bytes(8)
+ var seed_value: int = 0
+ for byte: int in bytes:
+ seed_value = (seed_value << 8) ^ byte
+ return seed_value
diff --git a/network/network_fishing_service.gd.uid b/network/network_fishing_service.gd.uid
new file mode 100644
index 0000000..921f52c
--- /dev/null
+++ b/network/network_fishing_service.gd.uid
@@ -0,0 +1 @@
+uid://b4v56htdn1y2l
diff --git a/network/network_identity_crypto.gd b/network/network_identity_crypto.gd
new file mode 100644
index 0000000..4886cca
--- /dev/null
+++ b/network/network_identity_crypto.gd
@@ -0,0 +1,144 @@
+class_name NetworkIdentityCrypto
+extends RefCounted
+
+const ALGORITHM: String = "RSA-3072-SHA256"
+const FINGERPRINT_LENGTH: int = 64
+const MAX_PUBLIC_KEY_BYTES: int = 8192
+const MAX_SIGNATURE_BYTES: int = 1024
+const DOMAIN_PREFIX: String = "NETFISHING"
+const IDENTITY_VERSION: String = "identity_v1"
+
+
+static func normalize_public_pem(value: String) -> String:
+ var normalized := value.replace("\r\n", "\n").replace("\r", "\n").strip_edges()
+ return normalized + "\n" if not normalized.is_empty() else ""
+
+
+static func fingerprint_public_pem(value: String) -> String:
+ var normalized := normalize_public_pem(value)
+ return normalized.sha256_text() if not normalized.is_empty() else ""
+
+
+static func valid_fingerprint(value: Variant) -> bool:
+ if typeof(value) != TYPE_STRING:
+ return false
+ var text := str(value)
+ if text.length() != FINGERPRINT_LENGTH:
+ return false
+ for character: String in text:
+ if character not in "0123456789abcdef":
+ return false
+ return true
+
+
+static func format_fingerprint(value: String, groups: int = 5) -> String:
+ if not valid_fingerprint(value):
+ return "Unavailable"
+ var visible := value.left(clampi(groups, 1, 8) * 4).to_upper()
+ var parts := PackedStringArray()
+ for offset: int in range(0, visible.length(), 4):
+ parts.append(visible.substr(offset, 4))
+ return "-".join(parts)
+
+
+static func compact_suffix(value: String) -> String:
+ return value.right(6).to_upper() if valid_fingerprint(value) else "??????"
+
+
+static func secure_id(byte_count: int = 16) -> String:
+ return Crypto.new().generate_random_bytes(byte_count).hex_encode()
+
+
+static func canonical_bytes(domain: String, fields: Array) -> PackedByteArray:
+ var output := PackedByteArray()
+ _append_string(output, DOMAIN_PREFIX)
+ _append_string(output, IDENTITY_VERSION)
+ _append_string(output, domain)
+ for value: Variant in fields:
+ match typeof(value):
+ TYPE_STRING, TYPE_STRING_NAME:
+ output.append(1)
+ _append_string(output, str(value))
+ TYPE_INT:
+ output.append(2)
+ _append_i64(output, int(value))
+ TYPE_BOOL:
+ output.append(3)
+ output.append(1 if bool(value) else 0)
+ TYPE_PACKED_BYTE_ARRAY:
+ output.append(4)
+ _append_bytes(output, value)
+ _:
+ return PackedByteArray()
+ return output
+
+
+static func digest(domain: String, fields: Array) -> PackedByteArray:
+ var bytes := canonical_bytes(domain, fields)
+ if bytes.is_empty():
+ return PackedByteArray()
+ var context := HashingContext.new()
+ if context.start(HashingContext.HASH_SHA256) != OK:
+ return PackedByteArray()
+ context.update(bytes)
+ return context.finish()
+
+
+static func sign_fields(
+ private_key: CryptoKey, domain: String, fields: Array
+) -> PackedByteArray:
+ if private_key == null:
+ return PackedByteArray()
+ var value := digest(domain, fields)
+ return (
+ Crypto.new().sign(HashingContext.HASH_SHA256, value, private_key)
+ if not value.is_empty() else PackedByteArray()
+ )
+
+
+static func verify_fields(
+ public_key: CryptoKey,
+ domain: String,
+ fields: Array,
+ signature: PackedByteArray,
+) -> bool:
+ if (
+ public_key == null
+ or signature.is_empty()
+ or signature.size() > MAX_SIGNATURE_BYTES
+ ):
+ return false
+ var value := digest(domain, fields)
+ return (
+ not value.is_empty()
+ and Crypto.new().verify(
+ HashingContext.HASH_SHA256, value, signature, public_key
+ )
+ )
+
+
+static func load_public_key(public_pem: String) -> CryptoKey:
+ var normalized := normalize_public_pem(public_pem)
+ if normalized.is_empty() or normalized.to_utf8_buffer().size() > MAX_PUBLIC_KEY_BYTES:
+ return null
+ var key := CryptoKey.new()
+ return key if key.load_from_string(normalized, true) == OK else null
+
+
+static func _append_string(output: PackedByteArray, value: String) -> void:
+ _append_bytes(output, value.to_utf8_buffer())
+
+
+static func _append_bytes(output: PackedByteArray, value: PackedByteArray) -> void:
+ _append_u32(output, value.size())
+ output.append_array(value)
+
+
+static func _append_u32(output: PackedByteArray, value: int) -> void:
+ for shift: int in [24, 16, 8, 0]:
+ output.append((value >> shift) & 0xff)
+
+
+static func _append_i64(output: PackedByteArray, value: int) -> void:
+ for shift: int in [56, 48, 40, 32, 24, 16, 8, 0]:
+ output.append((value >> shift) & 0xff)
diff --git a/network/network_identity_crypto.gd.uid b/network/network_identity_crypto.gd.uid
new file mode 100644
index 0000000..e11459e
--- /dev/null
+++ b/network/network_identity_crypto.gd.uid
@@ -0,0 +1 @@
+uid://bjb5n6ayqkb3q
diff --git a/network/network_item_protocol.gd b/network/network_item_protocol.gd
new file mode 100644
index 0000000..7ec5b7b
--- /dev/null
+++ b/network/network_item_protocol.gd
@@ -0,0 +1,83 @@
+class_name NetworkItemProtocol
+extends RefCounted
+
+const ITEM_USE_CAPABILITY: StringName = &"item_use_v1"
+const EQUIPMENT_CAPABILITY: StringName = &"equipment_v1"
+const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
+const MAX_ID_LENGTH: int = 96
+const MAX_ITEM_ID_LENGTH: int = 96
+const MAX_MESSAGE_LENGTH: int = 160
+
+
+static func validate_use_request(data: Variant) -> String:
+ if typeof(data) != TYPE_DICTIONARY:
+ return "Item use could not be completed."
+ var value: Dictionary = data
+ for key: String in [
+ "request_id", "session_id", "item_id", "quantity",
+ ]:
+ if not value.has(key):
+ return "Item use could not be completed."
+ if (
+ not _valid_id(value["request_id"])
+ or not _valid_id(value["session_id"])
+ or typeof(value["item_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or str(value["item_id"]).is_empty()
+ or str(value["item_id"]).length() > MAX_ITEM_ID_LENGTH
+ or typeof(value["quantity"]) != TYPE_INT
+ or int(value["quantity"]) < 1
+ or int(value["quantity"]) > 99
+ ):
+ return "Item use could not be completed."
+ return ""
+
+
+static func validate_use_result(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ _valid_id(value.get("result_id"))
+ and _valid_id(value.get("request_id"))
+ and _valid_id(value.get("session_id"))
+ and typeof(value.get("target_peer_id")) == TYPE_INT
+ and typeof(value.get("accepted")) == TYPE_BOOL
+ and typeof(value.get("item_id")) in [TYPE_STRING, TYPE_STRING_NAME]
+ and not str(value["item_id"]).is_empty()
+ and str(value["item_id"]).length() <= MAX_ITEM_ID_LENGTH
+ and typeof(value.get("quantity")) == TYPE_INT
+ and int(value["quantity"]) >= 0
+ and int(value["quantity"]) <= 1
+ and typeof(value.get("duration")) in [TYPE_FLOAT, TYPE_INT]
+ and is_finite(float(value["duration"]))
+ and float(value["duration"]) >= 0.0
+ and typeof(value.get("message")) == TYPE_STRING
+ and str(value["message"]).length() <= MAX_MESSAGE_LENGTH
+ )
+
+
+static func validate_equipped_state(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ _valid_id(value.get("session_id"))
+ and typeof(value.get("owner_peer_id")) == TYPE_INT
+ and int(value["owner_peer_id"]) > 0
+ and typeof(value.get("item_id")) in [TYPE_STRING, TYPE_STRING_NAME]
+ and str(value["item_id"]).length() <= MAX_ITEM_ID_LENGTH
+ and typeof(value.get("category")) == TYPE_INT
+ and int(value["category"]) >= -1
+ and int(value["category"]) < ItemData.Category.size()
+ and typeof(value.get("revision")) == TYPE_INT
+ and int(value["revision"]) >= 0
+ and typeof(value.get("owns_item")) == TYPE_BOOL
+ )
+
+
+static func _valid_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_ID_LENGTH
+ )
diff --git a/network/network_item_protocol.gd.uid b/network/network_item_protocol.gd.uid
new file mode 100644
index 0000000..6d3a289
--- /dev/null
+++ b/network/network_item_protocol.gd.uid
@@ -0,0 +1 @@
+uid://bphibe6g81mhm
diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd
new file mode 100644
index 0000000..f39d198
--- /dev/null
+++ b/network/network_item_use_service.gd
@@ -0,0 +1,447 @@
+class_name NetworkItemUseService
+extends Node
+
+const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
+
+const MAX_LEDGER_ENTRIES: int = 64
+
+signal local_item_use_pending(request_id: String)
+signal local_item_use_finished(accepted: bool, message: String)
+signal equipped_state_changed(peer_id: int, item_id: StringName, category: int)
+
+var _session: NetworkSession
+var _spawn_service: PlayerSpawnService
+var _catalog: ItemCatalog
+var _local_bag: PlayerBag
+var _local_effects: PlayerItemEffects
+var _save_manager: PlayerSaveManager
+var _requests: Dictionary[int, Dictionary] = {}
+var _pending_by_peer: Dictionary[int, String] = {}
+var _result_owners: Dictionary[String, int] = {}
+var _received_results: Dictionary[String, bool] = {}
+var _pending_local: Dictionary = {}
+var _equipped_states: Dictionary[int, Dictionary] = {}
+var _local_equipped_revision: int = 0
+var _reservations: PlayerAssetReservationService
+
+
+func setup(
+ session: NetworkSession,
+ spawn_service: PlayerSpawnService,
+ catalog: ItemCatalog,
+ local_bag: PlayerBag,
+ local_effects: PlayerItemEffects,
+ save_manager: PlayerSaveManager,
+ reservations: PlayerAssetReservationService,
+) -> void:
+ _session = session
+ _spawn_service = spawn_service
+ _catalog = catalog
+ _local_bag = local_bag
+ _local_effects = local_effects
+ _save_manager = save_manager
+ _reservations = reservations
+ _session.peer_removed.connect(_on_peer_removed)
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+
+
+func request_use(item_id: StringName) -> String:
+ if not _pending_local.is_empty():
+ local_item_use_finished.emit(false, "An item use is already pending.")
+ return ""
+ var consumed_item_id: StringName = _consumed_item_id(item_id)
+ if item_id == PlayerItemEffects.FISH_FINDER_ID and (
+ _local_bag == null
+ or not _local_bag.owns_item(item_id)
+ or _local_bag.get_quantity(consumed_item_id) < 1
+ ):
+ local_item_use_finished.emit(
+ false, PlayerItemEffects.FISH_FINDER_DEAD_MESSAGE
+ )
+ return ""
+ if (
+ _reservations != null
+ and _reservations.get_available_item_quantity(consumed_item_id) < 1
+ ):
+ local_item_use_finished.emit(false, "Reserved in a letter.")
+ return ""
+ if (
+ _session == null
+ or not _session.is_gameplay_session_active()
+ or (
+ not _session.is_host()
+ and not _session.supports_server_capability(
+ NetworkItemProtocol.ITEM_USE_CAPABILITY
+ )
+ )
+ ):
+ local_item_use_finished.emit(false, "Item use is unavailable.")
+ return ""
+ var request_id := _new_id("item")
+ var data := {
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "item_id": str(item_id),
+ "quantity": _local_bag.get_quantity(consumed_item_id),
+ }
+ _pending_local = data.duplicate(true)
+ local_item_use_pending.emit(request_id)
+ if _session.is_host():
+ _handle_use_request(_session.get_local_peer_id(), data)
+ else:
+ submit_item_use.rpc_id(1, data)
+ return request_id
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
+func submit_item_use(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_use_request(sender_id, data)
+
+
+func _handle_use_request(peer_id: int, data: Dictionary) -> void:
+ var request_id := str(data.get("request_id", ""))
+ var ledger: Dictionary = _requests.get(peer_id, {})
+ if ledger.has(request_id):
+ _send_result(peer_id, ledger[request_id])
+ return
+ var error := NetworkItemProtocol.validate_use_request(data)
+ if (
+ error.is_empty()
+ and str(data["session_id"]) != _session.get_session_id()
+ ):
+ error = "Item use could not be completed."
+ if error.is_empty() and _pending_by_peer.has(peer_id):
+ error = "An item use is already pending."
+ var item_id := StringName(str(data.get("item_id", "")))
+ var item: ItemData = _catalog.get_item_by_id(item_id)
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ var duration: float = 0.0
+ var is_fish_finder: bool = (
+ item_id == PlayerItemEffects.FISH_FINDER_ID
+ and item != null
+ and item.category == ItemData.Category.TOOL
+ and item.usable
+ and item.equippable
+ )
+ if error.is_empty() and (
+ item == null
+ or (
+ item.category != ItemData.Category.CONSUMABLE
+ and not is_fish_finder
+ )
+ or not item.usable
+ ):
+ error = "That item cannot be used now."
+ if error.is_empty() and int(data["quantity"]) < 1:
+ error = (
+ PlayerItemEffects.FISH_FINDER_DEAD_MESSAGE
+ if is_fish_finder
+ else "You do not have that item."
+ )
+ if error.is_empty() and (
+ avatar == null
+ or avatar.is_water_recovery_active()
+ ):
+ error = "That item cannot be used now."
+ if error.is_empty():
+ duration = avatar.item_effects.get_effect_duration(item_id)
+ if duration <= 0.0:
+ error = "That item cannot be used now."
+ var result := {
+ "result_id": _new_id("item_result"),
+ "request_id": request_id if not request_id.is_empty() else "invalid",
+ "session_id": _session.get_session_id(),
+ "target_peer_id": peer_id,
+ "accepted": error.is_empty(),
+ "item_id": str(item_id) if not item_id.is_empty() else "invalid",
+ "quantity": 1 if error.is_empty() else 0,
+ "duration": duration if error.is_empty() else 0.0,
+ "message": "Used %s." % item.display_name if error.is_empty() else error,
+ }
+ ledger[request_id] = result.duplicate(true)
+ _bound(ledger)
+ _requests[peer_id] = ledger
+ _result_owners[result["result_id"]] = peer_id
+ _pending_by_peer[peer_id] = request_id
+ _send_result(peer_id, result)
+
+
+func _send_result(peer_id: int, result: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _apply_result(result)
+ else:
+ receive_item_use_result.rpc_id(peer_id, result)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
+func receive_item_use_result(data: Dictionary) -> void:
+ _apply_result(data)
+
+
+func _apply_result(data: Dictionary) -> void:
+ if (
+ not NetworkItemProtocol.validate_use_result(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or int(data["target_peer_id"]) != _session.get_local_peer_id()
+ ):
+ return
+ var result_id: String = data["result_id"]
+ if _received_results.has(result_id):
+ _acknowledge(data, true, "")
+ return
+ if (
+ _pending_local.is_empty()
+ or str(data["request_id"]) != str(_pending_local.get("request_id", ""))
+ ):
+ return
+ if not bool(data["accepted"]):
+ _received_results[result_id] = true
+ _pending_local.clear()
+ local_item_use_finished.emit(false, str(data["message"]))
+ _acknowledge(data, false, str(data["message"]))
+ return
+ var item_id := StringName(str(data["item_id"]))
+ var consumed_item_id: StringName = _consumed_item_id(item_id)
+ if (
+ _reservations != null
+ and _reservations.get_available_item_quantity(consumed_item_id) < 1
+ ):
+ _pending_local.clear()
+ local_item_use_finished.emit(false, "Reserved in a letter.")
+ _acknowledge(data, false, "Reserved in a letter.")
+ return
+ var bag_snapshot := _local_bag.get_all_items()
+ if (
+ _local_bag.get_quantity(consumed_item_id) < 1
+ or not _local_bag.remove_item(consumed_item_id, 1)
+ or not _save_manager.save_if_dirty()
+ ):
+ _local_bag.replace_all_items(bag_snapshot)
+ _save_manager.save_if_dirty()
+ _pending_local.clear()
+ local_item_use_finished.emit(false, "Item use could not be completed.")
+ _acknowledge(data, false, "Item use could not be completed.")
+ return
+ _received_results[result_id] = true
+ _bound(_received_results)
+ _pending_local.clear()
+ local_item_use_finished.emit(true, str(data["message"]))
+ _acknowledge(data, true, "")
+
+
+func _acknowledge(data: Dictionary, applied: bool, message: String) -> void:
+ if _session.is_host():
+ _handle_ack(
+ _session.get_local_peer_id(),
+ str(data["result_id"]),
+ str(data["request_id"]),
+ applied,
+ message
+ )
+ else:
+ acknowledge_item_use.rpc_id(
+ 1, str(data["result_id"]), str(data["request_id"]), applied,
+ message.left(NetworkItemProtocol.MAX_MESSAGE_LENGTH)
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
+func acknowledge_item_use(
+ result_id: String,
+ request_id: String,
+ applied: bool,
+ message: String,
+) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_ack(sender_id, result_id, request_id, applied, message)
+
+
+func _handle_ack(
+ peer_id: int,
+ result_id: String,
+ request_id: String,
+ applied: bool,
+ _message: String,
+) -> void:
+ if (
+ _result_owners.get(result_id, 0) != peer_id
+ or _pending_by_peer.get(peer_id, "") != request_id
+ ):
+ return
+ _pending_by_peer.erase(peer_id)
+ if not applied:
+ return
+ var result: Dictionary = _requests.get(peer_id, {}).get(request_id, {})
+ var avatar := _spawn_service.get_avatar(peer_id)
+ if avatar == null or result.is_empty():
+ return
+ var item_id := StringName(str(result["item_id"]))
+ avatar.item_effects.activate_authoritative(item_id, float(result["duration"]))
+ _broadcast_effect(peer_id, item_id, float(result["duration"]))
+
+
+func get_effects_for_peer(peer_id: int) -> PlayerItemEffects:
+ var avatar := _spawn_service.get_avatar(peer_id)
+ return avatar.item_effects if avatar != null else null
+
+
+func submit_local_equipped(item_id: StringName, owns_item: bool) -> void:
+ if _session == null or not _session.is_gameplay_session_active():
+ return
+ _local_equipped_revision += 1
+ var item: ItemData = _catalog.get_item_by_id(item_id)
+ var data := {
+ "session_id": _session.get_session_id(),
+ "owner_peer_id": _session.get_local_peer_id(),
+ "item_id": str(item_id) if item != null and owns_item else "",
+ "category": int(item.category) if item != null and owns_item else -1,
+ "revision": _local_equipped_revision,
+ "owns_item": owns_item and item != null,
+ }
+ if _session.is_host():
+ _handle_equipped_state(_session.get_local_peer_id(), data)
+ elif _session.supports_server_capability(
+ NetworkItemProtocol.EQUIPMENT_CAPABILITY
+ ):
+ submit_equipped_state.rpc_id(1, data)
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
+func submit_equipped_state(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_equipped_state(sender_id, data)
+
+
+func _handle_equipped_state(peer_id: int, data: Dictionary) -> void:
+ if (
+ not NetworkItemProtocol.validate_equipped_state(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or int(data["owner_peer_id"]) != peer_id
+ ):
+ return
+ var previous: Dictionary = _equipped_states.get(peer_id, {})
+ if int(data["revision"]) <= int(previous.get("revision", -1)):
+ return
+ var item_id := StringName(str(data["item_id"]))
+ var item: ItemData = _catalog.get_item_by_id(item_id)
+ if not item_id.is_empty() and (
+ item == null
+ or not bool(data["owns_item"])
+ or int(item.category) != int(data["category"])
+ ):
+ return
+ _equipped_states[peer_id] = data.duplicate(true)
+ _apply_equipped(data)
+ receive_equipped_state.rpc(data)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
+func receive_equipped_state(data: Dictionary) -> void:
+ if NetworkItemProtocol.validate_equipped_state(data):
+ _equipped_states[int(data["owner_peer_id"])] = data.duplicate(true)
+ _apply_equipped(data)
+
+
+func _apply_equipped(data: Dictionary) -> void:
+ var peer_id: int = data["owner_peer_id"]
+ var avatar := _spawn_service.get_avatar(peer_id)
+ if avatar != null:
+ var item_id := StringName(str(data["item_id"]))
+ var item: ItemData = _catalog.get_item_by_id(item_id)
+ avatar.set_active_item_is_rod(
+ int(data["category"]) == ItemData.Category.ROD,
+ true,
+ )
+ avatar.set_active_art_kit(
+ item.icon if item != null else null,
+ item_id == ArtShopStockType.ART_KIT_ITEM_ID and bool(data["owns_item"]),
+ )
+ equipped_state_changed.emit(
+ peer_id, StringName(str(data["item_id"])), int(data["category"])
+ )
+
+
+func _broadcast_effect(peer_id: int, item_id: StringName, duration: float) -> void:
+ var data := {
+ "session_id": _session.get_session_id(),
+ "owner_peer_id": peer_id,
+ "item_id": str(item_id),
+ "remaining": duration,
+ }
+ _apply_effect_snapshot(data)
+ receive_effect_snapshot.rpc(data)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL)
+func receive_effect_snapshot(data: Dictionary) -> void:
+ _apply_effect_snapshot(data)
+
+
+func _apply_effect_snapshot(data: Dictionary) -> void:
+ if (
+ typeof(data.get("owner_peer_id")) != TYPE_INT
+ or typeof(data.get("item_id")) != TYPE_STRING
+ or typeof(data.get("remaining")) not in [TYPE_FLOAT, TYPE_INT]
+ or str(data.get("session_id", "")) != _session.get_session_id()
+ ):
+ return
+ var avatar := _spawn_service.get_avatar(int(data["owner_peer_id"]))
+ if avatar != null:
+ avatar.item_effects.activate_authoritative(
+ StringName(data["item_id"]), float(data["remaining"])
+ )
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ if not _session.is_host():
+ return
+ for state: Dictionary in _equipped_states.values():
+ receive_equipped_state.rpc_id(peer_id, state)
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ _requests.erase(peer_id)
+ _pending_by_peer.erase(peer_id)
+ _equipped_states.erase(peer_id)
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ var pending := not _pending_local.is_empty()
+ _requests.clear()
+ _pending_by_peer.clear()
+ _result_owners.clear()
+ _received_results.clear()
+ _equipped_states.clear()
+ _pending_local.clear()
+ if pending:
+ local_item_use_finished.emit(false, "Connection lost.")
+
+
+func _bound(values: Dictionary) -> void:
+ while values.size() > MAX_LEDGER_ENTRIES:
+ values.erase(values.keys().front())
+
+
+func _new_id(prefix: String) -> String:
+ return "%s:%s" % [
+ prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
+ ]
+
+
+func _consumed_item_id(item_id: StringName) -> StringName:
+ return (
+ PlayerItemEffects.BATTERIES_ID
+ if item_id == PlayerItemEffects.FISH_FINDER_ID
+ else item_id
+ )
diff --git a/network/network_item_use_service.gd.uid b/network/network_item_use_service.gd.uid
new file mode 100644
index 0000000..7558e08
--- /dev/null
+++ b/network/network_item_use_service.gd.uid
@@ -0,0 +1 @@
+uid://dx21uu253nmpw
diff --git a/network/network_job_service.gd b/network/network_job_service.gd
new file mode 100644
index 0000000..ebf5a6e
--- /dev/null
+++ b/network/network_job_service.gd
@@ -0,0 +1,155 @@
+class_name NetworkJobService
+extends Node
+
+const MAX_SESSION_ID_LENGTH: int = 96
+const BOARD_REQUEST_INTERVAL_SECONDS: float = 1.5
+
+var _session: NetworkSession
+var _jobs: PlayerJobService
+var _sequence: int = 0
+var _last_received_sequence: int = -1
+var _board_request_accumulator: float = 0.0
+
+
+func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
+ _session = session
+ _jobs = jobs
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ _jobs.board_changed.connect(_on_board_changed)
+ set_process(true)
+
+
+func _process(delta: float) -> void:
+ if (
+ _session == null
+ or _jobs == null
+ or not _session.is_joined_client()
+ or not _session.supports_server_capability(
+ NetworkProtocol.JOBS_CAPABILITY
+ )
+ or _jobs.has_active_board()
+ ):
+ _board_request_accumulator = 0.0
+ return
+ _board_request_accumulator += delta
+ if _board_request_accumulator < BOARD_REQUEST_INTERVAL_SECONDS:
+ return
+ _board_request_accumulator = 0.0
+ _request_remote_board()
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ _sequence = 0
+ _last_received_sequence = -1
+ _board_request_accumulator = 0.0
+ if state == NetworkSession.State.JOINED_CLIENT:
+ if _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY):
+ _request_remote_board()
+ else:
+ _jobs.clear_remote_board()
+ elif state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _jobs.clear_remote_board()
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.peer_supports_capability(
+ peer_id, NetworkProtocol.JOBS_CAPABILITY
+ )
+ ):
+ return
+ _send_board(peer_id)
+
+
+func _on_board_changed() -> void:
+ if _session == null or not _session.is_host():
+ return
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if (
+ peer_id != _session.get_local_peer_id()
+ and _session.peer_supports_capability(
+ peer_id, NetworkProtocol.JOBS_CAPABILITY
+ )
+ ):
+ _send_board(peer_id)
+
+
+func _send_board(peer_id: int) -> void:
+ var board: Dictionary = _jobs.get_host_board_network_data()
+ if board.is_empty() or not PlayerJobService.validate_board(board):
+ return
+ _sequence += 1
+ receive_job_board.rpc_id(peer_id, {
+ "session_id": _session.get_session_id(),
+ "sequence": _sequence,
+ "board": board,
+ })
+
+
+func _request_remote_board() -> void:
+ if (
+ _session != null
+ and _session.is_joined_client()
+ and _session.supports_server_capability(
+ NetworkProtocol.JOBS_CAPABILITY
+ )
+ ):
+ request_job_board.rpc_id(1)
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func request_job_board() -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ _session == null
+ or not _session.is_host()
+ or sender_id <= 1
+ or not _session.is_authenticated_peer(sender_id)
+ or not _session.peer_supports_capability(
+ sender_id, NetworkProtocol.JOBS_CAPABILITY
+ )
+ ):
+ return
+ _send_board(sender_id)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_job_board(data: Dictionary) -> void:
+ if (
+ _session == null
+ or _jobs == null
+ or not _session.is_joined_client()
+ or not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY)
+ or not validate_snapshot(data)
+ or str(data.get("session_id", "")) != _session.get_session_id()
+ ):
+ return
+ var sequence: int = int(data.get("sequence", -1))
+ if sequence <= _last_received_sequence:
+ return
+ var board: Dictionary = data.get("board", {})
+ if _jobs.apply_remote_board(board):
+ _last_received_sequence = sequence
+ _board_request_accumulator = 0.0
+
+
+static func validate_snapshot(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var data: Dictionary = value
+ return (
+ typeof(data.get("session_id")) == TYPE_STRING
+ and not str(data.get("session_id", "")).is_empty()
+ and str(data.get("session_id", "")).length() <= MAX_SESSION_ID_LENGTH
+ and typeof(data.get("sequence")) == TYPE_INT
+ and int(data.get("sequence", -1)) >= 0
+ and PlayerJobService.validate_board(data.get("board"))
+ )
diff --git a/network/network_job_service.gd.uid b/network/network_job_service.gd.uid
new file mode 100644
index 0000000..d50e8dc
--- /dev/null
+++ b/network/network_job_service.gd.uid
@@ -0,0 +1 @@
+uid://drlyytr2o3uw8
diff --git a/network/network_mail_protocol.gd b/network/network_mail_protocol.gd
new file mode 100644
index 0000000..f6441c7
--- /dev/null
+++ b/network/network_mail_protocol.gd
@@ -0,0 +1,165 @@
+class_name NetworkMailProtocol
+extends RefCounted
+
+const CAPABILITY: StringName = &"mail_v1"
+const RELIABLE_CHANNEL: int = NetworkProtocol.MAIL_RELIABLE_CHANNEL
+const MAX_ID_LENGTH: int = 96
+const MAX_BODY_CHARACTERS: int = 2000
+const MAX_BODY_BYTES: int = 8000
+const MAX_RECIPIENT_LETTERS: int = 100
+const MAX_SESSION_LETTERS: int = 200
+
+const GREETINGS: PackedStringArray = ["dear", "to", "hey", "greetings"]
+const SALUTATIONS: PackedStringArray = [
+ "love", "from", "cheers", "salutations", "good_luck_have_fun",
+]
+
+enum State {
+ SENT_UNREAD,
+ READ,
+ ACCEPTANCE_PENDING,
+ ACCEPTED,
+ DECLINED,
+ ATTACHMENT_RECALLED,
+ CANCELLED,
+ SESSION_ENDED,
+}
+
+
+static func sanitize_body(value: Variant) -> String:
+ if typeof(value) != TYPE_STRING:
+ return ""
+ var body := str(value).strip_edges(false, true)
+ if (
+ body.strip_edges().is_empty()
+ or body.length() > MAX_BODY_CHARACTERS
+ or body.to_utf8_buffer().size() > MAX_BODY_BYTES
+ ):
+ return ""
+ for index: int in body.length():
+ var codepoint := body.unicode_at(index)
+ if codepoint < 32 and codepoint not in [9, 10, 13]:
+ return ""
+ if codepoint == 127:
+ return ""
+ return body.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
+
+
+static func valid_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_ID_LENGTH
+ )
+
+
+static func validate_send_request(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ valid_id(value.get("request_id"))
+ and valid_id(value.get("session_id"))
+ and typeof(value.get("recipient_peer_id")) == TYPE_INT
+ and int(value["recipient_peer_id"]) > 0
+ and typeof(value.get("greeting_id")) == TYPE_STRING
+ and str(value["greeting_id"]) in GREETINGS
+ and not sanitize_body(value.get("body")).is_empty()
+ and typeof(value.get("salutation_id")) == TYPE_STRING
+ and str(value["salutation_id"]) in SALUTATIONS
+ and typeof(value.get("reservation_id")) == TYPE_STRING
+ and str(value["reservation_id"]).length() <= MAX_ID_LENGTH
+ and typeof(value.get("attachment")) == TYPE_DICTIONARY
+ and Dictionary(value["attachment"]).size() <= 3
+ and NetworkIdentityCrypto.valid_fingerprint(
+ value.get("sender_fingerprint")
+ )
+ and NetworkIdentityCrypto.valid_fingerprint(
+ value.get("recipient_fingerprint")
+ )
+ and typeof(value.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
+ )
+
+
+static func validate_mail(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ valid_id(value.get("mail_id"))
+ and valid_id(value.get("session_id"))
+ and typeof(value.get("sequence")) == TYPE_INT
+ and typeof(value.get("sender_peer_id")) == TYPE_INT
+ and typeof(value.get("recipient_peer_id")) == TYPE_INT
+ and typeof(value.get("sender_display_name")) == TYPE_STRING
+ and str(value["sender_display_name"]).length() <= 24
+ and str(value.get("greeting_id", "")) in GREETINGS
+ and not sanitize_body(value.get("body")).is_empty()
+ and str(value.get("salutation_id", "")) in SALUTATIONS
+ and typeof(value.get("attachment")) == TYPE_DICTIONARY
+ and Dictionary(value["attachment"]).size() <= 3
+ and typeof(value.get("state")) == TYPE_INT
+ and int(value["state"]) >= 0
+ and int(value["state"]) < State.size()
+ and NetworkIdentityCrypto.valid_fingerprint(
+ value.get("sender_fingerprint")
+ )
+ and NetworkIdentityCrypto.valid_fingerprint(
+ value.get("recipient_fingerprint")
+ )
+ and typeof(value.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
+ and typeof(value.get("sender_public_key")) == TYPE_STRING
+ and str(value["sender_public_key"]).to_utf8_buffer().size()
+ <= NetworkIdentityCrypto.MAX_PUBLIC_KEY_BYTES
+ )
+
+
+static func mail_signature_fields(data: Dictionary) -> Array:
+ var result: Array = [
+ str(data.get("session_id", "")),
+ str(data.get("request_id", "")),
+ str(data.get("sender_fingerprint", "")),
+ str(data.get("recipient_fingerprint", "")),
+ str(data.get("greeting_id", "")),
+ sanitize_body(data.get("body")),
+ str(data.get("salutation_id", "")),
+ str(data.get("reservation_id", "")),
+ ]
+ result.append_array(attachment_signature_fields(data.get("attachment", {})))
+ return result
+
+
+static func attachment_signature_fields(value: Variant) -> Array:
+ if typeof(value) != TYPE_DICTIONARY:
+ return [0]
+ var attachment: Dictionary = value
+ var type := int(attachment.get("type", 0))
+ var result: Array = [type]
+ match type:
+ 1:
+ result.append(int(attachment.get("amount", 0)))
+ 2:
+ var fish: Dictionary = attachment.get("catch", {})
+ result.append(str(attachment.get("catch_id", "")))
+ result.append(str(fish.get("fish_id", "")))
+ result.append(str(fish.get("weight_lb", "")))
+ result.append(str(fish.get("display_scale", "")))
+ result.append(int(fish.get("quality", -1)))
+ result.append(int(fish.get("sale_value", 0)))
+ result.append(bool(fish.get("is_favorited", false)))
+ 3:
+ result.append(str(attachment.get("item_id", "")))
+ result.append(int(attachment.get("quantity", 0)))
+ return result
+
+
+static func acceptance_signature_fields(
+ data: Dictionary, sender_fingerprint: String, recipient_fingerprint: String
+) -> Array:
+ return [
+ str(data.get("session_id", "")),
+ str(data.get("request_id", "")),
+ str(data.get("mail_id", "")),
+ sender_fingerprint,
+ recipient_fingerprint,
+ ]
diff --git a/network/network_mail_protocol.gd.uid b/network/network_mail_protocol.gd.uid
new file mode 100644
index 0000000..0359e82
--- /dev/null
+++ b/network/network_mail_protocol.gd.uid
@@ -0,0 +1 @@
+uid://5ttl0pytgtf7
diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd
new file mode 100644
index 0000000..eb9fe0f
--- /dev/null
+++ b/network/network_mail_service.gd
@@ -0,0 +1,1121 @@
+class_name NetworkMailService
+extends Node
+
+signal mailbox_changed
+signal unread_count_changed(count: int)
+signal operation_finished(success: bool, message: String)
+signal peers_changed
+
+const MAX_LEDGER: int = 256
+const FishQualityType = preload("res://fish/fish_quality.gd")
+
+var _session: NetworkSession
+var _reservations: PlayerAssetReservationService
+var _wallet: PlayerWallet
+var _inventory: FishInventory
+var _bag: PlayerBag
+var _collection_log: CollectionLog
+var _cooler_capacity: PlayerCoolerCapacity
+var _item_catalog: ItemCatalog
+var _fish_catalog: FishPool
+var _save_manager: PlayerSaveManager
+
+var _letters: Dictionary[String, Dictionary] = {}
+var _local_letters: Dictionary[String, Dictionary] = {}
+var _send_ledger: Dictionary[int, Dictionary] = {}
+var _action_ledger: Dictionary[String, bool] = {}
+var _sequence := 0
+var _pending_local_send: Dictionary = {}
+var _pending_transfers: Dictionary[String, Dictionary] = {}
+var _local_removal_snapshots: Dictionary[String, Dictionary] = {}
+var _received_awards: Dictionary[String, bool] = {}
+var _relationship_policy: NetworkPlayerListService
+var _archived_local_letters: Dictionary[String, bool] = {}
+var _deleted_local_letters: Dictionary[String, bool] = {}
+
+
+func setup(
+ session: NetworkSession,
+ reservations: PlayerAssetReservationService,
+ wallet: PlayerWallet,
+ inventory: FishInventory,
+ bag: PlayerBag,
+ collection_log: CollectionLog,
+ cooler_capacity: PlayerCoolerCapacity,
+ item_catalog: ItemCatalog,
+ fish_catalog: FishPool,
+ save_manager: PlayerSaveManager,
+) -> void:
+ _session = session
+ _reservations = reservations
+ _wallet = wallet
+ _inventory = inventory
+ _bag = bag
+ _collection_log = collection_log
+ _cooler_capacity = cooler_capacity
+ _item_catalog = item_catalog
+ _fish_catalog = fish_catalog
+ _save_manager = save_manager
+ _session.peer_authenticated.connect(func(_id: int, _name: String) -> void:
+ peers_changed.emit()
+ )
+ _session.peer_removed.connect(_on_peer_removed)
+ _session.peer_display_name_changed.connect(
+ func(_id: int, _name: String) -> void: peers_changed.emit()
+ )
+ _session.state_changed.connect(_on_session_state_changed)
+
+
+func set_relationship_policy(policy: NetworkPlayerListService) -> void:
+ _relationship_policy = policy
+
+
+func refresh_relationship_filters() -> void:
+ _emit_mailbox()
+ peers_changed.emit()
+
+
+func get_local_letters(archived: bool = false) -> Array[Dictionary]:
+ var values: Array[Dictionary] = []
+ for letter: Dictionary in _local_letters.values():
+ if (
+ _session.get_local_peer_id() in [
+ int(letter["sender_peer_id"]),
+ int(letter["recipient_peer_id"]),
+ ]
+ and not _deleted_local_letters.has(str(letter["mail_id"]))
+ and _archived_local_letters.has(str(letter["mail_id"])) == archived
+ and not _letter_is_locally_blocked(letter)
+ ):
+ values.append(letter.duplicate(true))
+ values.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ var a_unread := int(a["state"]) == NetworkMailProtocol.State.SENT_UNREAD
+ var b_unread := int(b["state"]) == NetworkMailProtocol.State.SENT_UNREAD
+ return a_unread != b_unread if a_unread != b_unread else (
+ int(a["sequence"]) > int(b["sequence"])
+ )
+ )
+ return values
+
+
+func get_letter(mail_id: String) -> Dictionary:
+ if _deleted_local_letters.has(mail_id):
+ return {}
+ return _local_letters.get(mail_id, {}).duplicate(true)
+
+
+func archive_local_letter(mail_id: String, archived: bool = true) -> bool:
+ if not _local_letters.has(mail_id) or _deleted_local_letters.has(mail_id):
+ return false
+ if archived:
+ _archived_local_letters[mail_id] = true
+ else:
+ _archived_local_letters.erase(mail_id)
+ _emit_mailbox()
+ return true
+
+
+func delete_local_letter(mail_id: String) -> bool:
+ var letter: Dictionary = _local_letters.get(mail_id, {})
+ if letter.is_empty() or _incoming_gift_is_unresolved(letter):
+ return false
+ _deleted_local_letters[mail_id] = true
+ _archived_local_letters.erase(mail_id)
+ _emit_mailbox()
+ return true
+
+
+func is_letter_archived(mail_id: String) -> bool:
+ return _archived_local_letters.has(mail_id)
+
+
+func get_unread_count() -> int:
+ var count := 0
+ for letter: Dictionary in _local_letters.values():
+ if (
+ int(letter["recipient_peer_id"]) == _session.get_local_peer_id()
+ and int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD
+ and not _archived_local_letters.has(str(letter["mail_id"]))
+ and not _deleted_local_letters.has(str(letter["mail_id"]))
+ and not _letter_is_locally_blocked(letter)
+ ):
+ count += 1
+ return count
+
+
+func is_local_recipient(letter: Dictionary) -> bool:
+ return (
+ int(letter.get("recipient_peer_id", 0))
+ == _session.get_local_peer_id()
+ )
+
+
+func get_local_display_name() -> String:
+ var record := _session.get_peer_record(_session.get_local_peer_id())
+ return record.display_name if record != null else "Player"
+
+
+func get_recipient_choices() -> Array[Dictionary]:
+ var choices: Array[Dictionary] = []
+ var local_id := _session.get_local_peer_id()
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if peer_id == local_id:
+ continue
+ var record := _session.get_peer_record(peer_id)
+ if record != null and (
+ _relationship_policy == null
+ or not _relationship_policy.is_locally_blocked(
+ record.identity_fingerprint
+ )
+ ):
+ choices.append({
+ "peer_id": peer_id,
+ "name": record.display_name,
+ "fingerprint": record.identity_fingerprint,
+ })
+ choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0
+ )
+ var counts: Dictionary[String, int] = {}
+ for choice: Dictionary in choices:
+ var normalized := str(choice["name"]).to_lower()
+ counts[normalized] = int(counts.get(normalized, 0)) + 1
+ for choice: Dictionary in choices:
+ var display_name: String = choice["name"]
+ if int(counts.get(display_name.to_lower(), 0)) > 1:
+ choice["label"] = "%s · %s" % [
+ display_name,
+ NetworkIdentityCrypto.compact_suffix(choice["fingerprint"]),
+ ]
+ else:
+ choice["label"] = display_name
+ return choices
+
+
+func send_letter(
+ recipient_peer_id: int,
+ greeting_id: String,
+ body: String,
+ salutation_id: String,
+ attachment: Dictionary,
+) -> bool:
+ if (
+ not _pending_local_send.is_empty()
+ or not _session.is_gameplay_session_active()
+ or recipient_peer_id == _session.get_local_peer_id()
+ or not _session.is_authenticated_peer(recipient_peer_id)
+ or (
+ _relationship_policy != null
+ and _relationship_policy.is_locally_blocked(
+ _session.get_peer_record(recipient_peer_id).identity_fingerprint
+ )
+ )
+ or (
+ not _session.is_host()
+ and not _session.supports_server_capability(
+ NetworkMailProtocol.CAPABILITY
+ )
+ )
+ ):
+ operation_finished.emit(false, "That player is no longer connected.")
+ return false
+ var request_id := _new_id("mail_request")
+ var reservation_id := ""
+ if int(attachment.get("type", 0)) != 0:
+ reservation_id = _new_id("reservation")
+ if not _reservations.reserve(reservation_id, attachment):
+ operation_finished.emit(false, "That gift is not available.")
+ return false
+ var request := {
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "recipient_peer_id": recipient_peer_id,
+ "greeting_id": greeting_id,
+ "body": NetworkMailProtocol.sanitize_body(body),
+ "salutation_id": salutation_id,
+ "reservation_id": reservation_id,
+ "attachment": attachment.duplicate(true),
+ "sender_fingerprint": _session.get_local_identity_fingerprint(),
+ "recipient_fingerprint": (
+ _session.get_peer_record(recipient_peer_id).identity_fingerprint
+ ),
+ }
+ request["sender_signature"] = _session.sign_local_action(
+ "mail_send", NetworkMailProtocol.mail_signature_fields(request)
+ )
+ if not NetworkMailProtocol.validate_send_request(request):
+ if not reservation_id.is_empty():
+ _reservations.release(reservation_id)
+ operation_finished.emit(false, "Letter could not be sent.")
+ return false
+ _pending_local_send = request.duplicate(true)
+ if _session.is_host():
+ _handle_send(_session.get_local_peer_id(), request)
+ else:
+ submit_mail.rpc_id(1, request)
+ return true
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func submit_mail(data: Dictionary) -> void:
+ var sender := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender):
+ _handle_send(sender, data)
+
+
+func _handle_send(sender: int, data: Dictionary) -> void:
+ var request_id := str(data.get("request_id", ""))
+ var ledger: Dictionary = _send_ledger.get(sender, {})
+ if ledger.has(request_id):
+ _deliver_send_result(sender, ledger[request_id])
+ return
+ var error := ""
+ if (
+ not NetworkMailProtocol.validate_send_request(data)
+ or str(data.get("session_id", "")) != _session.get_session_id()
+ ):
+ error = "Letter could not be sent."
+ var recipient := int(data.get("recipient_peer_id", 0))
+ if error.is_empty() and (
+ recipient == sender or not _session.is_authenticated_peer(recipient)
+ ):
+ error = "That player is no longer connected."
+ var sender_record := _session.get_peer_record(sender)
+ var recipient_record := _session.get_peer_record(recipient)
+ if error.is_empty() and (
+ sender_record == null
+ or recipient_record == null
+ or sender_record.identity_fingerprint
+ != str(data.get("sender_fingerprint", ""))
+ or recipient_record.identity_fingerprint
+ != str(data.get("recipient_fingerprint", ""))
+ or not _session.verify_peer_action(
+ sender,
+ "mail_send",
+ NetworkMailProtocol.mail_signature_fields(data),
+ data.get("sender_signature", PackedByteArray()),
+ )
+ ):
+ error = "Letter identity could not be verified."
+ var silently_dropped := (
+ error.is_empty()
+ and _relationship_policy != null
+ and _relationship_policy.pair_is_blocked(
+ sender_record.identity_fingerprint,
+ recipient_record.identity_fingerprint,
+ )
+ )
+ if (
+ error.is_empty()
+ and not silently_dropped
+ and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS
+ ):
+ error = "The session mailbox is full."
+ var recipient_count := 0
+ for letter: Dictionary in _letters.values():
+ if int(letter["recipient_peer_id"]) == recipient:
+ recipient_count += 1
+ if (
+ error.is_empty()
+ and not silently_dropped
+ and recipient_count >= NetworkMailProtocol.MAX_RECIPIENT_LETTERS
+ ):
+ error = "That inbox is full."
+ var attachment: Dictionary = data.get("attachment", {})
+ if error.is_empty() and not _validate_attachment_structure(attachment):
+ error = "That gift is not valid."
+ var result := {
+ "request_id": request_id if not request_id.is_empty() else "invalid",
+ "accepted": error.is_empty(),
+ "message": "Letter sent." if error.is_empty() else error,
+ "reservation_id": str(data.get("reservation_id", "")),
+ "silent_drop": silently_dropped,
+ }
+ if error.is_empty() and silently_dropped:
+ _sequence += 1
+ var sender_copy := _build_letter(
+ data, sender, recipient, sender_record, recipient_record, _sequence
+ )
+ result["mail"] = sender_copy
+ _deliver_private_letter(sender, sender_copy)
+ _request_release(sender, str(data.get("reservation_id", "")))
+ elif error.is_empty():
+ _sequence += 1
+ var letter := _build_letter(
+ data, sender, recipient, sender_record, recipient_record, _sequence
+ )
+ _letters[letter["mail_id"]] = letter
+ result["mail"] = letter
+ _deliver_private_letter(sender, letter)
+ _deliver_private_letter(recipient, letter)
+ ledger[result["request_id"]] = result.duplicate(true)
+ _bound(ledger)
+ _send_ledger[sender] = ledger
+ _deliver_send_result(sender, result)
+
+
+func _build_letter(
+ data: Dictionary,
+ sender: int,
+ recipient: int,
+ sender_record: PeerRegistry.PeerRecord,
+ recipient_record: PeerRegistry.PeerRecord,
+ sequence: int,
+) -> Dictionary:
+ return {
+ "mail_id": _new_id("mail"),
+ "session_id": _session.get_session_id(),
+ "sequence": sequence,
+ "sender_peer_id": sender,
+ "sender_display_name": sender_record.display_name,
+ "recipient_peer_id": recipient,
+ "recipient_display_name": recipient_record.display_name,
+ "greeting_id": str(data["greeting_id"]),
+ "body": NetworkMailProtocol.sanitize_body(data["body"]),
+ "salutation_id": str(data["salutation_id"]),
+ "reservation_id": str(data["reservation_id"]),
+ "attachment": (data.get("attachment", {}) as Dictionary).duplicate(true),
+ "state": NetworkMailProtocol.State.SENT_UNREAD,
+ "request_id": str(data["request_id"]),
+ "sender_fingerprint": data["sender_fingerprint"],
+ "recipient_fingerprint": data["recipient_fingerprint"],
+ "sender_signature": data["sender_signature"],
+ "sender_public_key": sender_record.identity_public_key,
+ }
+
+
+func _deliver_private_letter(peer_id: int, letter: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _receive_letter(letter)
+ elif _session.is_authenticated_peer(peer_id):
+ receive_private_letter.rpc_id(peer_id, letter)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func receive_private_letter(letter: Dictionary) -> void:
+ _receive_letter(letter)
+
+
+func _receive_letter(letter: Dictionary) -> void:
+ if (
+ not NetworkMailProtocol.validate_mail(letter)
+ or str(letter["session_id"]) != _session.get_session_id()
+ or _session.get_local_peer_id() not in [
+ int(letter["sender_peer_id"]), int(letter["recipient_peer_id"]),
+ ]
+ ):
+ return
+ var sender_public := NetworkIdentityCrypto.normalize_public_pem(
+ str(letter.get("sender_public_key", ""))
+ )
+ if (
+ not _session.matches_authenticated_session_identity(
+ str(letter["sender_fingerprint"]), sender_public
+ )
+ or not NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(sender_public),
+ "mail_send",
+ NetworkMailProtocol.mail_signature_fields(letter),
+ letter["sender_signature"],
+ )
+ ):
+ return
+ _local_letters[letter["mail_id"]] = letter.duplicate(true)
+ _emit_mailbox()
+
+
+func cancel_unaccepted_between(first: String, second: String) -> void:
+ if not _session.is_host():
+ return
+ for letter: Dictionary in _letters.values():
+ if not (
+ str(letter.get("sender_fingerprint", "")) in [first, second]
+ and str(letter.get("recipient_fingerprint", "")) in [first, second]
+ ):
+ continue
+ if int(letter.get("state", -1)) not in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ ]:
+ continue
+ letter["state"] = NetworkMailProtocol.State.CANCELLED
+ _request_release(
+ int(letter["sender_peer_id"]), str(letter.get("reservation_id", ""))
+ )
+ _update_participants(letter)
+
+
+func _deliver_send_result(peer_id: int, result: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _receive_send_result(result)
+ else:
+ receive_send_result.rpc_id(peer_id, result)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func receive_send_result(result: Dictionary) -> void:
+ _receive_send_result(result)
+
+
+func _receive_send_result(result: Dictionary) -> void:
+ if (
+ _pending_local_send.is_empty()
+ or str(result.get("request_id", ""))
+ != str(_pending_local_send.get("request_id", ""))
+ ):
+ return
+ if (
+ not bool(result.get("accepted", false))
+ or bool(result.get("silent_drop", false))
+ ):
+ var reservation_id := str(_pending_local_send.get("reservation_id", ""))
+ if not reservation_id.is_empty():
+ _reservations.release(reservation_id)
+ _pending_local_send.clear()
+ operation_finished.emit(
+ bool(result.get("accepted", false)),
+ str(result.get("message", "Letter could not be sent."))
+ )
+
+
+func mark_read(mail_id: String) -> void:
+ var request := _action_request(mail_id)
+ if request.is_empty():
+ return
+ if _session.is_host():
+ _handle_read(_session.get_local_peer_id(), request)
+ else:
+ submit_read.rpc_id(1, request)
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func submit_read(data: Dictionary) -> void:
+ var sender := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender):
+ _handle_read(sender, data)
+
+
+func _handle_read(peer_id: int, data: Dictionary) -> void:
+ var action_id := str(data.get("request_id", ""))
+ if _action_ledger.has(action_id):
+ return
+ var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {})
+ if (
+ letter.is_empty()
+ or int(letter["recipient_peer_id"]) != peer_id
+ or str(data.get("session_id", "")) != _session.get_session_id()
+ ):
+ return
+ _action_ledger[action_id] = true
+ if int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD:
+ letter["state"] = NetworkMailProtocol.State.READ
+ _update_participants(letter)
+
+
+func accept_gift(mail_id: String) -> void:
+ var request := _action_request(mail_id)
+ if request.is_empty():
+ return
+ var letter: Dictionary = _local_letters.get(mail_id, {})
+ request["recipient_signature"] = _session.sign_local_action(
+ "mail_accept",
+ NetworkMailProtocol.acceptance_signature_fields(
+ request,
+ str(letter.get("sender_fingerprint", "")),
+ str(letter.get("recipient_fingerprint", "")),
+ ),
+ )
+ if _session.is_host():
+ _handle_accept(_session.get_local_peer_id(), request)
+ else:
+ submit_accept.rpc_id(1, request)
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func submit_accept(data: Dictionary) -> void:
+ var sender := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender):
+ _handle_accept(sender, data)
+
+
+func _handle_accept(peer_id: int, data: Dictionary) -> void:
+ var action_id := str(data.get("request_id", ""))
+ var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {})
+ if _action_ledger.has(action_id):
+ return
+ if (
+ letter.is_empty()
+ or int(letter["recipient_peer_id"]) != peer_id
+ or int(letter["state"]) not in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ ]
+ or int(letter["attachment"].get("type", 0)) == 0
+ or not _session.is_authenticated_peer(int(letter["sender_peer_id"]))
+ ):
+ return
+ var recipient_record := _session.get_peer_record(peer_id)
+ if (
+ recipient_record == null
+ or recipient_record.identity_fingerprint
+ != str(letter.get("recipient_fingerprint", ""))
+ or not _session.verify_peer_action(
+ peer_id,
+ "mail_accept",
+ NetworkMailProtocol.acceptance_signature_fields(
+ data,
+ str(letter.get("sender_fingerprint", "")),
+ str(letter.get("recipient_fingerprint", "")),
+ ),
+ data.get("recipient_signature", PackedByteArray()),
+ )
+ ):
+ return
+ _action_ledger[action_id] = true
+ var transfer_id := _new_id("mail_transfer")
+ letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING
+ letter["transfer_id"] = transfer_id
+ _pending_transfers[transfer_id] = {
+ "letter": letter,
+ "recipient_prepared": false,
+ "sender_removed": false,
+ }
+ _update_participants(letter)
+ _request_prepare(int(letter["recipient_peer_id"]), transfer_id, letter)
+
+
+func _request_prepare(peer_id: int, transfer_id: String, letter: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _prepare_recipient(transfer_id, letter)
+ else:
+ prepare_recipient.rpc_id(peer_id, transfer_id, letter)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func prepare_recipient(transfer_id: String, letter: Dictionary) -> void:
+ _prepare_recipient(transfer_id, letter)
+
+
+func _prepare_recipient(transfer_id: String, letter: Dictionary) -> void:
+ var recipient_ready: bool = _can_receive(letter["attachment"])
+ _send_phase_ack("recipient_prepared", transfer_id, recipient_ready)
+
+
+func _request_sender_commit(peer_id: int, transfer_id: String, letter: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _commit_sender(transfer_id, letter)
+ else:
+ commit_sender.rpc_id(peer_id, transfer_id, letter)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func commit_sender(transfer_id: String, letter: Dictionary) -> void:
+ _commit_sender(transfer_id, letter)
+
+
+func _commit_sender(transfer_id: String, letter: Dictionary) -> void:
+ var reservation_id: String = letter["reservation_id"]
+ var snapshot := _capture_assets()
+ var applied := (
+ _reservations.has_reservation(reservation_id)
+ and _reservations.commit_removal(reservation_id, transfer_id)
+ and _save_manager.save_if_dirty()
+ )
+ if not applied:
+ _restore_assets(snapshot)
+ _save_manager.save_if_dirty()
+ else:
+ _local_removal_snapshots[transfer_id] = snapshot
+ _send_phase_ack("sender_removed", transfer_id, applied)
+
+
+func _request_recipient_award(
+ peer_id: int, transfer_id: String, letter: Dictionary
+) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _award_recipient(transfer_id, letter)
+ else:
+ award_recipient.rpc_id(peer_id, transfer_id, letter)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func award_recipient(transfer_id: String, letter: Dictionary) -> void:
+ _award_recipient(transfer_id, letter)
+
+
+func _award_recipient(transfer_id: String, letter: Dictionary) -> void:
+ if _received_awards.has(transfer_id):
+ _send_phase_ack("recipient_awarded", transfer_id, true)
+ return
+ var snapshot := _capture_assets()
+ var applied := _apply_award(letter["attachment"])
+ if applied:
+ applied = _save_manager.save_if_dirty()
+ if not applied:
+ _restore_assets(snapshot)
+ _save_manager.save_if_dirty()
+ else:
+ _received_awards[transfer_id] = true
+ _bound(_received_awards)
+ _send_phase_ack("recipient_awarded", transfer_id, applied)
+
+
+func _send_phase_ack(phase: String, transfer_id: String, applied: bool) -> void:
+ var letter := _find_local_transfer_letter(transfer_id)
+ var fields := _gift_phase_fields(phase, transfer_id, applied, letter)
+ var fingerprint := _session.get_local_identity_fingerprint()
+ var signature := _session.sign_local_action(
+ "gift_%s" % phase, fields
+ )
+ if _session.is_host():
+ _handle_phase_ack(
+ _session.get_local_peer_id(),
+ phase,
+ transfer_id,
+ applied,
+ fingerprint,
+ signature,
+ )
+ else:
+ acknowledge_transfer_phase.rpc_id(
+ 1, phase, transfer_id, applied, fingerprint, signature
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func acknowledge_transfer_phase(
+ phase: String,
+ transfer_id: String,
+ applied: bool,
+ fingerprint: String,
+ signature: PackedByteArray,
+) -> void:
+ var sender := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender):
+ _handle_phase_ack(
+ sender, phase, transfer_id, applied, fingerprint, signature
+ )
+
+
+func _handle_phase_ack(
+ peer_id: int,
+ phase: String,
+ transfer_id: String,
+ applied: bool,
+ fingerprint: String,
+ signature: PackedByteArray,
+) -> void:
+ var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
+ if transfer.is_empty():
+ return
+ var letter: Dictionary = transfer["letter"]
+ var record := _session.get_peer_record(peer_id)
+ if (
+ record == null
+ or record.identity_fingerprint != fingerprint
+ or not _session.verify_peer_action(
+ peer_id,
+ "gift_%s" % phase,
+ _gift_phase_fields(phase, transfer_id, applied, letter),
+ signature,
+ )
+ ):
+ return
+ if phase == "recipient_prepared":
+ if peer_id != int(letter["recipient_peer_id"]):
+ return
+ if not applied:
+ _finish_transfer(transfer_id, false, "Gift could not be transferred.")
+ return
+ transfer["recipient_prepared"] = true
+ _request_sender_commit(int(letter["sender_peer_id"]), transfer_id, letter)
+ elif phase == "sender_removed":
+ if peer_id != int(letter["sender_peer_id"]):
+ return
+ if not applied:
+ _finish_transfer(transfer_id, false, "Gift returned to sender.")
+ return
+ transfer["sender_removed"] = true
+ _request_recipient_award(
+ int(letter["recipient_peer_id"]), transfer_id, letter
+ )
+ elif phase == "recipient_awarded":
+ if peer_id != int(letter["recipient_peer_id"]):
+ return
+ if applied:
+ _finish_transfer(transfer_id, true, "Gift accepted.")
+ else:
+ _request_sender_rollback(
+ int(letter["sender_peer_id"]), transfer_id
+ )
+ _finish_transfer(transfer_id, false, "Gift could not be transferred.")
+
+
+func _find_local_transfer_letter(transfer_id: String) -> Dictionary:
+ for value: Dictionary in _local_letters.values():
+ if str(value.get("transfer_id", "")) == transfer_id:
+ return value
+ return {}
+
+
+func _gift_phase_fields(
+ phase: String,
+ transfer_id: String,
+ applied: bool,
+ letter: Dictionary,
+) -> Array:
+ return [
+ _session.get_session_id(),
+ transfer_id,
+ str(letter.get("mail_id", "")),
+ str(letter.get("sender_fingerprint", "")),
+ str(letter.get("recipient_fingerprint", "")),
+ phase,
+ applied,
+ ]
+
+
+func _request_sender_rollback(peer_id: int, transfer_id: String) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _rollback_sender(transfer_id)
+ else:
+ rollback_sender.rpc_id(peer_id, transfer_id)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func rollback_sender(transfer_id: String) -> void:
+ _rollback_sender(transfer_id)
+
+
+func _rollback_sender(transfer_id: String) -> void:
+ var snapshot: Dictionary = _local_removal_snapshots.get(transfer_id, {})
+ if not snapshot.is_empty():
+ _restore_assets(snapshot)
+ _save_manager.save_if_dirty()
+ _local_removal_snapshots.erase(transfer_id)
+
+
+func _finish_transfer(transfer_id: String, accepted: bool, message: String) -> void:
+ var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
+ if transfer.is_empty():
+ return
+ var letter: Dictionary = transfer["letter"]
+ letter["state"] = (
+ NetworkMailProtocol.State.ACCEPTED
+ if accepted else NetworkMailProtocol.State.ATTACHMENT_RECALLED
+ )
+ _pending_transfers.erase(transfer_id)
+ _update_participants(letter)
+ operation_finished.emit(accepted, message)
+
+
+func decline_gift(mail_id: String) -> void:
+ var request := _action_request(mail_id)
+ if request.is_empty():
+ return
+ if _session.is_host():
+ _handle_decline(_session.get_local_peer_id(), request)
+ else:
+ submit_decline.rpc_id(1, request)
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func submit_decline(data: Dictionary) -> void:
+ var sender := multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender):
+ _handle_decline(sender, data)
+
+
+func _handle_decline(peer_id: int, data: Dictionary) -> void:
+ var action_id := str(data.get("request_id", ""))
+ var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {})
+ if _action_ledger.has(action_id):
+ return
+ if (
+ letter.is_empty()
+ or int(letter["recipient_peer_id"]) != peer_id
+ or int(letter["state"]) not in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ ]
+ ):
+ return
+ _action_ledger[action_id] = true
+ letter["state"] = NetworkMailProtocol.State.DECLINED
+ _request_release(int(letter["sender_peer_id"]), letter["reservation_id"])
+ _update_participants(letter)
+
+
+func _request_release(peer_id: int, reservation_id: String) -> void:
+ if reservation_id.is_empty():
+ return
+ if peer_id == _session.get_local_peer_id():
+ _reservations.release(reservation_id)
+ else:
+ release_reservation.rpc_id(peer_id, reservation_id)
+
+
+@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
+func release_reservation(reservation_id: String) -> void:
+ _reservations.release(reservation_id)
+
+
+func _update_participants(letter: Dictionary) -> void:
+ _letters[letter["mail_id"]] = letter
+ _deliver_private_letter(int(letter["sender_peer_id"]), letter)
+ _deliver_private_letter(int(letter["recipient_peer_id"]), letter)
+
+
+func _action_request(mail_id: String) -> Dictionary:
+ if mail_id.is_empty() or not _local_letters.has(mail_id):
+ return {}
+ return {
+ "request_id": _new_id("mail_action"),
+ "mail_id": mail_id,
+ "session_id": _session.get_session_id(),
+ }
+
+
+func _validate_attachment_structure(attachment: Dictionary) -> bool:
+ if typeof(attachment.get("type")) != TYPE_INT:
+ return false
+ match int(attachment["type"]):
+ PlayerAssetReservationService.AttachmentType.NONE:
+ return attachment.size() == 1
+ PlayerAssetReservationService.AttachmentType.FISH_COIN:
+ return (
+ attachment.size() == 2
+ and
+ typeof(attachment.get("amount")) == TYPE_INT
+ and int(attachment["amount"]) >= 1
+ and int(attachment["amount"]) <= 1000000000
+ )
+ PlayerAssetReservationService.AttachmentType.FISH:
+ if not (
+ attachment.size() == 3
+ and
+ typeof(attachment.get("catch_id")) == TYPE_STRING
+ and typeof(attachment.get("catch")) == TYPE_DICTIONARY
+ ):
+ return false
+ var fish_catch := _decode_catch(attachment)
+ return (
+ fish_catch != null
+ and str(fish_catch.catch_id) == str(attachment["catch_id"])
+ and not bool(
+ Dictionary(attachment["catch"]).get("is_favorited", false)
+ )
+ )
+ PlayerAssetReservationService.AttachmentType.CONSUMABLE:
+ var item := _item_catalog.get_item_by_id(
+ StringName(str(attachment.get("item_id", "")))
+ )
+ return (
+ attachment.size() == 3
+ and
+ item != null
+ and item.category == ItemData.Category.CONSUMABLE
+ and typeof(attachment.get("quantity")) == TYPE_INT
+ and int(attachment["quantity"]) >= 1
+ and int(attachment["quantity"]) <= item.max_stack
+ )
+ return false
+
+
+func _can_receive(attachment: Dictionary) -> bool:
+ match int(attachment.get("type", 0)):
+ PlayerAssetReservationService.AttachmentType.FISH_COIN:
+ return _wallet.can_credit(int(attachment["amount"]))
+ PlayerAssetReservationService.AttachmentType.FISH:
+ return (
+ _inventory.get_all_catches().size() < _cooler_capacity.get_capacity()
+ and not _inventory.contains_catch_id(
+ StringName(str(attachment["catch_id"]))
+ )
+ and _decode_catch(attachment) != null
+ )
+ PlayerAssetReservationService.AttachmentType.CONSUMABLE:
+ return _bag.can_add_item(
+ StringName(str(attachment["item_id"])),
+ int(attachment["quantity"])
+ )
+ return false
+
+
+func _apply_award(attachment: Dictionary) -> bool:
+ if not _can_receive(attachment):
+ return false
+ match int(attachment["type"]):
+ PlayerAssetReservationService.AttachmentType.FISH_COIN:
+ return _wallet.credit(int(attachment["amount"]))
+ PlayerAssetReservationService.AttachmentType.FISH:
+ var fish_catch := _decode_catch(attachment)
+ _inventory.add_catch(fish_catch)
+ _collection_log.mark_quality_discovered(
+ fish_catch.fish_id,
+ fish_catch.quality,
+ )
+ return _inventory.contains_catch_id(fish_catch.catch_id)
+ PlayerAssetReservationService.AttachmentType.CONSUMABLE:
+ return _bag.add_item(
+ StringName(str(attachment["item_id"])),
+ int(attachment["quantity"])
+ )
+ return false
+
+
+func _decode_catch(attachment: Dictionary) -> FishCatch:
+ var data: Dictionary = attachment.get("catch", {})
+ var fish: FishData = _fish_catalog.get_fish_by_id(
+ StringName(str(data.get("fish_id", "")))
+ )
+ var fish_catch: FishCatch = FishCatch.from_network_dict(data, fish)
+ if fish_catch == null or fish == null:
+ return null
+ if (
+ fish_catch.weight_lb < fish.get_minimum_weight()
+ or fish_catch.weight_lb > fish.get_maximum_weight()
+ or not is_equal_approx(
+ fish_catch.display_scale,
+ fish.get_display_scale_for_weight(fish_catch.weight_lb),
+ )
+ or fish_catch.sale_value != FishQualityType.apply_sale_value(
+ fish.get_sale_value_for_weight(fish_catch.weight_lb),
+ fish_catch.quality,
+ )
+ ):
+ return null
+ return fish_catch
+
+
+func _capture_assets() -> Dictionary:
+ return {
+ "wallet": _wallet.get_balance(),
+ "bag": _bag.get_all_items(),
+ "unlocked_bait_ids": _bag.get_unlocked_bait_ids(),
+ "catches": _inventory.get_all_catches(),
+ "next_sequence": _inventory.get_next_catch_sequence(),
+ "discovered": _collection_log.get_discovered_ids(),
+ "quality_masks": _collection_log.get_discovered_quality_masks(),
+ }
+
+
+func _restore_assets(snapshot: Dictionary) -> void:
+ _wallet.restore_balance(int(snapshot["wallet"]))
+ _bag.replace_all_items(snapshot["bag"])
+ _bag.replace_unlocked_bait_ids(snapshot["unlocked_bait_ids"])
+ _inventory.replace_all_catches(
+ snapshot["catches"], int(snapshot["next_sequence"])
+ )
+ _collection_log.replace_discovery_state(
+ snapshot["discovered"],
+ snapshot["quality_masks"],
+ )
+
+
+func _emit_mailbox() -> void:
+ mailbox_changed.emit()
+ unread_count_changed.emit(get_unread_count())
+
+
+func _letter_is_locally_blocked(letter: Dictionary) -> bool:
+ if _relationship_policy == null:
+ return false
+ var local_id := _session.get_local_peer_id()
+ var other_fingerprint := (
+ str(letter.get("sender_fingerprint", ""))
+ if int(letter.get("sender_peer_id", 0)) != local_id
+ else str(letter.get("recipient_fingerprint", ""))
+ )
+ return _relationship_policy.is_locally_blocked(other_fingerprint)
+
+
+func _incoming_gift_is_unresolved(letter: Dictionary) -> bool:
+ return (
+ is_local_recipient(letter)
+ and int(letter.get("attachment", {}).get("type", 0))
+ != PlayerAssetReservationService.AttachmentType.NONE
+ and int(letter.get("state", -1)) in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ NetworkMailProtocol.State.ACCEPTANCE_PENDING,
+ ]
+ )
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ if not _session.is_host():
+ # The host delivers any unresolved final state. Keep local copies so
+ # disconnect feedback cannot disappear or overwrite settled mail.
+ peers_changed.emit()
+ return
+ for transfer_id: String in _pending_transfers.keys().duplicate():
+ var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
+ var transfer_letter: Dictionary = transfer.get("letter", {})
+ if peer_id not in [
+ int(transfer_letter.get("sender_peer_id", 0)),
+ int(transfer_letter.get("recipient_peer_id", 0)),
+ ]:
+ continue
+ if bool(transfer.get("sender_removed", false)):
+ var sender_id := int(transfer_letter.get("sender_peer_id", 0))
+ if _session.is_authenticated_peer(sender_id):
+ _request_sender_rollback(sender_id, transfer_id)
+ _finish_transfer(transfer_id, false, "Gift returned to sender.")
+ for letter: Dictionary in _letters.values():
+ if int(letter["recipient_peer_id"]) == peer_id:
+ if int(letter["state"]) in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ ]:
+ letter["state"] = NetworkMailProtocol.State.CANCELLED
+ _request_release(
+ int(letter["sender_peer_id"]), letter["reservation_id"]
+ )
+ _deliver_private_letter(
+ int(letter["sender_peer_id"]), letter
+ )
+ elif (
+ int(letter["sender_peer_id"]) == peer_id
+ and int(letter["state"]) in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ ]
+ ):
+ letter["state"] = NetworkMailProtocol.State.ATTACHMENT_RECALLED
+ _update_participants(letter)
+ _send_ledger.erase(peer_id)
+ peers_changed.emit()
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _reservations.release_all()
+ _letters.clear()
+ _local_letters.clear()
+ _send_ledger.clear()
+ _action_ledger.clear()
+ _pending_local_send.clear()
+ _pending_transfers.clear()
+ _local_removal_snapshots.clear()
+ _received_awards.clear()
+ _archived_local_letters.clear()
+ _deleted_local_letters.clear()
+ _sequence = 0
+ _emit_mailbox()
+
+
+func _bound(values: Dictionary) -> void:
+ while values.size() > MAX_LEDGER:
+ values.erase(values.keys().front())
+
+
+func _new_id(prefix: String) -> String:
+ return "%s:%s" % [
+ prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
+ ]
diff --git a/network/network_mail_service.gd.uid b/network/network_mail_service.gd.uid
new file mode 100644
index 0000000..4a16d79
--- /dev/null
+++ b/network/network_mail_service.gd.uid
@@ -0,0 +1 @@
+uid://t235k67v0dno
diff --git a/network/network_player_animation_protocol.gd b/network/network_player_animation_protocol.gd
new file mode 100644
index 0000000..b27cb9d
--- /dev/null
+++ b/network/network_player_animation_protocol.gd
@@ -0,0 +1,94 @@
+class_name NetworkPlayerAnimationProtocol
+extends RefCounted
+
+const FORMAT_VERSION: int = 1
+const MAX_STATE_ID_LENGTH: int = 64
+const MAX_ACTION_SEQUENCE: int = 2147483647
+const MAX_ACTION_ELAPSED_SECONDS: float = 86400.0
+
+const LOCOMOTION_IDLE: StringName = &"idle"
+const LOCOMOTION_WALKING: StringName = &"walking"
+const LOCOMOTION_RUNNING: StringName = &"running"
+
+
+static func make_state(
+ locomotion_id: StringName,
+ grounded: bool,
+ action_id: StringName = &"",
+ action_sequence: int = 0,
+ action_elapsed: float = 0.0,
+) -> Dictionary:
+ return {
+ "format_version": FORMAT_VERSION,
+ "locomotion_id": String(locomotion_id),
+ "grounded": grounded,
+ "action": make_action_state(
+ action_id, action_sequence, action_elapsed
+ ),
+ }
+
+
+static func make_action_state(
+ action_id: StringName = &"",
+ action_sequence: int = 0,
+ action_elapsed: float = 0.0,
+) -> Dictionary:
+ return {
+ "id": String(action_id),
+ "sequence": action_sequence,
+ "elapsed": action_elapsed,
+ }
+
+
+static func validate_state(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var state: Dictionary = value
+ return (
+ typeof(state.get("format_version")) == TYPE_INT
+ and int(state["format_version"]) == FORMAT_VERSION
+ and valid_state_id(state.get("locomotion_id"), false)
+ and typeof(state.get("grounded")) == TYPE_BOOL
+ and validate_action_state(state.get("action"))
+ )
+
+
+static func validate_action_state(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var action: Dictionary = value
+ if (
+ not valid_state_id(action.get("id"), true)
+ or typeof(action.get("sequence")) != TYPE_INT
+ or typeof(action.get("elapsed")) not in [TYPE_FLOAT, TYPE_INT]
+ ):
+ return false
+ var sequence: int = int(action["sequence"])
+ var elapsed: float = float(action["elapsed"])
+ return (
+ sequence >= 0
+ and sequence <= MAX_ACTION_SEQUENCE
+ and is_finite(elapsed)
+ and elapsed >= 0.0
+ and elapsed <= MAX_ACTION_ELAPSED_SECONDS
+ )
+
+
+static func valid_state_id(value: Variant, allow_empty: bool) -> bool:
+ if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ return false
+ var state_id: String = str(value)
+ if state_id.is_empty():
+ return allow_empty
+ if state_id.length() > MAX_STATE_ID_LENGTH:
+ return false
+ for index: int in state_id.length():
+ var codepoint: int = state_id.unicode_at(index)
+ var valid_character: bool = (
+ codepoint >= 97 and codepoint <= 122
+ or codepoint >= 48 and codepoint <= 57
+ or codepoint == 95
+ )
+ if not valid_character:
+ return false
+ return true
diff --git a/network/network_player_animation_protocol.gd.uid b/network/network_player_animation_protocol.gd.uid
new file mode 100644
index 0000000..466ad02
--- /dev/null
+++ b/network/network_player_animation_protocol.gd.uid
@@ -0,0 +1 @@
+uid://cmaaep4n3p2tq
diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd
new file mode 100644
index 0000000..4742d14
--- /dev/null
+++ b/network/network_player_list_service.gd
@@ -0,0 +1,623 @@
+class_name NetworkPlayerListService
+extends Node
+
+signal entries_changed
+signal moderation_finished(success: bool, message: String)
+
+var _session: NetworkSession
+var _relationships: PlayerRelationshipStore
+var _bans: HostBanStore
+var _known: KnownPlayerStore
+var _spawn: PlayerSpawnService
+var _chat: NetworkChatService
+var _mail: NetworkMailService
+var _surface_drawing: NetworkSurfaceDrawingService
+var _revision := 0
+var _host_block_pairs: Dictionary[String, bool] = {}
+var _peer_fingerprints: Dictionary[int, String] = {}
+var _remote_bans: Array[Dictionary] = []
+var _ban_snapshot_requested: bool = false
+var _ban_snapshot_loaded: bool = false
+
+
+func setup(
+ session: NetworkSession,
+ relationships: PlayerRelationshipStore,
+ bans: HostBanStore,
+ known: KnownPlayerStore,
+ spawn: PlayerSpawnService,
+ chat: NetworkChatService,
+ mail: NetworkMailService,
+) -> void:
+ _session = session
+ _relationships = relationships
+ _bans = bans
+ _known = known
+ _spawn = spawn
+ _chat = chat
+ _mail = mail
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ _session.peer_removed.connect(_on_peer_removed)
+ _session.peer_display_name_changed.connect(func(_id: int, _name: String) -> void: _changed())
+ _session.peer_count_changed.connect(
+ func(_count: int, _maximum: int) -> void: _sync_authenticated_peers()
+ )
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.operator_status_changed.connect(_on_operator_status_changed)
+ _relationships.relationship_changed.connect(_on_relationship_changed)
+ _bans.bans_changed.connect(_changed)
+ _chat.set_relationship_store(_relationships)
+ _mail.set_relationship_policy(self)
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ _on_peer_authenticated(peer_id, "")
+
+
+func get_entries() -> Array[PlayerListEntry]:
+ var result: Array[PlayerListEntry] = []
+ var local_id := _session.get_local_peer_id()
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ var record := _session.get_peer_record(peer_id)
+ if record == null or not record.identity_authenticated:
+ continue
+ var blocked := _relationships.is_blocked(record.identity_fingerprint)
+ if blocked and peer_id != local_id:
+ continue
+ var entry := PlayerListEntry.new()
+ entry.peer_id = peer_id
+ entry.full_fingerprint = record.identity_fingerprint
+ entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint)
+ entry.display_name = record.display_name
+ entry.is_host = peer_id == 1
+ entry.is_operator = _session.is_peer_operator(peer_id)
+ entry.is_local_player = peer_id == local_id
+ entry.continuity_state = _known.identity_status(record.identity_fingerprint, record.display_name)
+ entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
+ entry.muted = _relationships.is_muted(record.identity_fingerprint)
+ entry.blocked = blocked
+ entry.can_kick = (
+ _session.can_local_moderate()
+ and peer_id != local_id
+ and peer_id != 1
+ and (_session.is_host() or not entry.is_operator)
+ )
+ entry.can_ban = entry.can_kick
+ entry.can_manage_operator = (
+ _session.can_manage_operators()
+ and peer_id != local_id
+ and peer_id != 1
+ )
+ entry.revision = _revision
+ result.append(entry)
+ result.sort_custom(_entry_before)
+ return result
+
+
+func get_connected_count() -> int:
+ return _session.get_player_count()
+
+
+func get_max_players() -> int:
+ return _session.get_session_max_players()
+
+
+func is_local_host() -> bool:
+ return _session.is_host()
+
+
+func is_local_moderator() -> bool:
+ return _session.can_local_moderate()
+
+
+func can_manage_operators() -> bool:
+ return _session.can_manage_operators()
+
+
+func is_open_host() -> bool:
+ return _session.is_open_host()
+
+
+func set_host_open(is_open: bool) -> bool:
+ return _session.set_host_open(is_open)
+
+
+func get_relationships() -> Array[Dictionary]:
+ return _relationships.get_records()
+
+
+func get_bans() -> Array[Dictionary]:
+ if _session.is_host():
+ return _bans.get_bans(_session.get_host_identity_fingerprint())
+ if not _session.is_local_operator():
+ return []
+ _request_ban_snapshot()
+ return _remote_bans.duplicate(true)
+
+
+func set_surface_drawing_service(
+ service: NetworkSurfaceDrawingService,
+) -> void:
+ _surface_drawing = service
+ if (
+ _surface_drawing != null
+ and not _surface_drawing.session_artwork_changed.is_connected(
+ _on_session_artwork_changed
+ )
+ ):
+ _surface_drawing.session_artwork_changed.connect(
+ _on_session_artwork_changed
+ )
+ _changed()
+
+
+func get_session_artwork_counts() -> Vector2i:
+ if _surface_drawing == null:
+ return Vector2i.ZERO
+ return Vector2i(
+ _surface_drawing.get_canvas_count(),
+ _surface_drawing.get_painted_cell_count(),
+ )
+
+
+func reset_session_artwork() -> bool:
+ if _session.is_host():
+ return _reset_session_artwork_on_host()
+ if not _session.is_local_operator():
+ return false
+ request_reset_session_artwork.rpc_id(1)
+ return true
+
+
+func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
+ if fingerprint == _session.get_local_identity_fingerprint():
+ return false
+ return _relationships.set_muted(fingerprint, display_name, value)
+
+
+func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool:
+ if fingerprint == _session.get_local_identity_fingerprint():
+ return false
+ return _relationships.set_blocked(fingerprint, display_name, value)
+
+
+func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
+ if _session.is_host():
+ return _kick_on_host(peer_id, fingerprint, revision, true)
+ if not _session.is_local_operator():
+ moderation_finished.emit(false, "Only the host or an operator can remove players.")
+ return false
+ request_kick.rpc_id(1, peer_id, fingerprint)
+ return true
+
+
+func ban(
+ peer_id: int,
+ fingerprint: String,
+ display_name: String,
+ revision: int,
+) -> bool:
+ if _session.is_host():
+ return _ban_on_host(
+ peer_id, fingerprint, display_name, revision, true
+ )
+ if not _session.is_local_operator():
+ moderation_finished.emit(false, "Only the host or an operator can ban players.")
+ return false
+ request_ban.rpc_id(1, peer_id, fingerprint)
+ return true
+
+
+func unban(fingerprint: String) -> bool:
+ if _session.is_host():
+ var ok: bool = _bans.unban(
+ _session.get_host_identity_fingerprint(), fingerprint
+ )
+ moderation_finished.emit(
+ ok, "Player unbanned." if ok else "Ban could not be removed."
+ )
+ return ok
+ if not _session.is_local_operator():
+ moderation_finished.emit(false, "Only the host or an operator can remove bans.")
+ return false
+ request_unban.rpc_id(1, fingerprint)
+ return true
+
+
+func set_operator(
+ peer_id: int,
+ fingerprint: String,
+ enabled: bool,
+ revision: int,
+) -> bool:
+ if (
+ not _session.can_manage_operators()
+ or not _valid_moderation_target(
+ peer_id, fingerprint, revision, true, true
+ )
+ ):
+ moderation_finished.emit(false, "That player is no longer connected.")
+ return false
+ var ok: bool = _session.set_peer_operator(peer_id, fingerprint, enabled)
+ moderation_finished.emit(
+ ok,
+ ("Player is now an operator." if enabled else "Operator access removed.")
+ if ok
+ else "Operator access could not be changed.",
+ )
+ return ok
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func request_kick(peer_id: int, fingerprint: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _valid_operator_sender(sender_id):
+ return
+ var ok: bool = _kick_on_host(peer_id, fingerprint, -1, false)
+ _send_moderation_result(
+ sender_id, ok, "Player removed." if ok else "Player could not be removed."
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func request_ban(peer_id: int, fingerprint: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _valid_operator_sender(sender_id):
+ return
+ var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
+ var display_name: String = record.display_name if record != null else "Player"
+ var ok: bool = _ban_on_host(
+ peer_id, fingerprint, display_name, -1, false
+ )
+ _send_moderation_result(
+ sender_id, ok, "Player banned." if ok else "Player could not be banned."
+ )
+ _send_ban_snapshot(sender_id)
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func request_unban(fingerprint: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ not _valid_operator_sender(sender_id)
+ or not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+ ):
+ return
+ var ok: bool = _bans.unban(
+ _session.get_host_identity_fingerprint(), fingerprint
+ )
+ _send_moderation_result(
+ sender_id, ok, "Player unbanned." if ok else "Ban could not be removed."
+ )
+ _send_ban_snapshot(sender_id)
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func request_reset_session_artwork() -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not _valid_operator_sender(sender_id):
+ return
+ var ok: bool = _reset_session_artwork_on_host(false)
+ _send_moderation_result(
+ sender_id,
+ ok,
+ "Session artwork cleared."
+ if ok
+ else "Session artwork could not be cleared.",
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func request_ban_snapshot() -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if _valid_operator_sender(sender_id):
+ _send_ban_snapshot(sender_id)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_moderation_result(success: bool, message: String) -> void:
+ if not _session.is_joined_client():
+ return
+ moderation_finished.emit(success, message.left(120))
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_ban_snapshot(records: Array) -> void:
+ if not _session.is_local_operator() or records.size() > HostBanStore.MAX_BANS:
+ return
+ var sanitized: Array[Dictionary] = []
+ for value: Variant in records:
+ if typeof(value) != TYPE_DICTIONARY:
+ return
+ var record: Dictionary = value
+ var fingerprint: String = str(record.get("target_fingerprint", ""))
+ var display_name: String = str(
+ record.get("last_known_display_name", "Player")
+ ).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH)
+ if (
+ not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+ or display_name.is_empty()
+ or typeof(record.get("banned_unix")) != TYPE_INT
+ ):
+ return
+ sanitized.append({
+ "target_fingerprint": fingerprint,
+ "last_known_display_name": display_name,
+ "banned_unix": int(record["banned_unix"]),
+ })
+ _remote_bans = sanitized
+ _ban_snapshot_requested = false
+ _ban_snapshot_loaded = true
+ _changed()
+
+
+func is_locally_blocked(fingerprint: String) -> bool:
+ return _relationships.is_blocked(fingerprint)
+
+
+func pair_is_blocked(first: String, second: String) -> bool:
+ if first.is_empty() or second.is_empty():
+ return false
+ return _host_block_pairs.has(_pair_key(first, second))
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_block_policy(target_fingerprint: String, blocked: bool) -> void:
+ var sender := multiplayer.get_remote_sender_id()
+ if not _session.is_host() or not _session.is_authenticated_peer(sender):
+ return
+ var record := _session.get_peer_record(sender)
+ if (
+ record == null
+ or not NetworkIdentityCrypto.valid_fingerprint(target_fingerprint)
+ or record.identity_fingerprint == target_fingerprint
+ ):
+ return
+ _set_host_pair(record.identity_fingerprint, target_fingerprint, blocked)
+
+
+func _on_relationship_changed(fingerprint: String) -> void:
+ var blocked := _relationships.is_blocked(fingerprint)
+ var avatar_peer := _peer_for_fingerprint(fingerprint)
+ if avatar_peer > 0:
+ _spawn.set_peer_presentation_visible(avatar_peer, not blocked)
+ var local_fingerprint := _session.get_local_identity_fingerprint()
+ if _session.is_host():
+ _set_host_pair(local_fingerprint, fingerprint, blocked)
+ elif _session.is_gameplay_session_active():
+ submit_block_policy.rpc_id(1, fingerprint, blocked)
+ _chat.refresh_relationship_filters()
+ _mail.refresh_relationship_filters()
+ _changed()
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ var record := _session.get_peer_record(peer_id)
+ if record != null:
+ _peer_fingerprints[peer_id] = record.identity_fingerprint
+ var blocked := _relationships.is_blocked(record.identity_fingerprint)
+ _spawn.set_peer_presentation_visible(peer_id, not blocked)
+ if blocked and peer_id != _session.get_local_peer_id():
+ var local_fingerprint := _session.get_local_identity_fingerprint()
+ if _session.is_host():
+ _set_host_pair(
+ local_fingerprint, record.identity_fingerprint, true
+ )
+ else:
+ submit_block_policy.rpc_id(
+ 1, record.identity_fingerprint, true
+ )
+ _changed()
+
+
+func _sync_authenticated_peers() -> void:
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if not _peer_fingerprints.has(peer_id):
+ _on_peer_authenticated(peer_id, "")
+ _changed()
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ var fingerprint := str(_peer_fingerprints.get(peer_id, ""))
+ _peer_fingerprints.erase(peer_id)
+ if _session.is_host() and not fingerprint.is_empty():
+ for key: String in _host_block_pairs.keys():
+ if fingerprint in key.split(":"):
+ _host_block_pairs.erase(key)
+ _changed()
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _host_block_pairs.clear()
+ _peer_fingerprints.clear()
+ _remote_bans.clear()
+ _ban_snapshot_requested = false
+ _ban_snapshot_loaded = false
+ elif state == NetworkSession.State.JOINED_CLIENT:
+ _request_ban_snapshot()
+ _changed()
+
+
+func _on_operator_status_changed(peer_id: int, enabled: bool) -> void:
+ if peer_id == _session.get_local_peer_id():
+ if enabled:
+ _request_ban_snapshot()
+ else:
+ _remote_bans.clear()
+ _ban_snapshot_requested = false
+ _ban_snapshot_loaded = false
+ _changed()
+
+
+func _set_host_pair(first: String, second: String, blocked: bool) -> void:
+ var key := _pair_key(first, second)
+ if blocked:
+ _host_block_pairs[key] = true
+ _mail.cancel_unaccepted_between(first, second)
+ else:
+ _host_block_pairs.erase(key)
+
+
+func _pair_key(first: String, second: String) -> String:
+ return "%s:%s" % ([first, second] if first < second else [second, first])
+
+
+func _peer_for_fingerprint(fingerprint: String) -> int:
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ var record := _session.get_peer_record(peer_id)
+ if record != null and record.identity_fingerprint == fingerprint:
+ return peer_id
+ return 0
+
+
+func _valid_moderation_target(
+ peer_id: int,
+ fingerprint: String,
+ revision: int,
+ require_revision: bool,
+ can_target_operator: bool,
+) -> bool:
+ if (
+ not _session.is_host()
+ or (require_revision and revision != _revision)
+ or peer_id == 1
+ or (not can_target_operator and _session.is_peer_operator(peer_id))
+ ):
+ return false
+ var record := _session.get_peer_record(peer_id)
+ return record != null and record.identity_fingerprint == fingerprint
+
+
+func _valid_operator_sender(peer_id: int) -> bool:
+ return (
+ _session.is_host()
+ and peer_id > 1
+ and _session.is_authenticated_peer(peer_id)
+ and _session.is_peer_operator(peer_id)
+ )
+
+
+func _kick_on_host(
+ peer_id: int,
+ fingerprint: String,
+ revision: int,
+ local_host_request: bool,
+) -> bool:
+ if not _valid_moderation_target(
+ peer_id,
+ fingerprint,
+ revision,
+ local_host_request,
+ local_host_request,
+ ):
+ if local_host_request:
+ moderation_finished.emit(false, "That player is no longer connected.")
+ return false
+ var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint)
+ if local_host_request:
+ moderation_finished.emit(
+ ok, "Player removed." if ok else "Player could not be removed."
+ )
+ return ok
+
+
+func _ban_on_host(
+ peer_id: int,
+ fingerprint: String,
+ display_name: String,
+ revision: int,
+ local_host_request: bool,
+) -> bool:
+ if not _valid_moderation_target(
+ peer_id,
+ fingerprint,
+ revision,
+ local_host_request,
+ local_host_request,
+ ):
+ if local_host_request:
+ moderation_finished.emit(false, "That player is no longer connected.")
+ return false
+ var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
+ var trusted_display_name: String = (
+ record.display_name if record != null else display_name
+ )
+ var host_fingerprint: String = _session.get_host_identity_fingerprint()
+ if not _bans.ban(host_fingerprint, fingerprint, trusted_display_name):
+ if local_host_request:
+ moderation_finished.emit(false, "Ban could not be saved.")
+ return false
+ var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint, true)
+ if local_host_request:
+ moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.")
+ return true
+
+
+func _reset_session_artwork_on_host(
+ emit_local_result: bool = true,
+) -> bool:
+ if not _session.is_host() or _surface_drawing == null:
+ return false
+ var ok: bool = _surface_drawing.clear_session_artwork()
+ if emit_local_result:
+ moderation_finished.emit(
+ ok,
+ "Session artwork cleared."
+ if ok
+ else "Session artwork could not be cleared.",
+ )
+ return ok
+
+
+func _send_moderation_result(
+ peer_id: int,
+ success: bool,
+ message: String,
+) -> void:
+ receive_moderation_result.rpc_id(peer_id, success, message.left(120))
+
+
+func _send_ban_snapshot(peer_id: int) -> void:
+ if not _valid_operator_sender(peer_id):
+ return
+ receive_ban_snapshot.rpc_id(
+ peer_id,
+ _bans.get_bans(_session.get_host_identity_fingerprint()),
+ )
+
+
+func _request_ban_snapshot() -> void:
+ if (
+ _session.is_local_operator()
+ and not _ban_snapshot_requested
+ and not _ban_snapshot_loaded
+ ):
+ _ban_snapshot_requested = true
+ request_ban_snapshot.rpc_id(1)
+
+
+func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool:
+ if a.is_host != b.is_host:
+ return a.is_host
+ if a.is_operator != b.is_operator:
+ return a.is_operator
+ if a.is_local_player != b.is_local_player:
+ return a.is_local_player
+ var compared := a.display_name.naturalnocasecmp_to(b.display_name)
+ return compared < 0 if compared != 0 else a.full_fingerprint < b.full_fingerprint
+
+
+func _changed() -> void:
+ _revision += 1
+ entries_changed.emit()
+
+
+func _on_session_artwork_changed(
+ _canvas_count: int,
+ _painted_cell_count: int,
+) -> void:
+ _changed()
diff --git a/network/network_player_list_service.gd.uid b/network/network_player_list_service.gd.uid
new file mode 100644
index 0000000..be485b6
--- /dev/null
+++ b/network/network_player_list_service.gd.uid
@@ -0,0 +1 @@
+uid://c71playerlist0001
diff --git a/network/network_profile_preferences.gd b/network/network_profile_preferences.gd
new file mode 100644
index 0000000..5ecb6fd
--- /dev/null
+++ b/network/network_profile_preferences.gd
@@ -0,0 +1,205 @@
+class_name NetworkProfilePreferences
+extends Node
+
+const FORMAT_VERSION: int = 1
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+var profile_id: String = ""
+var display_name: String = "Player"
+var voice_id: String = VoiceProfilesType.DEFAULT_ID
+var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
+var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
+var created_at_unix: int = 0
+var _profile_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+var _future_version := false
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _profile_path = path
+ _data_root = data_root
+
+
+func _temp_path() -> String:
+ return _profile_path + ".tmp"
+
+
+func _backup_path() -> String:
+ return _profile_path + ".backup"
+
+
+func load_or_create() -> bool:
+ if _profile_path.is_empty():
+ return false
+ _recover_interrupted_write()
+ if FileAccess.file_exists(_profile_path):
+ if _load_existing():
+ _expected_hash = PortableFileGuard.hash_file(_profile_path)
+ return true
+ if _future_version:
+ return false
+ var corrupt_path: String = (
+ "%s.corrupt.%d.json"
+ % [_profile_path.get_basename(), int(Time.get_unix_time_from_system())]
+ )
+ if not _rename(_profile_path, corrupt_path):
+ push_warning("Invalid network profile was preserved and not overwritten.")
+ return false
+ profile_id = Crypto.new().generate_random_bytes(16).hex_encode()
+ if profile_id.is_empty():
+ profile_id = "%d-%d" % [
+ int(Time.get_unix_time_from_system()),
+ Time.get_ticks_usec(),
+ ]
+ display_name = "Player"
+ voice_id = VoiceProfilesType.DEFAULT_ID
+ speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
+ call_id = VoiceProfilesType.DEFAULT_CALL_ID
+ created_at_unix = int(Time.get_unix_time_from_system())
+ return _save_atomic()
+
+
+func set_display_name(value: String) -> bool:
+ return set_profile_identity(value, voice_id, speech_speed_id, call_id)
+
+
+func set_profile_identity(
+ value: String,
+ selected_voice_id: String,
+ selected_speech_speed_id: String,
+ selected_call_id: String,
+) -> bool:
+ var clean_name: String = value.strip_edges()
+ if (
+ not is_valid_display_name(clean_name)
+ or not VoiceProfilesType.is_valid(selected_voice_id)
+ or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id)
+ or not VoiceProfilesType.is_valid_call(selected_call_id)
+ ):
+ return false
+ var previous_name: String = display_name
+ var previous_voice_id: String = voice_id
+ var previous_speech_speed_id: String = speech_speed_id
+ var previous_call_id: String = call_id
+ display_name = clean_name
+ voice_id = selected_voice_id
+ speech_speed_id = selected_speech_speed_id
+ call_id = selected_call_id
+ if _save_atomic():
+ return true
+ display_name = previous_name
+ voice_id = previous_voice_id
+ speech_speed_id = previous_speech_speed_id
+ call_id = previous_call_id
+ return false
+
+
+static func is_valid_display_name(value: String) -> bool:
+ var clean_name := value.strip_edges()
+ if clean_name.is_empty() or clean_name.length() > 24:
+ return false
+ for index: int in clean_name.length():
+ var codepoint: int = clean_name.unicode_at(index)
+ if codepoint < 32 or codepoint == 127:
+ return false
+ return true
+
+
+func _load_existing() -> bool:
+ var file := FileAccess.open(_profile_path, FileAccess.READ)
+ if file == null:
+ return false
+ var json := JSON.new()
+ var error: Error = json.parse(file.get_as_text())
+ file.close()
+ if error != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return false
+ var data: Dictionary = json.data
+ if (
+ typeof(data.get("format_version")) in [TYPE_INT, TYPE_FLOAT]
+ and int(data.get("format_version")) > FORMAT_VERSION
+ ):
+ _future_version = true
+ return false
+ if (
+ data.get("format_version") != FORMAT_VERSION
+ or typeof(data.get("profile_id")) != TYPE_STRING
+ or typeof(data.get("display_name")) != TYPE_STRING
+ or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
+ ):
+ return false
+ var loaded_id: String = data["profile_id"]
+ var loaded_name: String = data["display_name"]
+ if (
+ loaded_id.is_empty()
+ or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH
+ or loaded_name.is_empty()
+ or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
+ ):
+ return false
+ profile_id = loaded_id
+ display_name = loaded_name
+ voice_id = VoiceProfilesType.sanitized_id(
+ str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
+ )
+ speech_speed_id = VoiceProfilesType.sanitized_speed_id(
+ str(data.get("speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID))
+ )
+ call_id = VoiceProfilesType.sanitized_call_id(
+ str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
+ )
+ created_at_unix = int(data["created_at_unix"])
+ return true
+
+
+func _save_atomic() -> bool:
+ var data: Dictionary = {
+ "format_version": FORMAT_VERSION,
+ "profile_id": profile_id,
+ "display_name": display_name,
+ "voice_id": voice_id,
+ "speech_speed_id": speech_speed_id,
+ "call_id": call_id,
+ "created_at_unix": created_at_unix,
+ }
+ var result := PortableFileGuard.write_guarded(
+ _profile_path,
+ JSON.stringify(data, "\t").to_utf8_buffer(),
+ _expected_hash,
+ _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(
+ str(result.get("message", "")),
+ str(result.get("conflict_path", "")),
+ )
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
+
+
+func _recover_interrupted_write() -> void:
+ if FileAccess.file_exists(_profile_path):
+ _remove_if_present(_temp_path())
+ _remove_if_present(_backup_path())
+ return
+ if FileAccess.file_exists(_backup_path()):
+ _rename(_backup_path(), _profile_path)
+ _remove_if_present(_temp_path())
+
+
+func _rename(from_path: String, to_path: String) -> bool:
+ return DirAccess.rename_absolute(
+ ProjectSettings.globalize_path(from_path),
+ ProjectSettings.globalize_path(to_path)
+ ) == OK
+
+
+func _remove_if_present(path: String) -> bool:
+ return (
+ not FileAccess.file_exists(path)
+ or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
+ )
diff --git a/network/network_profile_preferences.gd.uid b/network/network_profile_preferences.gd.uid
new file mode 100644
index 0000000..8e27e50
--- /dev/null
+++ b/network/network_profile_preferences.gd.uid
@@ -0,0 +1 @@
+uid://db8ua5psoql0w
diff --git a/network/network_profile_protocol.gd b/network/network_profile_protocol.gd
new file mode 100644
index 0000000..32c44b6
--- /dev/null
+++ b/network/network_profile_protocol.gd
@@ -0,0 +1,69 @@
+class_name NetworkProfileProtocol
+extends RefCounted
+
+const RELIABLE_CHANNEL: int = 0
+const MAX_SUGGESTIONS: int = 3
+const MAX_REQUEST_ID_LENGTH: int = 64
+
+
+static func valid_request_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_REQUEST_ID_LENGTH
+ )
+
+
+static func valid_snapshot(value: Variant) -> bool:
+ return CharacterCustomizationCatalog.validate_snapshot(value)
+
+
+static func valid_check_request(data: Variant) -> bool:
+ return (
+ typeof(data) == TYPE_DICTIONARY
+ and valid_request_id(data.get("request_id"))
+ and typeof(data.get("session_id")) == TYPE_STRING
+ and typeof(data.get("display_name")) == TYPE_STRING
+ and NetworkProfilePreferences.is_valid_display_name(data["display_name"])
+ )
+
+
+static func valid_apply_request(data: Variant) -> bool:
+ return (
+ valid_check_request(data)
+ and typeof(data.get("appearance")) == TYPE_DICTIONARY
+ and valid_snapshot(data["appearance"])
+ and typeof(data.get("use_anyway")) == TYPE_BOOL
+ and NetworkIdentityCrypto.valid_fingerprint(
+ data.get("sender_fingerprint")
+ )
+ and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
+ )
+
+
+static func signature_fields(data: Dictionary) -> Array:
+ var appearance: Dictionary = data.get("appearance", {})
+ return [
+ str(data.get("session_id", "")),
+ str(data.get("request_id", "")),
+ str(data.get("sender_fingerprint", "")),
+ str(data.get("display_name", "")),
+ str(appearance.get("species", "")),
+ CharacterCustomizationCatalog.character_scale_percent(
+ appearance.get(
+ CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
+ CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
+ )
+ ),
+ str(appearance.get("fur_pattern", "")),
+ str(appearance.get("fur_style", "")),
+ str(appearance.get("fur_color_2", "")),
+ str(appearance.get("fur_color_3", "")),
+ str(appearance.get("fur_color_4", "")),
+ str(appearance.get("ears", "")),
+ str(appearance.get("eyes", "")),
+ str(appearance.get("nose", "")),
+ str(appearance.get("mouth", "")),
+ str(appearance.get("tail", "")),
+ bool(data.get("use_anyway", false)),
+ ]
diff --git a/network/network_profile_protocol.gd.uid b/network/network_profile_protocol.gd.uid
new file mode 100644
index 0000000..8a5943c
--- /dev/null
+++ b/network/network_profile_protocol.gd.uid
@@ -0,0 +1 @@
+uid://dhv483bfl4get
diff --git a/network/network_profile_service.gd b/network/network_profile_service.gd
new file mode 100644
index 0000000..86b1be1
--- /dev/null
+++ b/network/network_profile_service.gd
@@ -0,0 +1,551 @@
+class_name NetworkProfileService
+extends Node
+
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+
+signal conflict_result(
+ request_id: String,
+ has_conflict: bool,
+ suggestions: PackedStringArray,
+)
+signal apply_finished(accepted: bool, message: String)
+signal profile_snapshot_changed(
+ peer_id: int,
+ display_name: String,
+ appearance: Dictionary,
+)
+
+var _session: NetworkSession
+var _preferences: NetworkProfilePreferences
+var _appearance_store: PlayerAppearanceStore
+var _spawn_service: PlayerSpawnService
+var _pending_apply: Dictionary[String, Dictionary] = {}
+var _pending_voice_ids: Dictionary[String, String] = {}
+var _pending_speech_speed_ids: Dictionary[String, String] = {}
+var _pending_call_ids: Dictionary[String, String] = {}
+var _host_pending_apply: Dictionary[String, Dictionary] = {}
+var _latest_check_id: String = ""
+var _latest_check_name: String = ""
+
+
+func setup(
+ session: NetworkSession,
+ preferences: NetworkProfilePreferences,
+ appearance_store: PlayerAppearanceStore,
+ spawn_service: PlayerSpawnService,
+) -> void:
+ _session = session
+ _preferences = preferences
+ _appearance_store = appearance_store
+ _spawn_service = spawn_service
+ _appearance_store.load_preferences()
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ _session.peer_removed.connect(_on_peer_removed)
+ _session.peer_display_name_changed.connect(_on_peer_display_name_changed)
+ _session.join_authenticated.connect(_on_join_authenticated)
+ _session.state_changed.connect(_on_session_state_changed)
+ _apply_to_avatar(1, _appearance_store.get_snapshot())
+ _apply_local_voice_to_avatar()
+
+
+func get_persisted_name() -> String:
+ return _preferences.display_name
+
+
+func get_persisted_appearance() -> Dictionary:
+ return _appearance_store.get_snapshot()
+
+
+func get_persisted_voice_id() -> String:
+ return _preferences.voice_id
+
+
+func get_persisted_speech_speed_id() -> String:
+ return _preferences.speech_speed_id
+
+
+func get_persisted_call_id() -> String:
+ return _preferences.call_id
+
+
+func get_identity_fingerprint() -> String:
+ return (
+ _session.get_local_identity_fingerprint()
+ if _session != null else ""
+ )
+
+
+func request_name_check(display_name: String) -> String:
+ var request_id := _new_id()
+ _latest_check_id = request_id
+ var clean_name := display_name.strip_edges()
+ _latest_check_name = clean_name
+ if not NetworkProfilePreferences.is_valid_display_name(clean_name):
+ conflict_result.emit(request_id, false, PackedStringArray())
+ return request_id
+ if _session == null or not _session.is_gameplay_session_active():
+ conflict_result.emit(request_id, false, PackedStringArray())
+ elif _session.is_host():
+ var result := _make_conflict_result(
+ _session.get_local_peer_id(), clean_name, request_id
+ )
+ _emit_conflict_result(result)
+ elif _session.supports_server_capability(&"profile_v1"):
+ submit_name_check.rpc_id(1, {
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "display_name": clean_name,
+ })
+ else:
+ conflict_result.emit(request_id, false, PackedStringArray())
+ return request_id
+
+
+func apply_profile(
+ display_name: String,
+ appearance: Dictionary,
+ use_anyway: bool,
+ voice_id: String = VoiceProfilesType.DEFAULT_ID,
+ speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID,
+ call_id: String = VoiceProfilesType.DEFAULT_CALL_ID,
+) -> bool:
+ var clean_name := display_name.strip_edges()
+ if (
+ not NetworkProfilePreferences.is_valid_display_name(clean_name)
+ or not CharacterCustomizationCatalog.validate_snapshot(appearance)
+ or not VoiceProfilesType.is_valid(voice_id)
+ or not VoiceProfilesType.is_valid_speed(speech_speed_id)
+ or not VoiceProfilesType.is_valid_call(call_id)
+ ):
+ apply_finished.emit(false, "Check the player name and appearance choices.")
+ return false
+ var request_id := _new_id()
+ var request := {
+ "request_id": request_id,
+ "session_id": _session.get_session_id() if _session != null else "",
+ "display_name": clean_name,
+ "appearance": appearance.duplicate(true),
+ "use_anyway": use_anyway,
+ "sender_fingerprint": _session.get_local_identity_fingerprint(),
+ }
+ request["sender_signature"] = _session.sign_local_action(
+ "profile_update", NetworkProfileProtocol.signature_fields(request)
+ )
+ _pending_apply[request_id] = request
+ _pending_voice_ids[request_id] = voice_id
+ _pending_speech_speed_ids[request_id] = speech_speed_id
+ _pending_call_ids[request_id] = call_id
+ if _session == null or not _session.is_gameplay_session_active():
+ _apply_local_result(request_id, true, "", false, PackedStringArray())
+ elif _session.is_host():
+ _process_apply_request(_session.get_local_peer_id(), request)
+ elif _session.supports_server_capability(&"profile_v1"):
+ submit_profile_apply.rpc_id(1, request)
+ else:
+ _apply_local_result(request_id, true, "", false, PackedStringArray())
+ return true
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
+func submit_name_check(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if (
+ not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ or not NetworkProfileProtocol.valid_check_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ receive_name_check.rpc_id(
+ sender_id,
+ _make_conflict_result(sender_id, str(data["display_name"]), str(data["request_id"])),
+ )
+
+
+@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
+func receive_name_check(data: Dictionary) -> void:
+ if (
+ typeof(data.get("request_id")) != TYPE_STRING
+ or typeof(data.get("has_conflict")) != TYPE_BOOL
+ or typeof(data.get("suggestions")) != TYPE_ARRAY
+ ):
+ return
+ _emit_conflict_result(data)
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
+func submit_profile_apply(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if (
+ not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ or not NetworkProfileProtocol.valid_apply_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ var record := _session.get_peer_record(sender_id)
+ if (
+ record == null
+ or record.identity_fingerprint != str(data["sender_fingerprint"])
+ or not _session.verify_peer_action(
+ sender_id,
+ "profile_update",
+ NetworkProfileProtocol.signature_fields(data),
+ data["sender_signature"],
+ )
+ ):
+ return
+ _process_apply_request(sender_id, data)
+
+
+func _process_apply_request(peer_id: int, data: Dictionary) -> void:
+ var display_name: String = str(data["display_name"]).strip_edges()
+ var conflict: bool = _name_conflicts(peer_id, display_name)
+ var accepted: bool = not conflict or bool(data["use_anyway"])
+ var suggestions: PackedStringArray = (
+ _make_suggestions(peer_id, display_name)
+ if conflict
+ else PackedStringArray()
+ )
+ if peer_id == _session.get_local_peer_id():
+ _apply_local_result(
+ str(data["request_id"]), accepted, "", conflict, suggestions
+ )
+ else:
+ if accepted:
+ _host_pending_apply[str(data["request_id"])] = {
+ "peer_id": peer_id,
+ "display_name": display_name,
+ "appearance": Dictionary(data["appearance"]).duplicate(true),
+ "authorization": {
+ "request_id": data["request_id"],
+ "session_id": data["session_id"],
+ "sender_fingerprint": data["sender_fingerprint"],
+ "sender_signature": data["sender_signature"],
+ "use_anyway": data["use_anyway"],
+ },
+ }
+ receive_profile_result.rpc_id(peer_id, {
+ "request_id": data["request_id"],
+ "accepted": accepted,
+ "conflict": conflict,
+ "suggestions": Array(suggestions),
+ "message": "" if accepted else "That name is already in use in this game.",
+ })
+
+
+@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
+func confirm_profile_saved(request_id: String) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ var pending: Dictionary = _host_pending_apply.get(request_id, {})
+ if (
+ not _session.is_host()
+ or pending.is_empty()
+ or int(pending["peer_id"]) != sender_id
+ ):
+ return
+ _host_pending_apply.erase(request_id)
+ var display_name: String = str(pending["display_name"])
+ var appearance: Dictionary = Dictionary(pending["appearance"])
+ _session.apply_canonical_profile(sender_id, display_name, appearance)
+ var record: PeerRegistry.PeerRecord = _session.get_peer_record(sender_id)
+ if record != null:
+ record.profile_authorization = pending.get("authorization", {}).duplicate(true)
+ _apply_to_avatar(sender_id, appearance)
+ broadcast_profile_snapshot.rpc(
+ sender_id,
+ name,
+ appearance,
+ pending.get("authorization", {}),
+ )
+
+
+@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
+func receive_profile_result(data: Dictionary) -> void:
+ if (
+ typeof(data.get("request_id")) != TYPE_STRING
+ or typeof(data.get("accepted")) != TYPE_BOOL
+ or typeof(data.get("conflict")) != TYPE_BOOL
+ or typeof(data.get("suggestions")) != TYPE_ARRAY
+ ):
+ return
+ var suggestions := PackedStringArray(data["suggestions"])
+ _apply_local_result(
+ str(data["request_id"]),
+ bool(data["accepted"]),
+ str(data.get("message", "")),
+ bool(data["conflict"]),
+ suggestions,
+ )
+
+
+func _apply_local_result(
+ request_id: String,
+ accepted: bool,
+ message: String,
+ conflict: bool,
+ suggestions: PackedStringArray,
+) -> void:
+ var request: Dictionary = _pending_apply.get(request_id, {})
+ if request.is_empty():
+ return
+ if not accepted:
+ _pending_apply.erase(request_id)
+ _pending_voice_ids.erase(request_id)
+ _pending_speech_speed_ids.erase(request_id)
+ _pending_call_ids.erase(request_id)
+ conflict_result.emit(request_id, conflict, suggestions)
+ apply_finished.emit(false, message)
+ return
+ var previous_name := _preferences.display_name
+ var previous_voice_id := _preferences.voice_id
+ var previous_speech_speed_id := _preferences.speech_speed_id
+ var previous_call_id := _preferences.call_id
+ var requested_voice_id: String = _pending_voice_ids.get(
+ request_id,
+ VoiceProfilesType.DEFAULT_ID,
+ )
+ var requested_speech_speed_id: String = _pending_speech_speed_ids.get(
+ request_id,
+ VoiceProfilesType.DEFAULT_SPEED_ID,
+ )
+ var requested_call_id: String = _pending_call_ids.get(
+ request_id,
+ VoiceProfilesType.DEFAULT_CALL_ID,
+ )
+ if not _preferences.set_profile_identity(
+ str(request["display_name"]),
+ requested_voice_id,
+ requested_speech_speed_id,
+ requested_call_id,
+ ):
+ _pending_apply.erase(request_id)
+ _pending_voice_ids.erase(request_id)
+ _pending_speech_speed_ids.erase(request_id)
+ _pending_call_ids.erase(request_id)
+ apply_finished.emit(false, "Profile could not be saved.")
+ return
+ if not _appearance_store.save_snapshot(request["appearance"]):
+ _preferences.set_profile_identity(
+ previous_name,
+ previous_voice_id,
+ previous_speech_speed_id,
+ previous_call_id,
+ )
+ _pending_apply.erase(request_id)
+ _pending_voice_ids.erase(request_id)
+ _pending_speech_speed_ids.erase(request_id)
+ _pending_call_ids.erase(request_id)
+ apply_finished.emit(false, "Profile could not be saved.")
+ return
+ _pending_apply.erase(request_id)
+ _pending_voice_ids.erase(request_id)
+ _pending_speech_speed_ids.erase(request_id)
+ _pending_call_ids.erase(request_id)
+ if _session != null and _session.is_gameplay_session_active():
+ if _session.is_host():
+ _session.apply_canonical_profile(
+ _session.get_local_peer_id(),
+ _preferences.display_name,
+ _appearance_store.get_snapshot(),
+ )
+ var record := _session.get_peer_record(
+ _session.get_local_peer_id()
+ )
+ if record != null:
+ record.profile_authorization = request.duplicate(true)
+ broadcast_profile_snapshot.rpc(
+ _session.get_local_peer_id(),
+ _preferences.display_name,
+ _appearance_store.get_snapshot(),
+ request,
+ )
+ elif _session.supports_server_capability(&"profile_v1"):
+ confirm_profile_saved.rpc_id(1, request_id)
+ elif _session.supports_server_capability(&"chat_v1"):
+ _session.update_local_display_name(_preferences.display_name)
+ _apply_to_avatar(
+ _session.get_local_peer_id() if _session != null else 1,
+ _appearance_store.get_snapshot(),
+ )
+ _apply_local_voice_to_avatar()
+ apply_finished.emit(true, "Profile saved.")
+
+
+@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
+func broadcast_profile_snapshot(
+ peer_id: int,
+ display_name: String,
+ appearance: Dictionary,
+ authorization: Dictionary = {},
+) -> void:
+ if (
+ not NetworkProfilePreferences.is_valid_display_name(display_name)
+ or not CharacterCustomizationCatalog.validate_snapshot(appearance)
+ ):
+ return
+ if not authorization.is_empty():
+ var signed := authorization.duplicate(true)
+ signed["display_name"] = display_name
+ signed["appearance"] = appearance
+ var record := _session.get_peer_record(peer_id)
+ if (
+ record == null
+ or record.identity_fingerprint
+ != str(signed.get("sender_fingerprint", ""))
+ or not _session.verify_peer_action(
+ peer_id,
+ "profile_update",
+ NetworkProfileProtocol.signature_fields(signed),
+ signed.get("sender_signature", PackedByteArray()),
+ )
+ ):
+ return
+ _session.apply_canonical_profile(peer_id, display_name, appearance)
+ _apply_to_avatar(peer_id, appearance)
+ profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true))
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ if not _session.is_host():
+ return
+ for existing_id: int in _session.get_authenticated_peer_ids():
+ var record := _session.get_peer_record(existing_id)
+ if record == null:
+ continue
+ broadcast_profile_snapshot.rpc_id(
+ peer_id,
+ existing_id,
+ record.display_name,
+ record.appearance_snapshot,
+ record.profile_authorization,
+ )
+
+
+func _on_join_authenticated() -> void:
+ _apply_to_avatar(
+ _session.get_local_peer_id(), _appearance_store.get_snapshot()
+ )
+ _apply_local_voice_to_avatar()
+
+
+func _on_peer_removed(_peer_id: int) -> void:
+ for request_id: String in _host_pending_apply.keys():
+ if int(_host_pending_apply[request_id].get("peer_id", 0)) == _peer_id:
+ _host_pending_apply.erase(request_id)
+ call_deferred("_refresh_latest_check")
+
+
+func _on_peer_display_name_changed(
+ _peer_id: int,
+ _display_name: String,
+) -> void:
+ call_deferred("_refresh_latest_check")
+
+
+func _refresh_latest_check() -> void:
+ if (
+ _latest_check_name.is_empty()
+ or _session == null
+ or not _session.is_gameplay_session_active()
+ ):
+ return
+ var peer := multiplayer.multiplayer_peer
+ if (
+ _session.is_joined_client()
+ and (
+ peer == null
+ or peer.get_connection_status()
+ != MultiplayerPeer.CONNECTION_CONNECTED
+ )
+ ):
+ return
+ request_name_check(_latest_check_name)
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state == NetworkSession.State.INACTIVE:
+ _pending_apply.clear()
+ _pending_voice_ids.clear()
+ _pending_speech_speed_ids.clear()
+ _pending_call_ids.clear()
+ _host_pending_apply.clear()
+ _latest_check_id = ""
+ _latest_check_name = ""
+
+
+func _emit_conflict_result(data: Dictionary) -> void:
+ var request_id := str(data["request_id"])
+ if request_id != _latest_check_id:
+ return
+ conflict_result.emit(
+ request_id,
+ bool(data["has_conflict"]),
+ PackedStringArray(data["suggestions"]),
+ )
+
+
+func _make_conflict_result(
+ peer_id: int,
+ display_name: String,
+ request_id: String,
+) -> Dictionary:
+ var conflict := _name_conflicts(peer_id, display_name)
+ return {
+ "request_id": request_id,
+ "has_conflict": conflict,
+ "suggestions": Array(
+ _make_suggestions(peer_id, display_name)
+ if conflict else PackedStringArray()
+ ),
+ }
+
+
+func _name_conflicts(peer_id: int, display_name: String) -> bool:
+ var normalized := display_name.strip_edges().to_lower()
+ for other_id: int in _session.get_authenticated_peer_ids():
+ if other_id == peer_id:
+ continue
+ var record := _session.get_peer_record(other_id)
+ if record != null and record.display_name.strip_edges().to_lower() == normalized:
+ return true
+ return false
+
+
+func _make_suggestions(peer_id: int, base_name: String) -> PackedStringArray:
+ var result := PackedStringArray()
+ var suffix := 2
+ while result.size() < NetworkProfileProtocol.MAX_SUGGESTIONS and suffix < 1000:
+ var suffix_text := " %d" % suffix
+ var candidate := base_name.left(
+ NetworkProtocol.MAX_DISPLAY_NAME_LENGTH - suffix_text.length()
+ ) + suffix_text
+ if not _name_conflicts(peer_id, candidate) and candidate not in result:
+ result.append(candidate)
+ suffix += 1
+ return result
+
+
+func _apply_to_avatar(peer_id: int, appearance: Dictionary) -> void:
+ if _spawn_service == null:
+ return
+ var avatar := _spawn_service.get_avatar(peer_id)
+ if avatar != null:
+ avatar.apply_appearance_snapshot(appearance)
+
+
+func _apply_local_voice_to_avatar() -> void:
+ if _spawn_service == null:
+ return
+ var local_peer_id := (
+ _session.get_local_peer_id() if _session != null else 1
+ )
+ var avatar := _spawn_service.get_avatar(local_peer_id)
+ if avatar != null:
+ avatar.apply_animalese_voice_id(_preferences.voice_id)
+
+
+func _new_id() -> String:
+ return Crypto.new().generate_random_bytes(16).hex_encode()
diff --git a/network/network_profile_service.gd.uid b/network/network_profile_service.gd.uid
new file mode 100644
index 0000000..4b3fcf7
--- /dev/null
+++ b/network/network_profile_service.gd.uid
@@ -0,0 +1 @@
+uid://dc80t6nmksvm2
diff --git a/network/network_protocol.gd b/network/network_protocol.gd
new file mode 100644
index 0000000..7250fba
--- /dev/null
+++ b/network/network_protocol.gd
@@ -0,0 +1,360 @@
+class_name NetworkProtocol
+extends RefCounted
+
+const PROTOCOL_VERSION: int = 4
+const GAME_BUILD: String = "prealpha"
+const MAX_GAME_VERSION_LENGTH: int = 64
+const MAX_DISPLAY_NAME_LENGTH: int = 24
+const MAX_PROFILE_ID_LENGTH: int = 96
+const MAX_NONCE_LENGTH: int = 96
+const MAX_PUBLIC_KEY_LENGTH: int = 8192
+const MAX_SIGNATURE_LENGTH: int = 2048
+# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
+# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
+# 6 reliable shop transactions, 7 reliable item/equipment/showcase/drawing,
+# 8 reliable ordered session chat, 9 reliable private session mail.
+const SALE_RELIABLE_CHANNEL: int = 5
+const SHOP_RELIABLE_CHANNEL: int = 6
+const ITEM_RELIABLE_CHANNEL: int = 7
+const CHAT_RELIABLE_CHANNEL: int = 8
+const MAIL_RELIABLE_CHANNEL: int = 9
+const ENET_CHANNEL_COUNT: int = 10
+const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
+const ART_SHOP_CAPABILITY: String = "art_shop_v1"
+const WORLD_TIME_CAPABILITY: String = "world_time_v1"
+const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1"
+const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
+const JOBS_CAPABILITY: String = "jobs_v1"
+
+enum RejectionCode {
+ NONE,
+ MALFORMED_HANDSHAKE,
+ PROTOCOL_MISMATCH,
+ SERVER_FULL,
+ DUPLICATE_PROFILE,
+ DUPLICATE_IDENTITY,
+ INVALID_IDENTITY_PROOF,
+ AUTHENTICATION_TIMEOUT,
+ SERVER_SHUTTING_DOWN,
+ UNSUPPORTED_CLIENT,
+ BANNED,
+ CLIENT_OUTDATED,
+ SERVER_OUTDATED,
+ VERSION_MISMATCH,
+}
+
+
+static func game_version() -> String:
+ return str(ProjectSettings.get_setting(
+ "application/config/version", "unknown"
+ )).strip_edges()
+
+
+static func game_version_rejection(
+ client_version: String,
+ server_version: String,
+) -> RejectionCode:
+ if client_version == server_version:
+ return RejectionCode.NONE
+ var comparison: int = _compare_game_versions(
+ client_version, server_version
+ )
+ if comparison < 0:
+ return RejectionCode.CLIENT_OUTDATED
+ if comparison > 0:
+ return RejectionCode.SERVER_OUTDATED
+ return RejectionCode.VERSION_MISMATCH
+
+
+static func _compare_game_versions(first: String, second: String) -> int:
+ var first_parts: PackedStringArray = first.split("-", true, 1)
+ var second_parts: PackedStringArray = second.split("-", true, 1)
+ var first_core: PackedStringArray = first_parts[0].split(".")
+ var second_core: PackedStringArray = second_parts[0].split(".")
+ for index: int in maxi(first_core.size(), second_core.size()):
+ var first_number: int = (
+ int(first_core[index])
+ if index < first_core.size() and first_core[index].is_valid_int()
+ else 0
+ )
+ var second_number: int = (
+ int(second_core[index])
+ if index < second_core.size() and second_core[index].is_valid_int()
+ else 0
+ )
+ if first_number != second_number:
+ return -1 if first_number < second_number else 1
+ var first_rank: int = _prerelease_rank(
+ first_parts[1] if first_parts.size() > 1 else ""
+ )
+ var second_rank: int = _prerelease_rank(
+ second_parts[1] if second_parts.size() > 1 else ""
+ )
+ if first_rank != second_rank:
+ return -1 if first_rank < second_rank else 1
+ return 0
+
+
+static func _prerelease_rank(label: String) -> int:
+ var normalized: String = label.to_lower()
+ if normalized.is_empty():
+ return 5
+ if normalized.begins_with("dev"):
+ return 0
+ if normalized.begins_with("prealpha"):
+ return 1
+ if normalized.begins_with("alpha"):
+ return 2
+ if normalized.begins_with("beta"):
+ return 3
+ if normalized.begins_with("rc"):
+ return 4
+ return 2
+
+
+static func make_identity_hello(
+ public_key: String,
+ fingerprint: String,
+ client_nonce: String,
+ attempt_id: String,
+) -> Dictionary:
+ return {
+ "protocol_version": PROTOCOL_VERSION,
+ "game_version": game_version(),
+ "public_key": public_key,
+ "fingerprint": fingerprint,
+ "client_nonce": client_nonce,
+ "attempt_id": attempt_id,
+ "capability_flags": PackedStringArray(["identity_v1"]),
+ }
+
+
+static func validate_identity_hello(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ typeof(value.get("protocol_version")) == TYPE_INT
+ and typeof(value.get("game_version")) == TYPE_STRING
+ and not str(value["game_version"]).is_empty()
+ and str(value["game_version"]).length() <= MAX_GAME_VERSION_LENGTH
+ and typeof(value.get("public_key")) == TYPE_STRING
+ and str(value["public_key"]).to_utf8_buffer().size() <= MAX_PUBLIC_KEY_LENGTH
+ and NetworkIdentityCrypto.valid_fingerprint(value.get("fingerprint"))
+ and typeof(value.get("client_nonce")) == TYPE_STRING
+ and str(value["client_nonce"]).length() == 64
+ and typeof(value.get("attempt_id")) == TYPE_STRING
+ and str(value["attempt_id"]).length() == 32
+ and typeof(value.get("capability_flags")) in [
+ TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
+ ]
+ )
+
+
+static func make_client_hello(
+ profile_id: String,
+ display_name: String,
+ client_nonce: String,
+ cosmetic_snapshot: Dictionary = {},
+ identity_fingerprint: String = "",
+ identity_signature: PackedByteArray = PackedByteArray(),
+) -> Dictionary:
+ return {
+ "protocol_version": PROTOCOL_VERSION,
+ "game_version": game_version(),
+ "game_build": GAME_BUILD,
+ "local_profile_id": profile_id,
+ "display_name": display_name,
+ "client_nonce": client_nonce,
+ "capability_flags": PackedStringArray([
+ FISH_QUALITY_CAPABILITY,
+ SURFACE_DRAWING_CAPABILITY,
+ WORLD_TIME_CAPABILITY,
+ WORLD_WEATHER_CAPABILITY,
+ JOBS_CAPABILITY,
+ ]),
+ "cosmetic_snapshot": cosmetic_snapshot,
+ "identity_fingerprint": identity_fingerprint,
+ "identity_signature": identity_signature,
+ }
+
+
+static func validate_client_hello(data: Variant) -> String:
+ if typeof(data) != TYPE_DICTIONARY:
+ return "Handshake payload is not a dictionary."
+ var payload: Dictionary = data
+ for key: String in [
+ "protocol_version",
+ "game_version",
+ "game_build",
+ "local_profile_id",
+ "display_name",
+ "client_nonce",
+ "capability_flags",
+ "cosmetic_snapshot",
+ "identity_fingerprint",
+ "identity_signature",
+ ]:
+ if not payload.has(key):
+ return "Handshake is missing %s." % key
+ if typeof(payload["protocol_version"]) != TYPE_INT:
+ return "Protocol version is invalid."
+ if (
+ typeof(payload["game_version"]) != TYPE_STRING
+ or str(payload["game_version"]).is_empty()
+ or str(payload["game_version"]).length() > MAX_GAME_VERSION_LENGTH
+ ):
+ return "Game version is invalid."
+ if typeof(payload["game_build"]) != TYPE_STRING:
+ return "Game build is invalid."
+ if typeof(payload["local_profile_id"]) != TYPE_STRING:
+ return "Profile ID is invalid."
+ if typeof(payload["display_name"]) != TYPE_STRING:
+ return "Display name is invalid."
+ if typeof(payload["client_nonce"]) != TYPE_STRING:
+ return "Client nonce is invalid."
+ if typeof(payload["capability_flags"]) not in [
+ TYPE_PACKED_STRING_ARRAY,
+ TYPE_ARRAY,
+ ]:
+ return "Capabilities are invalid."
+ var capabilities: Variant = payload["capability_flags"]
+ if capabilities.size() > 32:
+ return "Capabilities are invalid."
+ for capability: Variant in capabilities:
+ if (
+ typeof(capability) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or str(capability).is_empty()
+ or str(capability).length() > 64
+ ):
+ return "Capabilities are invalid."
+ if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
+ return "Cosmetic snapshot is invalid."
+ if (
+ not NetworkIdentityCrypto.valid_fingerprint(
+ payload["identity_fingerprint"]
+ )
+ or typeof(payload["identity_signature"]) != TYPE_PACKED_BYTE_ARRAY
+ ):
+ return "Profile identity proof is invalid."
+ var profile_id: String = payload["local_profile_id"]
+ var display_name: String = payload["display_name"]
+ var nonce: String = payload["client_nonce"]
+ if (
+ profile_id.is_empty()
+ or profile_id.length() > MAX_PROFILE_ID_LENGTH
+ or display_name.is_empty()
+ or display_name.length() > MAX_DISPLAY_NAME_LENGTH
+ or nonce.is_empty()
+ or nonce.length() > MAX_NONCE_LENGTH
+ ):
+ return "Handshake strings are outside allowed limits."
+ return ""
+
+
+static func client_profile_fields(data: Dictionary) -> Array:
+ var appearance: Dictionary = data.get("cosmetic_snapshot", {})
+ return [
+ str(data.get("game_version", "")),
+ str(data.get("client_nonce", "")),
+ str(data.get("identity_fingerprint", "")),
+ str(data.get("local_profile_id", "")),
+ str(data.get("display_name", "")),
+ str(appearance.get("species", "")),
+ CharacterCustomizationCatalog.character_scale_percent(
+ appearance.get(
+ CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
+ CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
+ )
+ ),
+ str(appearance.get("fur_pattern", "")),
+ str(appearance.get("fur_style", "")),
+ str(appearance.get("fur_color_2", "")),
+ str(appearance.get("fur_color_3", "")),
+ str(appearance.get("fur_color_4", "")),
+ str(appearance.get("ears", "")),
+ str(appearance.get("eyes", "")),
+ str(appearance.get("nose", "")),
+ str(appearance.get("mouth", "")),
+ str(appearance.get("tail", "")),
+ ]
+
+
+static func make_server_hello(
+ accepted: bool,
+ rejection_code: RejectionCode,
+ session_id: String,
+ assigned_peer_id: int,
+ player_count: int,
+ max_players: int,
+ server_display_name: String = "NETfishing",
+) -> Dictionary:
+ return {
+ "accepted": accepted,
+ "rejection_code": int(rejection_code),
+ "protocol_version": PROTOCOL_VERSION,
+ "game_version": game_version(),
+ "session_id": session_id,
+ "assigned_peer_id": assigned_peer_id,
+ "server_display_name": server_display_name,
+ "player_count": player_count,
+ "max_players": max_players,
+ "capability_flags": PackedStringArray([
+ "movement_v1",
+ "fishing_v1",
+ "sale_v1",
+ "shop_v1",
+ ART_SHOP_CAPABILITY,
+ "item_use_v1",
+ "equipment_v1",
+ "fish_showcase_v1",
+ FISH_QUALITY_CAPABILITY,
+ SURFACE_DRAWING_CAPABILITY,
+ WORLD_TIME_CAPABILITY,
+ WORLD_WEATHER_CAPABILITY,
+ JOBS_CAPABILITY,
+ "chat_v1",
+ "mail_v1",
+ "profile_v1",
+ "identity_v1",
+ ]),
+ }
+
+
+static func rejection_text(code: int) -> String:
+ match code:
+ RejectionCode.PROTOCOL_MISMATCH:
+ return "The server uses a different network protocol."
+ RejectionCode.SERVER_FULL:
+ return "The server is full."
+ RejectionCode.DUPLICATE_PROFILE:
+ return "This legacy local profile is already connected."
+ RejectionCode.DUPLICATE_IDENTITY:
+ return "This player identity is already connected."
+ RejectionCode.INVALID_IDENTITY_PROOF:
+ return "Player identity authentication failed."
+ RejectionCode.AUTHENTICATION_TIMEOUT:
+ return "The server did not finish authentication."
+ RejectionCode.SERVER_SHUTTING_DOWN:
+ return "The server is shutting down."
+ RejectionCode.UNSUPPORTED_CLIENT:
+ return "This game build is not supported by the server."
+ RejectionCode.BANNED:
+ return "You are not permitted to join this server."
+ RejectionCode.CLIENT_OUTDATED:
+ return (
+ "Your NETfishing version is out of date. "
+ + "Update the game to join this server."
+ )
+ RejectionCode.SERVER_OUTDATED:
+ return (
+ "This server is out of date. "
+ + "The host needs to update NETfishing."
+ )
+ RejectionCode.VERSION_MISMATCH:
+ return (
+ "This game and server use different NETfishing versions. "
+ + "Update both to the latest release."
+ )
+ _:
+ return "The server rejected the connection."
diff --git a/network/network_protocol.gd.uid b/network/network_protocol.gd.uid
new file mode 100644
index 0000000..aecfaef
--- /dev/null
+++ b/network/network_protocol.gd.uid
@@ -0,0 +1 @@
+uid://dghw7u3pdrpsf
diff --git a/network/network_sale_protocol.gd b/network/network_sale_protocol.gd
new file mode 100644
index 0000000..3b80de6
--- /dev/null
+++ b/network/network_sale_protocol.gd
@@ -0,0 +1,122 @@
+class_name NetworkSaleProtocol
+extends RefCounted
+
+const CAPABILITY: StringName = &"sale_v1"
+const RELIABLE_CHANNEL: int = NetworkProtocol.SALE_RELIABLE_CHANNEL
+const MAX_ID_LENGTH: int = 96
+const MAX_CATCH_ID_LENGTH: int = 160
+const MAX_CATCHES_PER_REQUEST: int = 64
+const MAX_MESSAGE_LENGTH: int = 160
+
+enum Rejection {
+ NONE,
+ MALFORMED,
+ STALE_SESSION,
+ UNAUTHENTICATED,
+ ALREADY_PENDING,
+ BUYER_UNAVAILABLE,
+ TOO_FAR,
+ INVALID_CATCH,
+ FAVORITED,
+ UNSUPPORTED,
+}
+
+
+static func validate_request(data: Variant) -> String:
+ if typeof(data) != TYPE_DICTIONARY:
+ return "Sale could not be completed."
+ var payload: Dictionary = data
+ for key: String in ["request_id", "session_id", "buyer_id", "catches"]:
+ if not payload.has(key):
+ return "Sale could not be completed."
+ if (
+ typeof(payload["request_id"]) != TYPE_STRING
+ or typeof(payload["session_id"]) != TYPE_STRING
+ or typeof(payload["buyer_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or typeof(payload["catches"]) != TYPE_ARRAY
+ ):
+ return "Sale could not be completed."
+ var request_id: String = payload["request_id"]
+ var session_id: String = payload["session_id"]
+ var buyer_id: String = str(payload["buyer_id"])
+ var catches: Array = payload["catches"]
+ if (
+ request_id.is_empty()
+ or request_id.length() > MAX_ID_LENGTH
+ or session_id.is_empty()
+ or session_id.length() > MAX_ID_LENGTH
+ or buyer_id.is_empty()
+ or buyer_id.length() > MAX_ID_LENGTH
+ or catches.is_empty()
+ or catches.size() > MAX_CATCHES_PER_REQUEST
+ ):
+ return "Sale could not be completed."
+ var seen: Dictionary[String, bool] = {}
+ for value: Variant in catches:
+ if typeof(value) != TYPE_DICTIONARY:
+ return "Sale could not be completed."
+ var evidence: Dictionary = value
+ for key: String in [
+ "catch_id", "fish_id", "weight_lb", "display_scale",
+ "quality", "sale_value", "is_favorited",
+ ]:
+ if not evidence.has(key):
+ return "Sale could not be completed."
+ var catch_id: String = str(evidence["catch_id"])
+ if (
+ catch_id.is_empty()
+ or catch_id.length() > MAX_CATCH_ID_LENGTH
+ or seen.has(catch_id)
+ or typeof(evidence["fish_id"]) not in [
+ TYPE_STRING, TYPE_STRING_NAME
+ ]
+ or typeof(evidence["is_favorited"]) != TYPE_BOOL
+ ):
+ return "Sale could not be completed."
+ seen[catch_id] = true
+ return ""
+
+
+static func validate_result(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var payload: Dictionary = data
+ if not (
+ typeof(payload.get("result_id")) == TYPE_STRING
+ and not str(payload["result_id"]).is_empty()
+ and str(payload["result_id"]).length() <= MAX_ID_LENGTH
+ and typeof(payload.get("request_id")) == TYPE_STRING
+ and not str(payload["request_id"]).is_empty()
+ and str(payload["request_id"]).length() <= MAX_ID_LENGTH
+ and typeof(payload.get("session_id")) == TYPE_STRING
+ and typeof(payload.get("target_peer_id")) == TYPE_INT
+ and typeof(payload.get("accepted")) == TYPE_BOOL
+ and typeof(payload.get("catch_ids")) == TYPE_ARRAY
+ and payload["catch_ids"].size() <= MAX_CATCHES_PER_REQUEST
+ and typeof(payload.get("payout")) == TYPE_INT
+ and int(payload["payout"]) >= 0
+ and typeof(payload.get("base_value")) == TYPE_INT
+ and int(payload["base_value"]) >= 0
+ and typeof(payload.get("message")) == TYPE_STRING
+ and str(payload["message"]).length() <= MAX_MESSAGE_LENGTH
+ ):
+ return false
+ if payload.has("buyer_id") and (
+ typeof(payload["buyer_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or str(payload["buyer_id"]).is_empty()
+ or str(payload["buyer_id"]).length() > MAX_ID_LENGTH
+ ):
+ return false
+ var seen: Dictionary[String, bool] = {}
+ for value: Variant in payload["catch_ids"]:
+ if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ return false
+ var catch_id: String = str(value)
+ if (
+ catch_id.is_empty()
+ or catch_id.length() > MAX_CATCH_ID_LENGTH
+ or seen.has(catch_id)
+ ):
+ return false
+ seen[catch_id] = true
+ return true
diff --git a/network/network_sale_protocol.gd.uid b/network/network_sale_protocol.gd.uid
new file mode 100644
index 0000000..58e991a
--- /dev/null
+++ b/network/network_sale_protocol.gd.uid
@@ -0,0 +1 @@
+uid://dsj0noi32pqeu
diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd
new file mode 100644
index 0000000..11a499c
--- /dev/null
+++ b/network/network_sale_service.gd
@@ -0,0 +1,606 @@
+class_name NetworkSaleService
+extends Node
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
+const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
+const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
+
+const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
+const PELICAN_BUYER_ID: StringName = &"pelicans"
+const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
+
+signal local_sale_pending(request_id: String)
+signal local_sale_finished(
+ request_id: String,
+ accepted: bool,
+ message: String,
+ catch_ids: Array[StringName],
+ payout: int,
+)
+
+var _session: NetworkSession
+var _spawn_service: PlayerSpawnService
+var _network_fishing: NetworkFishingService
+var _shop_interaction: FishingShopInteraction
+var _inventory: FishInventory
+var _wallet: PlayerWallet
+var _sale_service: FishSaleServiceType
+var _save_manager: PlayerSaveManager
+var _fish_catalog: FishPoolType
+var _buyers: Dictionary[StringName, FishBuyerProfileType] = {}
+var _request_ledgers: Dictionary[int, Dictionary] = {}
+var _pending_by_peer: Dictionary[int, String] = {}
+var _result_owners: Dictionary[String, int] = {}
+var _acknowledged_results: Dictionary[String, bool] = {}
+var _applied_results: Dictionary[String, bool] = {}
+var _received_results: Dictionary[String, bool] = {}
+var _pending_local_request_id: String = ""
+var _pending_local_catch_ids: Array[StringName] = []
+var _pending_local_buyer_id: StringName
+var _reservations: PlayerAssetReservationService
+
+
+func setup(
+ session: NetworkSession,
+ spawn_service: PlayerSpawnService,
+ network_fishing: NetworkFishingService,
+ shop_interaction: FishingShopInteraction,
+ inventory: FishInventory,
+ wallet: PlayerWallet,
+ sale_service: FishSaleServiceType,
+ save_manager: PlayerSaveManager,
+ fish_catalog: FishPoolType,
+ buyers: Array[FishBuyerProfileType],
+ reservations: PlayerAssetReservationService,
+) -> void:
+ _session = session
+ _spawn_service = spawn_service
+ _network_fishing = network_fishing
+ _shop_interaction = shop_interaction
+ _inventory = inventory
+ _wallet = wallet
+ _sale_service = sale_service
+ _save_manager = save_manager
+ _fish_catalog = fish_catalog
+ _buyers.clear()
+ for buyer: FishBuyerProfileType in buyers:
+ if buyer != null and buyer.is_valid():
+ _buyers[buyer.id] = buyer
+ _reservations = reservations
+ if not _session.peer_removed.is_connected(_on_peer_removed):
+ _session.peer_removed.connect(_on_peer_removed)
+ if not _session.state_changed.is_connected(_on_session_state_changed):
+ _session.state_changed.connect(_on_session_state_changed)
+
+
+func is_local_sale_pending() -> bool:
+ return not _pending_local_request_id.is_empty()
+
+
+func can_request_sale(
+ buyer_id: StringName = PELICAN_BUYER_ID,
+) -> bool:
+ var buyer: FishBuyerProfileType = _buyers.get(buyer_id)
+ return (
+ buyer != null
+ and buyer.is_valid()
+ and _session != null
+ and _session.is_gameplay_session_active()
+ and (
+ _session.is_host()
+ or _session.supports_server_capability(
+ NetworkSaleProtocol.CAPABILITY
+ )
+ )
+ )
+
+
+func request_local_sale(
+ catch_ids: Array[StringName],
+ buyer_id: StringName = PELICAN_BUYER_ID,
+) -> String:
+ for catch_id: StringName in catch_ids:
+ if _reservations != null and _reservations.is_fish_reserved(catch_id):
+ local_sale_finished.emit(
+ "", false, "Reserved in a letter.", _empty_catch_ids(), 0
+ )
+ return ""
+ if is_local_sale_pending():
+ local_sale_finished.emit(
+ "", false, "Selling…", _empty_catch_ids(), 0
+ )
+ return ""
+ if not can_request_sale(buyer_id):
+ local_sale_finished.emit(
+ "",
+ false,
+ "Selling is not supported by this server.",
+ _empty_catch_ids(),
+ 0
+ )
+ return ""
+ if catch_ids.is_empty() or _inventory == null:
+ local_sale_finished.emit(
+ "", false, "Sale could not be completed.", _empty_catch_ids(), 0
+ )
+ return ""
+ var buyer: FishBuyerProfileType = _buyers.get(buyer_id)
+ if buyer == null or not buyer.is_valid():
+ local_sale_finished.emit(
+ "", false, "The buyer is unavailable.", _empty_catch_ids(), 0
+ )
+ return ""
+ var evidence: Array[Dictionary] = []
+ for catch_id: StringName in catch_ids:
+ var fish_catch: FishCatch = _inventory.get_catch_by_id(catch_id)
+ if fish_catch == null:
+ local_sale_finished.emit(
+ "", false, "That catch is no longer available.", _empty_catch_ids(), 0
+ )
+ return ""
+ evidence.append(fish_catch.to_network_dict())
+ var request_id: String = _new_id("sale")
+ var request: Dictionary = {
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "buyer_id": str(buyer.id),
+ "catches": evidence,
+ }
+ _pending_local_request_id = request_id
+ _pending_local_catch_ids = catch_ids.duplicate()
+ _pending_local_buyer_id = buyer.id
+ local_sale_pending.emit(request_id)
+ if _session.is_host():
+ _handle_sale_request(_session.get_local_peer_id(), request)
+ else:
+ submit_sale_request.rpc_id(1, request)
+ return request_id
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ NetworkSaleProtocol.RELIABLE_CHANNEL
+)
+func submit_sale_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ ):
+ return
+ _handle_sale_request(sender_id, data)
+
+
+func _handle_sale_request(peer_id: int, data: Dictionary) -> void:
+ var validation_error: String = NetworkSaleProtocol.validate_request(data)
+ var request_id: String = str(data.get("request_id", ""))
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ if not request_id.is_empty() and ledger.has(request_id):
+ _send_result(peer_id, ledger[request_id])
+ return
+ if not validation_error.is_empty():
+ _record_and_send(peer_id, _rejected_result(
+ request_id, validation_error
+ ))
+ return
+ if str(data["session_id"]) != _session.get_session_id():
+ _record_and_send(peer_id, _rejected_result(
+ request_id, "Sale could not be completed."
+ ))
+ return
+ if (
+ _pending_by_peer.has(peer_id)
+ and _pending_by_peer[peer_id] != request_id
+ ):
+ _record_and_send(peer_id, _rejected_result(
+ request_id, "A sale is already pending."
+ ))
+ return
+ var buyer_id := StringName(str(data["buyer_id"]))
+ var buyer: FishBuyerProfileType = _buyers.get(buyer_id)
+ if buyer == null or not buyer.is_valid():
+ _record_and_send(peer_id, _rejected_result(
+ request_id, "The buyer is unavailable."
+ ))
+ return
+ if (
+ buyer_id == MAIN_SHOP_BUYER_ID
+ and not _is_shop_available_for_peer(peer_id)
+ ):
+ _record_and_send(peer_id, _rejected_result(
+ request_id, "Move closer to the fishing shop."
+ ))
+ return
+ var result: Dictionary = _build_authoritative_result(
+ peer_id, request_id, data["catches"], buyer
+ )
+ _pending_by_peer[peer_id] = request_id
+ _record_and_send(peer_id, result)
+
+
+func _build_authoritative_result(
+ peer_id: int,
+ request_id: String,
+ evidence_values: Array,
+ buyer: FishBuyerProfileType,
+) -> Dictionary:
+ if _fish_catalog == null or buyer == null:
+ return _rejected_result(
+ request_id, "The buyer is unavailable."
+ )
+ var catch_ids: Array[String] = []
+ var base_value: int = 0
+ var payout: int = 0
+ for value: Variant in evidence_values:
+ var evidence: Dictionary = value
+ var fish_id := StringName(str(evidence.get("fish_id", "")))
+ var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id)
+ var decoded: FishCatch = FishCatchType.from_network_dict(
+ evidence, fish
+ )
+ if decoded == null:
+ return _rejected_result(
+ request_id, "Sale could not be completed."
+ )
+ if (
+ not str(decoded.catch_id).begins_with("%s:" % fish_id)
+ or decoded.weight_lb < fish.get_minimum_weight()
+ or decoded.weight_lb > fish.get_maximum_weight()
+ or decoded.sale_value != FishQualityType.apply_sale_value(
+ fish.get_sale_value_for_weight(decoded.weight_lb),
+ decoded.quality,
+ )
+ ):
+ return _rejected_result(
+ request_id, "Sale could not be completed."
+ )
+ if bool(evidence.get("is_favorited", false)):
+ return _rejected_result(
+ request_id, "Favorite catches cannot be sold."
+ )
+ var ordinary_value: int = fish.get_sale_value_for_weight(
+ decoded.weight_lb
+ )
+ var offer: int = buyer.get_quality_offer(
+ ordinary_value,
+ decoded.quality,
+ )
+ if (
+ offer < 0
+ or base_value > 9223372036854775807 - decoded.sale_value
+ or payout > 9223372036854775807 - offer
+ ):
+ return _rejected_result(
+ request_id, "Sale could not be completed."
+ )
+ base_value += decoded.sale_value
+ payout += offer
+ catch_ids.append(str(decoded.catch_id))
+ return {
+ "result_id": _new_id("sale_result"),
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "target_peer_id": peer_id,
+ "buyer_id": str(buyer.id),
+ "accepted": true,
+ "catch_ids": catch_ids,
+ "payout": payout,
+ "base_value": base_value,
+ "message": "Sale complete.",
+ }
+
+
+func _rejected_result(request_id: String, message: String) -> Dictionary:
+ var safe_request_id: String = (
+ request_id
+ if (
+ not request_id.is_empty()
+ and request_id.length() <= NetworkSaleProtocol.MAX_ID_LENGTH
+ )
+ else "invalid"
+ )
+ return {
+ "result_id": _new_id("sale_result"),
+ "request_id": safe_request_id,
+ "session_id": _session.get_session_id() if _session != null else "",
+ "target_peer_id": 0,
+ "accepted": false,
+ "catch_ids": [],
+ "payout": 0,
+ "base_value": 0,
+ "message": message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH),
+ }
+
+
+func _record_and_send(peer_id: int, result: Dictionary) -> void:
+ result["target_peer_id"] = peer_id
+ var request_id: String = str(result["request_id"])
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ ledger[request_id] = result.duplicate(true)
+ while ledger.size() > MAX_LEDGER_ENTRIES_PER_PEER:
+ ledger.erase(ledger.keys().front())
+ _request_ledgers[peer_id] = ledger
+ _result_owners[str(result["result_id"])] = peer_id
+ _bound_dictionary(_result_owners)
+ _send_result(peer_id, result)
+
+
+func _send_result(peer_id: int, result: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _apply_sale_result(result)
+ else:
+ receive_sale_result.rpc_id(peer_id, result)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ NetworkSaleProtocol.RELIABLE_CHANNEL
+)
+func receive_sale_result(data: Dictionary) -> void:
+ _apply_sale_result(data)
+
+
+func _apply_sale_result(data: Dictionary) -> void:
+ if (
+ not NetworkSaleProtocol.validate_result(data)
+ or _session == null
+ or str(data["session_id"]) != _session.get_session_id()
+ or int(data["target_peer_id"]) != _session.get_local_peer_id()
+ ):
+ return
+ var result_id: String = data["result_id"]
+ if _received_results.has(result_id):
+ _acknowledge_result(
+ data, _applied_results.has(result_id), ""
+ )
+ return
+ if str(data["request_id"]) != _pending_local_request_id:
+ return
+ var result_buyer_id := StringName(str(
+ data.get("buyer_id", _pending_local_buyer_id)
+ ))
+ if result_buyer_id != _pending_local_buyer_id:
+ _fail_local_apply(data, "Sale could not be completed.")
+ return
+ if not bool(data["accepted"]):
+ _received_results[result_id] = true
+ _bound_dictionary(_received_results)
+ _finish_local_sale(
+ data["request_id"], false, str(data["message"]), [], 0
+ )
+ _acknowledge_result(data, false, str(data["message"]))
+ return
+ var catch_ids: Array[StringName] = []
+ for value: Variant in data["catch_ids"]:
+ if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ _fail_local_apply(data, "Sale could not be completed.")
+ return
+ catch_ids.append(StringName(str(value)))
+ if catch_ids != _pending_local_catch_ids:
+ _fail_local_apply(data, "Sale could not be completed.")
+ return
+ for catch_id: StringName in catch_ids:
+ if _reservations != null and _reservations.is_fish_reserved(catch_id):
+ _fail_local_apply(data, "Reserved in a letter.")
+ return
+ var buyer: FishBuyerProfileType = _buyers.get(_pending_local_buyer_id)
+ if buyer == null or not buyer.is_valid():
+ _fail_local_apply(data, "The buyer is unavailable.")
+ return
+ var preview: FishSaleResultType = _sale_service.preview_batch(
+ catch_ids, buyer
+ )
+ if not preview.is_success():
+ var message: String = (
+ "Favorite catches cannot be sold."
+ if preview.status == FishSaleResultType.Status.FAVORITED
+ else "That catch is no longer available."
+ )
+ _fail_local_apply(data, message)
+ return
+ if (
+ preview.payout != int(data["payout"])
+ or preview.base_value != int(data["base_value"])
+ ):
+ _fail_local_apply(data, "Sale could not be completed.")
+ return
+ var inventory_snapshot: Array[FishCatch] = _inventory.get_all_catches()
+ var sequence_snapshot: int = _inventory.get_next_catch_sequence()
+ var wallet_snapshot: int = _wallet.get_balance()
+ var local_result: FishSaleResultType = _sale_service.sell_batch(
+ catch_ids, buyer
+ )
+ if not local_result.is_success() or not _save_manager.save_if_dirty():
+ _inventory.replace_all_catches(
+ inventory_snapshot, sequence_snapshot
+ )
+ _wallet.restore_balance(wallet_snapshot)
+ _save_manager.save_if_dirty()
+ _fail_local_apply(data, "Sale could not be completed.")
+ return
+ _applied_results[result_id] = true
+ _bound_dictionary(_applied_results)
+ _received_results[result_id] = true
+ _bound_dictionary(_received_results)
+ _finish_local_sale(
+ data["request_id"],
+ true,
+ "Sale complete.",
+ catch_ids,
+ int(data["payout"])
+ )
+ _acknowledge_result(data, true, "")
+
+
+func _fail_local_apply(data: Dictionary, message: String) -> void:
+ _finish_local_sale(
+ data["request_id"], false, message, _empty_catch_ids(), 0
+ )
+ _acknowledge_result(data, false, message)
+
+
+func _finish_local_sale(
+ request_id: String,
+ accepted: bool,
+ message: String,
+ catch_ids: Array[StringName],
+ payout: int,
+) -> void:
+ _pending_local_request_id = ""
+ _pending_local_catch_ids.clear()
+ _pending_local_buyer_id = StringName()
+ local_sale_finished.emit(
+ request_id, accepted, message, catch_ids, payout
+ )
+
+
+func _acknowledge_result(
+ data: Dictionary,
+ applied: bool,
+ message: String,
+) -> void:
+ if _session.is_host():
+ _handle_acknowledgement(
+ _session.get_local_peer_id(),
+ str(data["result_id"]),
+ str(data["request_id"]),
+ applied,
+ message
+ )
+ else:
+ acknowledge_sale_result.rpc_id(
+ 1,
+ str(data["result_id"]),
+ str(data["request_id"]),
+ applied,
+ message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH)
+ )
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ NetworkSaleProtocol.RELIABLE_CHANNEL
+)
+func acknowledge_sale_result(
+ result_id: String,
+ request_id: String,
+ applied: bool,
+ message: String,
+) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ ):
+ return
+ _handle_acknowledgement(
+ sender_id, result_id, request_id, applied, message
+ )
+
+
+func _handle_acknowledgement(
+ peer_id: int,
+ result_id: String,
+ request_id: String,
+ _applied: bool,
+ _message: String,
+) -> void:
+ if (
+ result_id.is_empty()
+ or result_id.length() > NetworkSaleProtocol.MAX_ID_LENGTH
+ or request_id.is_empty()
+ or request_id.length() > NetworkSaleProtocol.MAX_ID_LENGTH
+ or _message.length() > NetworkSaleProtocol.MAX_MESSAGE_LENGTH
+ or _result_owners.get(result_id, 0) != peer_id
+ ):
+ return
+ _acknowledged_results[result_id] = true
+ _bound_dictionary(_acknowledged_results)
+ if _pending_by_peer.get(peer_id, "") == request_id:
+ _pending_by_peer.erase(peer_id)
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ _request_ledgers.erase(peer_id)
+ _pending_by_peer.erase(peer_id)
+ for result_id: String in _result_owners.keys():
+ if _result_owners[result_id] == peer_id:
+ _result_owners.erase(result_id)
+ _acknowledged_results.erase(result_id)
+
+
+func _is_shop_available_for_peer(peer_id: int) -> bool:
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_gameplay_session_active()
+ or _spawn_service == null
+ or _shop_interaction == null
+ or not is_instance_valid(_shop_interaction)
+ ):
+ return false
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ return (
+ avatar != null
+ and not avatar.is_water_recovery_active()
+ and (
+ _network_fishing == null
+ or not _network_fishing.has_peer_attempt(peer_id)
+ )
+ and _shop_interaction.is_avatar_in_range(avatar)
+ )
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ var had_pending: bool = is_local_sale_pending()
+ _clear_session_state()
+ if had_pending:
+ local_sale_finished.emit(
+ "", false, "Connection lost.", _empty_catch_ids(), 0
+ )
+
+
+func _empty_catch_ids() -> Array[StringName]:
+ return []
+
+
+func _clear_session_state() -> void:
+ _request_ledgers.clear()
+ _pending_by_peer.clear()
+ _result_owners.clear()
+ _acknowledged_results.clear()
+ _applied_results.clear()
+ _received_results.clear()
+ _pending_local_request_id = ""
+ _pending_local_catch_ids.clear()
+ _pending_local_buyer_id = StringName()
+
+
+func _bound_dictionary(values: Dictionary) -> void:
+ while values.size() > MAX_LEDGER_ENTRIES_PER_PEER:
+ values.erase(values.keys().front())
+
+
+func _new_id(prefix: String) -> String:
+ return "%s:%s" % [
+ prefix,
+ Crypto.new().generate_random_bytes(16).hex_encode(),
+ ]
diff --git a/network/network_sale_service.gd.uid b/network/network_sale_service.gd.uid
new file mode 100644
index 0000000..daa398a
--- /dev/null
+++ b/network/network_sale_service.gd.uid
@@ -0,0 +1 @@
+uid://sqqy3anotq6n
diff --git a/network/network_session.gd b/network/network_session.gd
new file mode 100644
index 0000000..076acc5
--- /dev/null
+++ b/network/network_session.gd
@@ -0,0 +1,2072 @@
+class_name NetworkSession
+extends Node
+
+const DEFAULT_PORT: int = 7777
+const DEFAULT_PRIVATE_HOST_PORT_ATTEMPTS: int = 16
+const DEFAULT_SESSION_MAX_PLAYERS: int = 8
+const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
+const CONNECTION_TIMEOUT_SECONDS: float = 10.0
+const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
+const INPUT_INTERVAL: float = 1.0 / 30.0
+const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
+const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
+
+signal state_changed(state: State)
+signal status_message_changed(message: String)
+signal connection_error(message: String)
+signal peer_authenticated(peer_id: int, display_name: String)
+signal peer_removed(peer_id: int)
+signal host_openness_changed(is_open: bool)
+signal session_display_name_changed(display_name: String)
+signal peer_count_changed(player_count: int, max_players: int)
+signal peer_display_name_changed(peer_id: int, display_name: String)
+signal peer_profile_changed(
+ peer_id: int,
+ display_name: String,
+ appearance: Dictionary,
+)
+signal join_authenticated
+signal server_trust_required(
+ endpoint: String,
+ expected_fingerprint: String,
+ received_fingerprint: String,
+ is_changed: bool,
+)
+signal peer_identity_observed(peer_id: int, status: String)
+signal operator_status_changed(peer_id: int, is_operator: bool)
+signal server_lost(message: String)
+signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
+signal remote_recovery_presentation_changed(
+ peer_id: int,
+ active: bool,
+ attempt_id: String,
+)
+
+enum State {
+ INACTIVE,
+ STARTING_PRIVATE_HOST,
+ PRIVATE_HOST,
+ OPEN_HOST,
+ CONNECTING,
+ AUTHENTICATING,
+ VERIFYING_SERVER_IDENTITY,
+ JOINED_CLIENT,
+ DISCONNECTING,
+ CONNECTION_FAILED,
+ SERVER_LOST,
+}
+
+@export_range(2, 128, 1) var session_max_players: int = (
+ DEFAULT_SESSION_MAX_PLAYERS
+)
+@export_range(1, 256, 1) var transport_max_clients: int = (
+ DEFAULT_TRANSPORT_MAX_CLIENTS
+)
+
+var state: State = State.INACTIVE
+var _transport: DirectEnetTransport
+var _profile: NetworkProfilePreferences
+var _saved_servers: SavedServerStore
+var _spawn_service: PlayerSpawnService
+var _registry := PeerRegistry.new()
+var _pending_authentication: Dictionary[int, float] = {}
+var _session_id: String = ""
+var _operation_generation: int = 0
+var _connection_deadline: float = 0.0
+var _client_nonce: String = ""
+var _current_route: ConnectionRoute
+var _input_sequence: int = 0
+var _input_accumulator: float = 0.0
+var _snapshot_accumulator: float = 0.0
+var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
+var _last_server_player_count: int = 0
+var _last_server_display_name: String = ""
+var _last_server_protocol_version: int = 0
+var _server_capabilities: PackedStringArray = PackedStringArray()
+var _profile_ready: bool = false
+var _player_identity: PlayerIdentityStore
+var _host_identity: HostIdentityStore
+var _known_players: KnownPlayerStore
+var _server_trust: ServerTrustStore
+var _host_bans: HostBanStore
+var _pending_identity_challenges: Dictionary[int, Dictionary] = {}
+var _authenticated_identity_cache: Dictionary[int, Dictionary] = {}
+var _client_identity_attempt: Dictionary = {}
+var _pending_server_proof: Dictionary = {}
+var _recovery_attempts: Dictionary[int, String] = {}
+var _server_identity_fingerprint: String = ""
+var _server_identity_public_key: String = ""
+var _session_identity_keys: Dictionary[String, String] = {}
+var _local_appearance_snapshot: Dictionary = (
+ CharacterCustomizationCatalog.default_snapshot()
+)
+var _moderation_disconnect_message := ""
+var _host_port: int = 0
+var _session_display_name: String = "NETfishing Room"
+var _dedicated_host: bool = false
+var _configured_operator_fingerprints: Dictionary[String, bool] = {}
+var _session_operator_fingerprints: Dictionary[String, bool] = {}
+var _operator_peer_ids: Dictionary[int, bool] = {}
+var _local_operator: bool = false
+
+
+func _ready() -> void:
+ transport_max_clients = maxi(
+ transport_max_clients,
+ session_max_players - 1
+ )
+ _connect_multiplayer_signals()
+
+
+func setup(
+ profile: NetworkProfilePreferences,
+ saved_servers: SavedServerStore,
+ spawn_service: PlayerSpawnService,
+ player_identity: PlayerIdentityStore,
+ host_identity: HostIdentityStore,
+ known_players: KnownPlayerStore,
+ server_trust: ServerTrustStore,
+ host_bans: HostBanStore,
+ dedicated: bool = false,
+) -> void:
+ _profile = profile
+ _saved_servers = saved_servers
+ _spawn_service = spawn_service
+ _player_identity = player_identity
+ _host_identity = host_identity
+ _known_players = known_players
+ _server_trust = server_trust
+ _host_bans = host_bans
+ if not dedicated and _profile != null:
+ _profile_ready = _profile.load_or_create()
+ if not dedicated and _player_identity != null:
+ _profile_ready = _profile_ready and _player_identity.load_or_create()
+
+
+func configure_dedicated_operators(
+ fingerprints: PackedStringArray,
+) -> bool:
+ if state != State.INACTIVE:
+ return false
+ var configured: Dictionary[String, bool] = {}
+ for fingerprint: String in fingerprints:
+ if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
+ return false
+ configured[fingerprint] = true
+ _configured_operator_fingerprints = configured
+ return true
+
+
+func start_private_host(
+ port: int = DEFAULT_PORT,
+ port_attempts: int = 1,
+) -> bool:
+ if (
+ state != State.INACTIVE
+ or not _profile_ready
+ or _profile == null
+ or _spawn_service == null
+ or _host_identity == null
+ ):
+ return false
+ return _start_host(port, port_attempts, false)
+
+
+func start_dedicated_host(
+ port: int = DEFAULT_PORT,
+ max_players: int = DEFAULT_SESSION_MAX_PLAYERS,
+ bind_address: String = "*",
+) -> bool:
+ if (
+ state != State.INACTIVE
+ or _spawn_service == null
+ or _host_identity == null
+ or max_players < 1
+ or max_players > 128
+ or bind_address.is_empty()
+ ):
+ return false
+ session_max_players = max_players
+ transport_max_clients = maxi(transport_max_clients, max_players)
+ return _start_host(port, 1, true, bind_address)
+
+
+func _start_host(
+ port: int,
+ port_attempts: int,
+ dedicated: bool,
+ bind_address: String = "*",
+) -> bool:
+ if not _host_identity.load_or_create():
+ _fail(_host_identity.error_message)
+ return false
+ if port < 1 or port > 65535 or port_attempts < 1:
+ _fail("The hosting port must be from 1 to 65535.")
+ return false
+ _operation_generation += 1
+ _set_state(
+ State.STARTING_PRIVATE_HOST,
+ "Starting dedicated server..." if dedicated else "Starting private game...",
+ )
+ var selected_port: int = 0
+ var final_port: int = mini(port + port_attempts - 1, 65535)
+ for candidate_port: int in range(port, final_port + 1):
+ if not _can_bind_udp_port(candidate_port, bind_address):
+ continue
+ _replace_transport()
+ var error: Error = _transport.start_host(
+ candidate_port,
+ transport_max_clients,
+ bind_address,
+ )
+ if error == OK:
+ selected_port = candidate_port
+ break
+ if selected_port == 0:
+ _fail(
+ "Could not start a private game on UDP ports %d–%d."
+ % [port, final_port]
+ )
+ return false
+ _host_port = selected_port
+ _dedicated_host = dedicated
+ var peer: MultiplayerPeer = _transport.get_multiplayer_peer()
+ peer.refuse_new_connections = true
+ multiplayer.multiplayer_peer = peer
+ _session_id = Crypto.new().generate_random_bytes(16).hex_encode()
+ _registry.clear()
+ _session_operator_fingerprints.clear()
+ _operator_peer_ids.clear()
+ _local_operator = false
+ _spawn_service.clear_remote_players()
+ if not dedicated:
+ _register_player_host()
+ _set_state(
+ State.PRIVATE_HOST,
+ (
+ "Dedicated server • UDP %d"
+ if dedicated else "Private game • UDP %d"
+ ) % selected_port,
+ )
+ host_openness_changed.emit(false)
+ _emit_peer_count()
+ return true
+
+
+func _register_player_host() -> void:
+ _registry.add_peer(
+ 1,
+ _profile.profile_id,
+ _profile.display_name,
+ NetworkProtocol.PROTOCOL_VERSION,
+ _player_identity.fingerprint,
+ _player_identity.public_pem,
+ PackedStringArray([
+ NetworkProtocol.FISH_QUALITY_CAPABILITY,
+ NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
+ NetworkProtocol.WORLD_TIME_CAPABILITY,
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY,
+ NetworkProtocol.JOBS_CAPABILITY,
+ ]),
+ )
+ _registry.update_appearance(1, _local_appearance_snapshot)
+ var host_profile_hello := NetworkProtocol.make_client_hello(
+ _profile.profile_id,
+ _profile.display_name,
+ NetworkIdentityCrypto.secure_id(32),
+ _local_appearance_snapshot,
+ _player_identity.fingerprint,
+ )
+ host_profile_hello["identity_signature"] = _player_identity.sign(
+ "handshake_client_profile",
+ NetworkProtocol.client_profile_fields(host_profile_hello),
+ )
+ var host_record := _registry.get_peer(1)
+ if host_record != null:
+ host_record.profile_authorization = {
+ "domain": "handshake_client_profile",
+ "signature": host_profile_hello["identity_signature"],
+ "client_nonce": host_profile_hello["client_nonce"],
+ }
+ _archive_authenticated_identity(host_record)
+ _spawn_service.register_local_player(1)
+ var host_avatar := _spawn_service.get_avatar(1)
+ if host_avatar != null:
+ host_avatar.apply_appearance_snapshot(_local_appearance_snapshot)
+
+
+func get_host_port() -> int:
+ return _host_port if is_host() else 0
+
+
+func send_traversal_packet(
+ destination_address: String,
+ destination_port: int,
+ packet: PackedByteArray,
+) -> bool:
+ return (
+ _transport != null
+ and state in [
+ State.PRIVATE_HOST,
+ State.OPEN_HOST,
+ State.CONNECTING,
+ State.AUTHENTICATING,
+ ]
+ and _transport.send_raw_packet(
+ destination_address,
+ destination_port,
+ packet,
+ )
+ )
+
+
+func is_dedicated_host() -> bool:
+ return is_host() and _dedicated_host
+
+
+func is_local_operator() -> bool:
+ return state == State.JOINED_CLIENT and _local_operator
+
+
+func can_local_moderate() -> bool:
+ return is_host() or is_local_operator()
+
+
+func can_manage_operators() -> bool:
+ return is_host() and not _dedicated_host
+
+
+func is_peer_operator(peer_id: int) -> bool:
+ return bool(_operator_peer_ids.get(peer_id, false))
+
+
+func set_peer_operator(
+ peer_id: int,
+ fingerprint: String,
+ enabled: bool,
+) -> bool:
+ if not can_manage_operators() or peer_id <= 1:
+ return false
+ var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
+ if (
+ record == null
+ or not record.identity_authenticated
+ or record.identity_fingerprint != fingerprint
+ ):
+ return false
+ if enabled:
+ _session_operator_fingerprints[fingerprint] = true
+ else:
+ _session_operator_fingerprints.erase(fingerprint)
+ _set_operator_status(peer_id, enabled)
+ return true
+
+
+func set_session_display_name(value: String) -> void:
+ var cleaned: String = value.strip_edges().left(48)
+ if cleaned.is_empty():
+ cleaned = "NETfishing Room"
+ if cleaned == _session_display_name:
+ return
+ _session_display_name = cleaned
+ session_display_name_changed.emit(_session_display_name)
+
+
+func get_session_display_name() -> String:
+ return _session_display_name
+
+
+func get_local_display_name() -> String:
+ return _profile.display_name if _profile != null else ""
+
+
+static func _can_bind_udp_port(port: int, bind_address: String = "*") -> bool:
+ var probe := PacketPeerUDP.new()
+ var error: Error = probe.bind(port, bind_address)
+ probe.close()
+ return error == OK
+
+
+func join_direct(endpoint_text: String) -> bool:
+ if (
+ state != State.INACTIVE
+ or not _profile_ready
+ or _profile == null
+ or _spawn_service == null
+ ):
+ return false
+ var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
+ if not endpoint.is_valid():
+ _fail(endpoint.error_message)
+ return false
+ _operation_generation += 1
+ var generation: int = _operation_generation
+ _current_route = ConnectionRoute.direct(endpoint)
+ _set_state(
+ State.CONNECTING,
+ "Connecting to %s..." % endpoint.normalized_display
+ )
+ _replace_transport()
+ var error: Error = _transport.connect_to_route(_current_route)
+ if error != OK:
+ _fail("Could not begin the direct connection.")
+ return false
+ multiplayer.multiplayer_peer = _transport.get_multiplayer_peer()
+ _connection_deadline = (
+ Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
+ )
+ # The generation is checked by the process timeout and all state-gated
+ # multiplayer callbacks.
+ if generation != _operation_generation:
+ return false
+ return true
+
+
+func cancel_connection() -> void:
+ if state not in [
+ State.CONNECTING,
+ State.AUTHENTICATING,
+ State.VERIFYING_SERVER_IDENTITY,
+ ]:
+ return
+ _operation_generation += 1
+ _teardown_peer()
+ _set_state(State.INACTIVE, "Connection cancelled.")
+
+
+func reset_failure() -> void:
+ if state not in [State.CONNECTION_FAILED, State.SERVER_LOST]:
+ return
+ _teardown_peer()
+ _set_state(State.INACTIVE, "Ready for a direct connection.")
+
+
+func disconnect_session(message: String = "Disconnected.") -> void:
+ if state == State.INACTIVE:
+ return
+ if state in [State.CONNECTION_FAILED, State.SERVER_LOST]:
+ _teardown_peer()
+ _set_state(State.INACTIVE, message)
+ return
+ _operation_generation += 1
+ _set_state(State.DISCONNECTING, "Disconnecting...")
+ _teardown_peer()
+ _set_state(State.INACTIVE, message)
+
+
+func set_host_open(is_open: bool) -> bool:
+ if state not in [State.PRIVATE_HOST, State.OPEN_HOST]:
+ return false
+ var peer: MultiplayerPeer = multiplayer.multiplayer_peer
+ if peer == null:
+ return false
+ peer.refuse_new_connections = not is_open
+ _set_state(
+ State.OPEN_HOST if is_open else State.PRIVATE_HOST,
+ (
+ "Dedicated server • UDP %d • %d / %d players"
+ if _dedicated_host
+ else "Open game • UDP %d • %d / %d players"
+ if is_open
+ else "Private game • UDP %d • %d / %d players"
+ ) % [_host_port, _registry.size(), session_max_players]
+ )
+ host_openness_changed.emit(is_open)
+ return true
+
+
+func is_host() -> bool:
+ return state in [State.PRIVATE_HOST, State.OPEN_HOST]
+
+
+func is_open_host() -> bool:
+ return state == State.OPEN_HOST
+
+
+func is_joined_client() -> bool:
+ return state == State.JOINED_CLIENT
+
+
+func is_session_active() -> bool:
+ return is_host() or is_joined_client()
+
+
+func get_player_count() -> int:
+ return _registry.size()
+
+
+func get_session_max_players() -> int:
+ return _last_server_max_players if is_joined_client() else session_max_players
+
+
+func get_current_route_display() -> String:
+ return (
+ _current_route.display_description
+ if _current_route != null
+ else _transport.get_route_description() if _transport != null else ""
+ )
+
+
+func get_current_endpoint() -> ConnectionEndpoint:
+ return (
+ _current_route.direct_endpoint
+ if _current_route != null
+ and _current_route.kind == ConnectionRoute.Kind.DIRECT
+ else null
+ )
+
+
+func get_last_server_metadata() -> Dictionary:
+ return {
+ "server_display_name": _last_server_display_name,
+ "protocol_version": _last_server_protocol_version,
+ "player_count": _last_server_player_count,
+ "max_players": _last_server_max_players,
+ }
+
+
+func can_use_host_gameplay() -> bool:
+ return is_host()
+
+
+func is_gameplay_session_active() -> bool:
+ return state in [State.PRIVATE_HOST, State.OPEN_HOST, State.JOINED_CLIENT]
+
+
+func is_authenticated_peer(peer_id: int) -> bool:
+ return _registry.has_peer(peer_id)
+
+
+func get_session_id() -> String:
+ return _session_id
+
+
+func get_operation_generation() -> int:
+ return _operation_generation
+
+
+func supports_server_capability(capability: StringName) -> bool:
+ if is_host():
+ return str(capability) in PackedStringArray([
+ "movement_v1", "fishing_v1", "sale_v1", "shop_v1",
+ NetworkProtocol.ART_SHOP_CAPABILITY,
+ "item_use_v1", "equipment_v1", "fish_showcase_v1",
+ NetworkProtocol.FISH_QUALITY_CAPABILITY,
+ NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
+ NetworkProtocol.WORLD_TIME_CAPABILITY,
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY,
+ "chat_v1",
+ "mail_v1",
+ "profile_v1",
+ "identity_v1",
+ ])
+ return str(capability) in _server_capabilities
+
+
+func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord:
+ return _registry.get_peer(peer_id)
+
+
+func peer_supports_capability(
+ peer_id: int,
+ capability: StringName,
+) -> bool:
+ var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
+ return record != null and str(capability) in record.capability_flags
+
+
+func get_authenticated_peer_ids() -> Array[int]:
+ return _registry.get_peer_ids()
+
+
+func get_peer_rtt_ms(peer_id: int) -> int:
+ if peer_id == 1:
+ return 0
+ var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
+ if (
+ enet == null
+ or enet.get_connection_status()
+ != MultiplayerPeer.CONNECTION_CONNECTED
+ ):
+ return -1
+ var measurable := is_host() or (
+ is_joined_client() and peer_id == get_local_peer_id()
+ )
+ if not measurable:
+ return -1
+ if is_joined_client() and not _registry.has_peer(1):
+ return -1
+ var packet_peer: ENetPacketPeer = enet.get_peer(peer_id if is_host() else 1)
+ if packet_peer == null:
+ return -1
+ return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
+
+
+func kick_authenticated_peer(
+ peer_id: int,
+ fingerprint: String,
+ banned: bool = false,
+) -> bool:
+ if not is_host() or peer_id <= 1:
+ return false
+ var record := _registry.get_peer(peer_id)
+ if record == null or record.identity_fingerprint != fingerprint:
+ return false
+ receive_moderation_disconnect.rpc_id(
+ peer_id,
+ (
+ "You are not permitted to join this server."
+ if banned else "You were removed by the host."
+ ),
+ )
+ call_deferred("_disconnect_rejected_peer", peer_id)
+ return true
+
+
+func get_local_identity_fingerprint() -> String:
+ return _player_identity.fingerprint if _player_identity != null else ""
+
+
+func sign_local_action(domain: String, fields: Array) -> PackedByteArray:
+ return (
+ _player_identity.sign(domain, fields)
+ if _player_identity != null else PackedByteArray()
+ )
+
+
+func verify_peer_action(
+ peer_id: int,
+ domain: String,
+ fields: Array,
+ signature: PackedByteArray,
+) -> bool:
+ var record := _registry.get_peer(peer_id)
+ if record == null or not record.identity_authenticated:
+ return false
+ return NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(record.identity_public_key),
+ domain,
+ fields,
+ signature,
+ )
+
+
+func matches_authenticated_session_identity(
+ fingerprint: String, public_pem: String
+) -> bool:
+ var normalized := NetworkIdentityCrypto.normalize_public_pem(public_pem)
+ return (
+ NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+ and NetworkIdentityCrypto.fingerprint_public_pem(normalized)
+ == fingerprint
+ and _session_identity_keys.get(fingerprint, "") == normalized
+ )
+
+
+func sign_host_action(domain: String, fields: Array) -> PackedByteArray:
+ return (
+ _host_identity.sign(domain, fields)
+ if is_host() and _host_identity != null else PackedByteArray()
+ )
+
+
+func get_host_identity_fingerprint() -> String:
+ return (
+ _host_identity.fingerprint
+ if is_host() and _host_identity != null
+ else _server_identity_fingerprint
+ )
+
+
+func verify_host_action(
+ domain: String, fields: Array, signature: PackedByteArray
+) -> bool:
+ var public_pem: String = (
+ _host_identity.public_pem
+ if is_host() and _host_identity != null
+ else _server_identity_public_key
+ )
+ return NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(public_pem),
+ domain,
+ fields,
+ signature,
+ )
+
+
+func update_local_display_name(value: String) -> bool:
+ if _profile == null or not _profile.set_display_name(value):
+ return false
+ var peer_id := get_local_peer_id()
+ if is_host():
+ _apply_display_name(peer_id, _profile.display_name)
+ receive_display_name.rpc(peer_id, _profile.display_name)
+ elif is_joined_client() and supports_server_capability(&"chat_v1"):
+ submit_display_name.rpc_id(1, _profile.display_name)
+ return true
+
+
+func set_local_appearance_snapshot(snapshot: Dictionary) -> void:
+ if CharacterCustomizationCatalog.validate_snapshot(snapshot):
+ _local_appearance_snapshot = snapshot.duplicate(true)
+ var local_id := get_local_peer_id()
+ if local_id > 0:
+ _registry.update_appearance(local_id, _local_appearance_snapshot)
+
+
+func apply_canonical_profile(
+ peer_id: int,
+ display_name: String,
+ appearance: Dictionary,
+) -> bool:
+ if (
+ not NetworkProfilePreferences.is_valid_display_name(display_name)
+ or not CharacterCustomizationCatalog.validate_snapshot(appearance)
+ ):
+ return false
+ var name_changed := _registry.update_display_name(peer_id, display_name)
+ var appearance_changed := _registry.update_appearance(peer_id, appearance)
+ if name_changed:
+ peer_display_name_changed.emit(peer_id, display_name)
+ if appearance_changed:
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar != null:
+ avatar.apply_appearance_snapshot(appearance)
+ if name_changed or appearance_changed:
+ peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
+ return name_changed or appearance_changed
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_display_name(value: String) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ if (
+ is_host()
+ and is_authenticated_peer(sender_id)
+ and NetworkProfilePreferences.is_valid_display_name(value)
+ ):
+ _apply_display_name(sender_id, value.strip_edges())
+ receive_display_name.rpc(sender_id, value.strip_edges())
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_display_name(peer_id: int, value: String) -> void:
+ if NetworkProfilePreferences.is_valid_display_name(value):
+ _apply_display_name(peer_id, value.strip_edges())
+
+
+func _apply_display_name(peer_id: int, value: String) -> void:
+ if _registry.update_display_name(peer_id, value):
+ peer_display_name_changed.emit(peer_id, value)
+
+
+func get_local_peer_id() -> int:
+ if is_dedicated_host():
+ return 0
+ return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
+
+
+func _process(delta: float) -> void:
+ var now: float = Time.get_ticks_msec() / 1000.0
+ if (
+ state in [
+ State.CONNECTING,
+ State.AUTHENTICATING,
+ State.VERIFYING_SERVER_IDENTITY,
+ ]
+ and _connection_deadline > 0.0
+ and now >= _connection_deadline
+ ):
+ _fail("Connection timed out.")
+ _teardown_peer()
+ return
+ if is_host():
+ _expire_pending_authentication(now)
+ if state not in [State.OPEN_HOST, State.PRIVATE_HOST, State.JOINED_CLIENT]:
+ return
+ _input_accumulator += delta
+ _snapshot_accumulator += delta
+ if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL:
+ _input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL)
+ _send_local_input()
+ if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL:
+ _snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL)
+ _broadcast_movement_snapshots()
+
+
+func _connect_multiplayer_signals() -> void:
+ if not multiplayer.peer_connected.is_connected(_on_peer_connected):
+ multiplayer.peer_connected.connect(_on_peer_connected)
+ if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected):
+ multiplayer.peer_disconnected.connect(_on_peer_disconnected)
+ if not multiplayer.connected_to_server.is_connected(
+ _on_connected_to_server
+ ):
+ multiplayer.connected_to_server.connect(_on_connected_to_server)
+ if not multiplayer.connection_failed.is_connected(_on_connection_failed):
+ multiplayer.connection_failed.connect(_on_connection_failed)
+ if not multiplayer.server_disconnected.is_connected(
+ _on_server_disconnected
+ ):
+ multiplayer.server_disconnected.connect(_on_server_disconnected)
+
+
+func _replace_transport() -> void:
+ if _transport != null:
+ _transport.disconnect_transport()
+ _transport.queue_free()
+ _transport = DirectEnetTransport.new()
+ add_child(_transport)
+ _transport.transport_error.connect(connection_error.emit)
+
+
+func _on_peer_connected(peer_id: int) -> void:
+ if not is_host():
+ return
+ if state != State.OPEN_HOST:
+ multiplayer.multiplayer_peer.disconnect_peer(peer_id)
+ return
+ _pending_authentication[peer_id] = (
+ Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS
+ )
+
+
+func _on_connected_to_server() -> void:
+ if state != State.CONNECTING:
+ return
+ _set_state(State.AUTHENTICATING, "Authenticating...")
+ _client_nonce = NetworkIdentityCrypto.secure_id(32)
+ _client_identity_attempt = NetworkProtocol.make_identity_hello(
+ _player_identity.public_pem,
+ _player_identity.fingerprint,
+ _client_nonce,
+ NetworkIdentityCrypto.secure_id(16),
+ )
+ submit_identity_hello.rpc_id(1, _client_identity_attempt)
+
+
+func _on_connection_failed() -> void:
+ if state not in [
+ State.CONNECTING,
+ State.AUTHENTICATING,
+ State.VERIFYING_SERVER_IDENTITY,
+ ]:
+ return
+ _teardown_peer()
+ _fail("Could not connect. The server may be private, unavailable, or unreachable.")
+
+
+func _on_server_disconnected() -> void:
+ if state in [State.INACTIVE, State.DISCONNECTING]:
+ return
+ _operation_generation += 1
+ _teardown_peer()
+ if state in [
+ State.CONNECTING,
+ State.AUTHENTICATING,
+ State.VERIFYING_SERVER_IDENTITY,
+ ]:
+ _fail("The server rejected or ended authentication.")
+ return
+ var message := (
+ _moderation_disconnect_message
+ if not _moderation_disconnect_message.is_empty()
+ else "The server connection was lost."
+ )
+ _moderation_disconnect_message = ""
+ _set_state(State.SERVER_LOST, message)
+ server_lost.emit(message)
+ connection_error.emit(message)
+
+
+func _on_peer_disconnected(peer_id: int) -> void:
+ _pending_authentication.erase(peer_id)
+ _pending_identity_challenges.erase(peer_id)
+ _authenticated_identity_cache.erase(peer_id)
+ _operator_peer_ids.erase(peer_id)
+ var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
+ if not recovery_attempt.is_empty():
+ _recovery_attempts.erase(peer_id)
+ remote_recovery_presentation_changed.emit(
+ peer_id, false, recovery_attempt
+ )
+ if _registry.has_peer(peer_id):
+ _registry.remove_peer(peer_id)
+ _spawn_service.remove_peer(peer_id)
+ if is_host():
+ receive_peer_despawn.rpc(peer_id)
+ peer_removed.emit(peer_id)
+ _emit_peer_count()
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_identity_hello(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
+ return
+ var client_game_version: String = str(data.get("game_version", ""))
+ var version_rejection: NetworkProtocol.RejectionCode = (
+ NetworkProtocol.game_version_rejection(
+ client_game_version,
+ NetworkProtocol.game_version(),
+ )
+ )
+ if version_rejection != NetworkProtocol.RejectionCode.NONE:
+ _reject_peer(sender_id, version_rejection)
+ return
+ if (
+ not NetworkProtocol.validate_identity_hello(data)
+ or int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION
+ ):
+ _reject_peer(
+ sender_id,
+ NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
+ )
+ return
+ var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["public_key"])
+ var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
+ if (
+ fingerprint != str(data["fingerprint"])
+ or NetworkIdentityCrypto.load_public_key(public_pem) == null
+ ):
+ _reject_peer(
+ sender_id,
+ NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF
+ )
+ return
+ if _registry.has_fingerprint(fingerprint):
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.DUPLICATE_IDENTITY)
+ return
+ var server_nonce := NetworkIdentityCrypto.secure_id(32)
+ var challenge := {
+ "peer_id": sender_id,
+ "attempt_id": str(data["attempt_id"]),
+ "client_nonce": str(data["client_nonce"]),
+ "client_fingerprint": fingerprint,
+ "client_public_key": public_pem,
+ "client_game_version": client_game_version,
+ "server_nonce": server_nonce,
+ "server_fingerprint": _host_identity.fingerprint,
+ "server_game_version": NetworkProtocol.game_version(),
+ "session_id": _session_id,
+ "generation": _operation_generation,
+ "expires_at_msec": Time.get_ticks_msec() + 60000,
+ }
+ var fields := _identity_proof_fields(challenge)
+ var proof := challenge.duplicate(true)
+ proof["server_public_key"] = _host_identity.public_pem
+ proof["server_signature"] = _host_identity.sign(
+ "handshake_server_proof", fields
+ )
+ _pending_identity_challenges[sender_id] = challenge
+ receive_server_identity_proof.rpc_id(sender_id, proof)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_server_identity_proof(data: Dictionary) -> void:
+ if state != State.AUTHENTICATING:
+ return
+ var server_game_version: String = str(data.get("server_game_version", ""))
+ if server_game_version.is_empty():
+ _fail_identity(NetworkProtocol.rejection_text(
+ NetworkProtocol.RejectionCode.SERVER_OUTDATED
+ ))
+ return
+ var version_rejection: NetworkProtocol.RejectionCode = (
+ NetworkProtocol.game_version_rejection(
+ NetworkProtocol.game_version(),
+ server_game_version,
+ )
+ )
+ if version_rejection != NetworkProtocol.RejectionCode.NONE:
+ _fail_identity(NetworkProtocol.rejection_text(version_rejection))
+ return
+ if not _valid_server_proof_shape(data):
+ _fail_identity("The server sent an invalid identity proof.")
+ return
+ if (
+ str(data["attempt_id"]) != str(_client_identity_attempt.get("attempt_id", ""))
+ or str(data["client_nonce"]) != _client_nonce
+ ):
+ _fail_identity("The server identity proof did not match this connection.")
+ return
+ var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["server_public_key"])
+ var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
+ var public_key := NetworkIdentityCrypto.load_public_key(public_pem)
+ if (
+ fingerprint != str(data["server_fingerprint"])
+ or not NetworkIdentityCrypto.verify_fields(
+ public_key,
+ "handshake_server_proof",
+ _identity_proof_fields(data),
+ data["server_signature"],
+ )
+ ):
+ _fail_identity("The server identity proof was invalid.")
+ return
+ _pending_server_proof = data.duplicate(true)
+ _server_identity_fingerprint = fingerprint
+ _server_identity_public_key = public_pem
+ var verification := _server_trust.verify(
+ get_current_endpoint(), fingerprint
+ )
+ if verification == ServerTrustStore.Verification.MATCH:
+ _server_trust.touch(get_current_endpoint())
+ _send_client_identity_proof()
+ return
+ _set_state(
+ State.VERIFYING_SERVER_IDENTITY,
+ "Confirm this server identity before continuing.",
+ )
+ _connection_deadline = 0.0
+ var expected := str(
+ _server_trust.get_record(get_current_endpoint()).get("fingerprint", "")
+ )
+ server_trust_required.emit(
+ get_current_route_display(),
+ expected,
+ fingerprint,
+ verification == ServerTrustStore.Verification.CHANGED,
+ )
+
+
+func resolve_server_trust(accepted: bool) -> void:
+ if state != State.VERIFYING_SERVER_IDENTITY:
+ return
+ if not accepted:
+ cancel_connection()
+ return
+ if not _server_trust.trust(
+ get_current_endpoint(),
+ str(_pending_server_proof["server_fingerprint"]),
+ "NETfishing",
+ ):
+ _fail_identity("The server identity could not be pinned.")
+ return
+ _set_state(State.AUTHENTICATING, "Authenticating identity...")
+ _connection_deadline = (
+ Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
+ )
+ _send_client_identity_proof()
+
+
+func _send_client_identity_proof() -> void:
+ var proof := {
+ "attempt_id": _pending_server_proof["attempt_id"],
+ "session_id": _pending_server_proof["session_id"],
+ "client_fingerprint": _player_identity.fingerprint,
+ "client_signature": _player_identity.sign(
+ "handshake_client_proof",
+ _identity_proof_fields(_pending_server_proof),
+ ),
+ }
+ submit_client_identity_proof.rpc_id(1, proof)
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_client_identity_proof(data: Dictionary) -> void:
+ var sender_id := multiplayer.get_remote_sender_id()
+ var challenge: Dictionary = _pending_identity_challenges.get(sender_id, {})
+ if (
+ not is_host()
+ or challenge.is_empty()
+ or Time.get_ticks_msec() > int(challenge["expires_at_msec"])
+ or data.get("attempt_id") != challenge["attempt_id"]
+ or data.get("session_id") != _session_id
+ or data.get("client_fingerprint") != challenge["client_fingerprint"]
+ or typeof(data.get("client_signature")) != TYPE_PACKED_BYTE_ARRAY
+ ):
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
+ return
+ var verified := NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(challenge["client_public_key"]),
+ "handshake_client_proof",
+ _identity_proof_fields(challenge),
+ data["client_signature"],
+ )
+ _pending_identity_challenges.erase(sender_id)
+ if not verified:
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
+ return
+ _authenticated_identity_cache[sender_id] = {
+ "fingerprint": challenge["client_fingerprint"],
+ "public_key": challenge["client_public_key"],
+ "game_version": challenge["client_game_version"],
+ }
+ if (
+ _host_bans != null
+ and _host_bans.is_banned(
+ _host_identity.fingerprint,
+ str(challenge["client_fingerprint"]),
+ )
+ ):
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.BANNED)
+ return
+ request_client_profile.rpc_id(sender_id)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_moderation_disconnect(message: String) -> void:
+ _moderation_disconnect_message = message.left(120)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func request_client_profile() -> void:
+ if state != State.AUTHENTICATING:
+ return
+ var hello := NetworkProtocol.make_client_hello(
+ _profile.profile_id,
+ _profile.display_name,
+ _client_nonce,
+ _local_appearance_snapshot,
+ _player_identity.fingerprint,
+ )
+ hello["identity_signature"] = _player_identity.sign(
+ "handshake_client_profile",
+ NetworkProtocol.client_profile_fields(hello),
+ )
+ submit_client_hello.rpc_id(1, hello)
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_client_hello(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
+ return
+ var validation_error: String = NetworkProtocol.validate_client_hello(data)
+ if not validation_error.is_empty():
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE)
+ return
+ var client_game_version: String = str(data.get("game_version", ""))
+ var version_rejection: NetworkProtocol.RejectionCode = (
+ NetworkProtocol.game_version_rejection(
+ client_game_version,
+ NetworkProtocol.game_version(),
+ )
+ )
+ if version_rejection != NetworkProtocol.RejectionCode.NONE:
+ _reject_peer(sender_id, version_rejection)
+ return
+ var client_capabilities: PackedStringArray = _sanitized_capabilities(
+ data.get("capability_flags", [])
+ )
+ if NetworkProtocol.FISH_QUALITY_CAPABILITY not in client_capabilities:
+ _reject_peer(
+ sender_id,
+ NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
+ )
+ return
+ var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
+ if identity.is_empty():
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
+ return
+ if (
+ data["identity_fingerprint"] != identity["fingerprint"]
+ or client_game_version != str(identity.get("game_version", ""))
+ or not NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(identity["public_key"]),
+ "handshake_client_profile",
+ NetworkProtocol.client_profile_fields(data),
+ data["identity_signature"],
+ )
+ ):
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
+ return
+ if _registry.size() >= session_max_players:
+ _reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL)
+ return
+ var profile_id: String = data["local_profile_id"]
+ var display_name: String = data["display_name"]
+ if not NetworkProfilePreferences.is_valid_display_name(display_name):
+ _reject_peer(
+ sender_id,
+ NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
+ )
+ return
+ if not _registry.add_peer(
+ sender_id,
+ profile_id,
+ display_name,
+ NetworkProtocol.PROTOCOL_VERSION,
+ identity["fingerprint"],
+ identity["public_key"],
+ client_capabilities,
+ ):
+ _reject_peer(
+ sender_id,
+ NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
+ )
+ return
+ var operator_enabled: bool = _operator_for_fingerprint(
+ str(identity["fingerprint"])
+ )
+ if operator_enabled:
+ _operator_peer_ids[sender_id] = true
+ var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
+ data["cosmetic_snapshot"]
+ )
+ _registry.update_appearance(sender_id, submitted_appearance)
+ var peer_record := _registry.get_peer(sender_id)
+ if peer_record != null:
+ peer_record.profile_authorization = {
+ "domain": "handshake_client_profile",
+ "signature": data["identity_signature"],
+ "client_nonce": data["client_nonce"],
+ }
+ _archive_authenticated_identity(peer_record)
+ var identity_status := _known_players.observe(
+ str(identity["fingerprint"]), display_name
+ )
+ peer_identity_observed.emit(sender_id, identity_status)
+ _pending_authentication.erase(sender_id)
+ _authenticated_identity_cache.erase(sender_id)
+ var spawn_index: int = _registry.get_peer_ids().find(sender_id)
+ var spawn_transform: Transform3D = (
+ _spawn_service.get_spawn_transform_for_index(spawn_index)
+ )
+ _spawn_service.spawn_remote_player(sender_id, spawn_transform, true)
+ var spawned_avatar: Player = _spawn_service.get_avatar(sender_id)
+ if spawned_avatar != null:
+ spawned_avatar.apply_appearance_snapshot(submitted_appearance)
+ peer_profile_changed.emit(
+ sender_id,
+ display_name,
+ submitted_appearance.duplicate(true),
+ )
+ receive_server_hello.rpc_id(
+ sender_id,
+ NetworkProtocol.make_server_hello(
+ true,
+ NetworkProtocol.RejectionCode.NONE,
+ _session_id,
+ sender_id,
+ _registry.size(),
+ session_max_players,
+ _session_display_name,
+ )
+ )
+ receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
+ receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot())
+ receive_peer_spawn.rpc(
+ _make_spawn_entry(sender_id, display_name, spawn_transform)
+ )
+ if operator_enabled:
+ receive_operator_status.rpc(sender_id, true)
+ peer_authenticated.emit(sender_id, display_name)
+ _emit_peer_count()
+
+
+func _reject_peer(
+ peer_id: int,
+ code: NetworkProtocol.RejectionCode,
+) -> void:
+ receive_server_hello.rpc_id(
+ peer_id,
+ NetworkProtocol.make_server_hello(
+ false,
+ code,
+ _session_id,
+ peer_id,
+ _registry.size(),
+ session_max_players
+ )
+ )
+ _pending_authentication.erase(peer_id)
+ call_deferred("_disconnect_rejected_peer", peer_id)
+
+
+func _disconnect_rejected_peer(peer_id: int) -> void:
+ await get_tree().create_timer(0.15).timeout
+ if (
+ is_host()
+ and multiplayer.multiplayer_peer != null
+ and multiplayer.multiplayer_peer.get_connection_status()
+ == MultiplayerPeer.CONNECTION_CONNECTED
+ and peer_id in multiplayer.get_peers()
+ ):
+ multiplayer.multiplayer_peer.disconnect_peer(peer_id)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_server_hello(data: Dictionary) -> void:
+ if state != State.AUTHENTICATING:
+ return
+ if (
+ typeof(data.get("accepted")) != TYPE_BOOL
+ or typeof(data.get("protocol_version")) != TYPE_INT
+ or typeof(data.get("game_version")) != TYPE_STRING
+ or typeof(data.get("rejection_code")) != TYPE_INT
+ ):
+ _teardown_peer()
+ _fail("The server sent an invalid handshake response.")
+ return
+ if not bool(data["accepted"]):
+ var message: String = NetworkProtocol.rejection_text(
+ int(data["rejection_code"])
+ )
+ _teardown_peer()
+ _fail(message)
+ return
+ var version_rejection: NetworkProtocol.RejectionCode = (
+ NetworkProtocol.game_version_rejection(
+ NetworkProtocol.game_version(),
+ str(data["game_version"]),
+ )
+ )
+ if version_rejection != NetworkProtocol.RejectionCode.NONE:
+ _teardown_peer()
+ _fail(NetworkProtocol.rejection_text(version_rejection))
+ return
+ if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION:
+ _teardown_peer()
+ _fail("The server uses a different network protocol.")
+ return
+ _session_id = str(data.get("session_id", ""))
+ _last_server_max_players = int(data.get(
+ "max_players",
+ DEFAULT_SESSION_MAX_PLAYERS
+ ))
+ _last_server_player_count = int(data.get("player_count", 1))
+ _last_server_display_name = str(data.get("server_display_name", ""))
+ _last_server_protocol_version = int(data.get(
+ "protocol_version", NetworkProtocol.PROTOCOL_VERSION
+ ))
+ _server_capabilities = PackedStringArray()
+ var advertised_capabilities: Variant = data.get(
+ "capability_flags", PackedStringArray()
+ )
+ if typeof(advertised_capabilities) in [
+ TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
+ ]:
+ for value: Variant in advertised_capabilities:
+ if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
+ _server_capabilities.append(str(value))
+ if NetworkProtocol.FISH_QUALITY_CAPABILITY not in _server_capabilities:
+ _teardown_peer()
+ _fail("This server does not support fish quality data.")
+ return
+ var local_peer_id: int = multiplayer.get_unique_id()
+ _registry.clear()
+ _registry.add_peer(
+ local_peer_id,
+ _profile.profile_id,
+ _profile.display_name,
+ NetworkProtocol.PROTOCOL_VERSION,
+ _player_identity.fingerprint,
+ _player_identity.public_pem,
+ PackedStringArray([
+ NetworkProtocol.FISH_QUALITY_CAPABILITY,
+ NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
+ NetworkProtocol.WORLD_TIME_CAPABILITY,
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY,
+ ]),
+ )
+ _registry.update_appearance(local_peer_id, _local_appearance_snapshot)
+ var local_record := _registry.get_peer(local_peer_id)
+ if local_record != null:
+ _archive_authenticated_identity(local_record)
+ _spawn_service.clear_remote_players()
+ _spawn_service.register_local_player(local_peer_id)
+ var local_avatar := _spawn_service.get_avatar(local_peer_id)
+ if local_avatar != null:
+ local_avatar.apply_appearance_snapshot(_local_appearance_snapshot)
+ _connection_deadline = 0.0
+ _set_state(
+ State.JOINED_CLIENT,
+ "Connected • %d / %d players" % [
+ int(data.get("player_count", 1)),
+ _last_server_max_players,
+ ]
+ )
+ join_authenticated.emit()
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_spawn_list(entries: Array) -> void:
+ if state != State.JOINED_CLIENT:
+ return
+ for value: Variant in entries:
+ if typeof(value) == TYPE_DICTIONARY:
+ _apply_spawn_entry(value)
+ _emit_peer_count()
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_peer_spawn(entry: Dictionary) -> void:
+ if state != State.JOINED_CLIENT:
+ return
+ _apply_spawn_entry(entry)
+ _emit_peer_count()
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_peer_despawn(peer_id: int) -> void:
+ if state != State.JOINED_CLIENT:
+ return
+ _operator_peer_ids.erase(peer_id)
+ _registry.remove_peer(peer_id)
+ _spawn_service.remove_peer(peer_id)
+ peer_removed.emit(peer_id)
+ _emit_peer_count()
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_operator_snapshot(peer_ids: PackedInt32Array) -> void:
+ if (
+ state != State.JOINED_CLIENT
+ or peer_ids.size() > get_session_max_players()
+ ):
+ return
+ _operator_peer_ids.clear()
+ for peer_id: int in peer_ids:
+ if peer_id > 1:
+ _operator_peer_ids[peer_id] = true
+ _update_local_operator()
+ for peer_id: int in _operator_peer_ids:
+ operator_status_changed.emit(peer_id, true)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_operator_status(peer_id: int, enabled: bool) -> void:
+ if state != State.JOINED_CLIENT or peer_id <= 1:
+ return
+ if enabled:
+ _operator_peer_ids[peer_id] = true
+ else:
+ _operator_peer_ids.erase(peer_id)
+ _update_local_operator()
+ operator_status_changed.emit(peer_id, enabled)
+
+
+func _apply_spawn_entry(entry: Dictionary) -> void:
+ if (
+ typeof(entry.get("peer_id")) != TYPE_INT
+ or typeof(entry.get("profile_id")) != TYPE_STRING
+ or typeof(entry.get("display_name")) != TYPE_STRING
+ or typeof(entry.get("position")) != TYPE_ARRAY
+ or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
+ ):
+ return
+ if not _verify_spawn_identity(entry):
+ return
+ var peer_id: int = entry["peer_id"]
+ if peer_id == multiplayer.get_unique_id():
+ var own_position: Array = entry["position"]
+ if own_position.size() == 3:
+ var own_avatar: Player = _spawn_service.get_avatar(peer_id)
+ if own_avatar != null:
+ var own_snapshot: Dictionary = own_avatar.make_network_snapshot(
+ peer_id
+ )
+ own_snapshot["position"] = own_position
+ own_snapshot["visual_yaw"] = float(entry["yaw"])
+ own_avatar.apply_network_teleport(own_snapshot)
+ return
+ if not _registry.has_peer(peer_id):
+ _registry.add_peer(
+ peer_id,
+ entry["profile_id"],
+ entry["display_name"],
+ NetworkProtocol.PROTOCOL_VERSION,
+ str(entry.get("identity_fingerprint", "")),
+ str(entry.get("identity_public_key", "")),
+ _sanitized_capabilities(entry.get("capability_flags", [])),
+ )
+ var added_record := _registry.get_peer(peer_id)
+ if added_record != null and added_record.identity_authenticated:
+ _archive_authenticated_identity(added_record)
+ var status := _known_players.observe(
+ added_record.identity_fingerprint,
+ added_record.display_name,
+ )
+ peer_identity_observed.emit(peer_id, status)
+ if typeof(entry.get("appearance")) == TYPE_DICTIONARY:
+ var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
+ entry["appearance"]
+ )
+ _registry.update_appearance(peer_id, appearance)
+ var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0)
+ var spawn_position: Array = entry["position"]
+ if spawn_position.size() != 3:
+ return
+ transform.origin = Vector3(
+ float(spawn_position[0]),
+ float(spawn_position[1]),
+ float(spawn_position[2])
+ )
+ transform.basis = Basis(Vector3.UP, float(entry["yaw"]))
+ var avatar := _spawn_service.spawn_remote_player(peer_id, transform, false)
+ var record := _registry.get_peer(peer_id)
+ if avatar != null and record != null:
+ avatar.apply_appearance_snapshot(record.appearance_snapshot)
+
+
+func _build_spawn_list() -> Array[Dictionary]:
+ var entries: Array[Dictionary] = []
+ for peer_id: int in _registry.get_peer_ids():
+ var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if record != null and avatar != null:
+ entries.append(_make_spawn_entry(
+ peer_id,
+ record.display_name,
+ avatar.global_transform
+ ))
+ return entries
+
+
+func _make_spawn_entry(
+ peer_id: int,
+ display_name: String,
+ transform: Transform3D,
+) -> Dictionary:
+ var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
+ return {
+ "peer_id": peer_id,
+ "profile_id": record.profile_id if record != null else "",
+ "display_name": display_name,
+ "position": [
+ transform.origin.x,
+ transform.origin.y,
+ transform.origin.z,
+ ],
+ "yaw": transform.basis.get_euler().y,
+ "appearance": (
+ record.appearance_snapshot.duplicate(true)
+ if record != null
+ else CharacterCustomizationCatalog.default_snapshot()
+ ),
+ "identity_fingerprint": (
+ record.identity_fingerprint if record != null else ""
+ ),
+ "identity_public_key": (
+ record.identity_public_key if record != null else ""
+ ),
+ "profile_authorization": (
+ record.profile_authorization.duplicate(true)
+ if record != null else {}
+ ),
+ "capability_flags": (
+ record.capability_flags.duplicate()
+ if record != null else PackedStringArray()
+ ),
+ }
+
+
+func _sanitized_capabilities(value: Variant) -> PackedStringArray:
+ var result := PackedStringArray()
+ if typeof(value) not in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]:
+ return result
+ for capability: Variant in value:
+ if (
+ typeof(capability) in [TYPE_STRING, TYPE_STRING_NAME]
+ and not str(capability).is_empty()
+ and str(capability).length() <= 64
+ and str(capability) not in result
+ ):
+ result.append(str(capability))
+ return result
+
+
+func _operator_for_fingerprint(fingerprint: String) -> bool:
+ return bool((
+ _configured_operator_fingerprints
+ if _dedicated_host
+ else _session_operator_fingerprints
+ ).get(fingerprint, false))
+
+
+func _operator_peer_id_snapshot() -> PackedInt32Array:
+ var result: PackedInt32Array = PackedInt32Array()
+ for peer_id: int in _operator_peer_ids:
+ result.append(peer_id)
+ result.sort()
+ return result
+
+
+func _set_operator_status(peer_id: int, enabled: bool) -> void:
+ if enabled:
+ _operator_peer_ids[peer_id] = true
+ else:
+ _operator_peer_ids.erase(peer_id)
+ operator_status_changed.emit(peer_id, enabled)
+ receive_operator_status.rpc(peer_id, enabled)
+
+
+func _update_local_operator() -> void:
+ var local_peer_id: int = multiplayer.get_unique_id()
+ _local_operator = bool(_operator_peer_ids.get(local_peer_id, false))
+
+
+func _verify_spawn_identity(entry: Dictionary) -> bool:
+ var fingerprint := str(entry.get("identity_fingerprint", ""))
+ var public_pem := NetworkIdentityCrypto.normalize_public_pem(
+ str(entry.get("identity_public_key", ""))
+ )
+ if (
+ NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
+ or typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY
+ ):
+ return false
+ var authorization: Dictionary = entry["profile_authorization"]
+ if authorization.is_empty():
+ return int(entry.get("peer_id", 0)) == 1
+ if authorization.get("domain") == "handshake_client_profile":
+ var hello := NetworkProtocol.make_client_hello(
+ str(entry.get("profile_id", "")),
+ str(entry.get("display_name", "")),
+ str(authorization.get("client_nonce", "")),
+ Dictionary(entry.get("appearance", {})),
+ fingerprint,
+ authorization.get("signature", PackedByteArray()),
+ )
+ return NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(public_pem),
+ "handshake_client_profile",
+ NetworkProtocol.client_profile_fields(hello),
+ hello["identity_signature"],
+ )
+ if authorization.has("sender_signature"):
+ var signed := authorization.duplicate(true)
+ signed["display_name"] = entry.get("display_name", "")
+ signed["appearance"] = entry.get("appearance", {})
+ return NetworkIdentityCrypto.verify_fields(
+ NetworkIdentityCrypto.load_public_key(public_pem),
+ "profile_update",
+ NetworkProfileProtocol.signature_fields(signed),
+ signed["sender_signature"],
+ )
+ return false
+
+
+func _identity_proof_fields(data: Dictionary) -> Array:
+ return [
+ NetworkProtocol.PROTOCOL_VERSION,
+ str(data.get("client_game_version", "")),
+ str(data.get("server_game_version", "")),
+ str(data.get("attempt_id", "")),
+ str(data.get("client_fingerprint", "")),
+ str(data.get("client_nonce", "")),
+ str(data.get("server_fingerprint", "")),
+ str(data.get("server_nonce", "")),
+ str(data.get("session_id", "")),
+ int(data.get("peer_id", 0)),
+ int(data.get("generation", 0)),
+ int(data.get("expires_at_msec", 0)),
+ ]
+
+
+func _archive_authenticated_identity(record: PeerRegistry.PeerRecord) -> void:
+ if (
+ record != null
+ and record.identity_authenticated
+ and NetworkIdentityCrypto.fingerprint_public_pem(
+ record.identity_public_key
+ ) == record.identity_fingerprint
+ ):
+ _session_identity_keys[record.identity_fingerprint] = (
+ NetworkIdentityCrypto.normalize_public_pem(
+ record.identity_public_key
+ )
+ )
+
+
+func _valid_server_proof_shape(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ typeof(value.get("attempt_id")) == TYPE_STRING
+ and str(value["attempt_id"]).length() == 32
+ and typeof(value.get("client_nonce")) == TYPE_STRING
+ and str(value["client_nonce"]).length() == 64
+ and typeof(value.get("server_nonce")) == TYPE_STRING
+ and str(value["server_nonce"]).length() == 64
+ and NetworkIdentityCrypto.valid_fingerprint(value.get("client_fingerprint"))
+ and NetworkIdentityCrypto.valid_fingerprint(value.get("server_fingerprint"))
+ and typeof(value.get("client_game_version")) == TYPE_STRING
+ and str(value["client_game_version"]) == NetworkProtocol.game_version()
+ and typeof(value.get("server_game_version")) == TYPE_STRING
+ and str(value["server_game_version"]) == NetworkProtocol.game_version()
+ and typeof(value.get("server_public_key")) == TYPE_STRING
+ and str(value["server_public_key"]).to_utf8_buffer().size()
+ <= NetworkProtocol.MAX_PUBLIC_KEY_LENGTH
+ and typeof(value.get("server_signature")) == TYPE_PACKED_BYTE_ARRAY
+ and PackedByteArray(value["server_signature"]).size()
+ <= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
+ and typeof(value.get("session_id")) == TYPE_STRING
+ and typeof(value.get("peer_id")) == TYPE_INT
+ and typeof(value.get("generation")) == TYPE_INT
+ and typeof(value.get("expires_at_msec")) == TYPE_INT
+ )
+
+
+func _fail_identity(message: String) -> void:
+ _teardown_peer()
+ _fail(message)
+
+
+func _send_local_input() -> void:
+ var peer_id: int = multiplayer.get_unique_id()
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ return
+ _input_sequence += 1
+ var input: Dictionary = avatar.capture_network_input(_input_sequence)
+ submit_movement_input.rpc_id(1, input)
+
+
+func submit_neutral_local_movement() -> void:
+ if state != State.JOINED_CLIENT:
+ return
+ _send_local_input()
+
+
+@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
+func submit_movement_input(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if not is_host() or not _registry.has_peer(sender_id):
+ return
+ var avatar: Player = _spawn_service.get_avatar(sender_id)
+ if avatar == null or not _is_valid_movement_input(data):
+ return
+ avatar.apply_authoritative_network_input(data)
+
+
+func _is_valid_movement_input(data: Dictionary) -> bool:
+ if (
+ typeof(data.get("sequence")) != TYPE_INT
+ or typeof(data.get("axis")) != TYPE_ARRAY
+ or typeof(data.get("jump")) != TYPE_BOOL
+ or typeof(data.get("sprint")) != TYPE_BOOL
+ or typeof(data.get("sneak")) != TYPE_BOOL
+ or typeof(data.get("slow_walk")) != TYPE_BOOL
+ or typeof(data.get("sitting")) != TYPE_BOOL
+ or typeof(data.get("casting")) != TYPE_BOOL
+ or not NetworkPlayerAnimationProtocol.validate_action_state(
+ data.get("animation_action")
+ )
+ or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT]
+ ):
+ return false
+ var axis: Array = data["axis"]
+ if axis.size() != 2:
+ return false
+ if (
+ typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT]
+ or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT]
+ ):
+ return false
+ var x: float = float(axis[0])
+ var y: float = float(axis[1])
+ var camera_yaw: float = float(data["camera_yaw"])
+ return (
+ int(data["sequence"]) > 0
+ and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE
+ and is_finite(x)
+ and is_finite(y)
+ and is_finite(camera_yaw)
+ and absf(x) <= 1.01
+ and absf(y) <= 1.01
+ and absf(camera_yaw) <= TAU * 100.0
+ )
+
+
+func _broadcast_movement_snapshots() -> void:
+ var snapshots: Array[Dictionary] = []
+ for peer_id: int in _registry.get_peer_ids():
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ continue
+ snapshots.append(avatar.make_network_snapshot(peer_id))
+ receive_movement_snapshots.rpc(snapshots)
+
+
+@rpc("authority", "call_remote", "unreliable_ordered", 2)
+func receive_movement_snapshots(snapshots: Array) -> void:
+ if state != State.JOINED_CLIENT:
+ return
+ var local_peer_id: int = multiplayer.get_unique_id()
+ for value: Variant in snapshots:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var snapshot: Dictionary = value
+ if typeof(snapshot.get("peer_id")) != TYPE_INT:
+ continue
+ var peer_id: int = snapshot["peer_id"]
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ continue
+ if peer_id == local_peer_id:
+ avatar.apply_local_prediction_correction(snapshot)
+ else:
+ avatar.push_network_snapshot(snapshot)
+
+
+func publish_authoritative_teleport(peer_id: int) -> void:
+ if not is_host():
+ return
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ return
+ receive_authoritative_teleport.rpc(
+ avatar.make_network_snapshot(peer_id)
+ )
+
+
+func request_recovery_presentation(
+ active: bool,
+ attempt_id: String,
+) -> void:
+ if (
+ attempt_id.is_empty()
+ or attempt_id.length() > 96
+ or not is_gameplay_session_active()
+ ):
+ return
+ if is_host():
+ _set_authoritative_recovery_presentation(
+ get_local_peer_id(), active, attempt_id
+ )
+ elif state == State.JOINED_CLIENT:
+ submit_recovery_presentation.rpc_id(
+ 1, _session_id, active, attempt_id
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_recovery_presentation(
+ session_id: String,
+ active: bool,
+ attempt_id: String,
+) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ not is_host()
+ or not _registry.has_peer(sender_id)
+ or session_id != _session_id
+ or attempt_id.is_empty()
+ or attempt_id.length() > 96
+ ):
+ return
+ _set_authoritative_recovery_presentation(sender_id, active, attempt_id)
+
+
+func _set_authoritative_recovery_presentation(
+ peer_id: int,
+ active: bool,
+ attempt_id: String,
+) -> void:
+ var current_attempt: String = _recovery_attempts.get(peer_id, "")
+ if active:
+ if not current_attempt.is_empty() and current_attempt != attempt_id:
+ return
+ elif current_attempt != attempt_id:
+ return
+ var data := {
+ "session_id": _session_id,
+ "generation": _operation_generation,
+ "peer_id": peer_id,
+ "active": active,
+ "attempt_id": attempt_id,
+ }
+ _apply_recovery_presentation(data)
+ receive_recovery_presentation.rpc(data)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_recovery_presentation(data: Dictionary) -> void:
+ _apply_recovery_presentation(data)
+
+
+func _apply_recovery_presentation(data: Dictionary) -> void:
+ if (
+ typeof(data.get("session_id")) != TYPE_STRING
+ or str(data["session_id"]) != _session_id
+ or typeof(data.get("generation")) != TYPE_INT
+ or int(data["generation"]) < 1
+ or typeof(data.get("peer_id")) != TYPE_INT
+ or typeof(data.get("active")) != TYPE_BOOL
+ or typeof(data.get("attempt_id")) != TYPE_STRING
+ or str(data["attempt_id"]).is_empty()
+ or str(data["attempt_id"]).length() > 96
+ ):
+ return
+ var peer_id: int = data["peer_id"]
+ var attempt_id: String = data["attempt_id"]
+ var active: bool = data["active"]
+ var current_attempt: String = _recovery_attempts.get(peer_id, "")
+ if active:
+ if not current_attempt.is_empty() and current_attempt != attempt_id:
+ return
+ _recovery_attempts[peer_id] = attempt_id
+ elif current_attempt != attempt_id:
+ return
+ else:
+ _recovery_attempts.erase(peer_id)
+ remote_recovery_presentation_changed.emit(peer_id, active, attempt_id)
+
+
+func request_safe_respawn(entry_position: Vector3) -> void:
+ if not entry_position.is_finite():
+ return
+ if is_host():
+ remote_recovery_requested.emit(1, entry_position)
+ elif state == State.JOINED_CLIENT:
+ submit_safe_respawn_request.rpc_id(
+ 1,
+ [entry_position.x, entry_position.y, entry_position.z]
+ )
+
+
+@rpc("any_peer", "call_remote", "reliable", 0)
+func submit_safe_respawn_request(position_data: Array) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ not is_host()
+ or not _registry.has_peer(sender_id)
+ or position_data.size() != 3
+ ):
+ return
+ var entry_position := Vector3(
+ float(position_data[0]),
+ float(position_data[1]),
+ float(position_data[2])
+ )
+ if not entry_position.is_finite():
+ return
+ remote_recovery_requested.emit(sender_id, entry_position)
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_authoritative_teleport(snapshot: Dictionary) -> void:
+ if state != State.JOINED_CLIENT:
+ return
+ var peer_id: int = int(snapshot.get("peer_id", 0))
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if avatar != null:
+ avatar.apply_network_teleport(snapshot)
+
+
+func _expire_pending_authentication(now: float) -> void:
+ for peer_id: int in _pending_authentication.keys():
+ if now >= float(_pending_authentication[peer_id]):
+ _reject_peer(
+ peer_id,
+ NetworkProtocol.RejectionCode.AUTHENTICATION_TIMEOUT
+ )
+
+
+func _emit_peer_count() -> void:
+ peer_count_changed.emit(_registry.size(), session_max_players)
+
+
+func _set_state(new_state: State, message: String) -> void:
+ if not _is_transition_allowed(state, new_state):
+ push_warning(
+ "Rejected invalid network state transition %s -> %s."
+ % [State.keys()[state], State.keys()[new_state]]
+ )
+ return
+ state = new_state
+ state_changed.emit(state)
+ status_message_changed.emit(message)
+
+
+func _is_transition_allowed(from_state: State, to_state: State) -> bool:
+ if from_state == to_state:
+ return true
+ var allowed: Dictionary[State, Array] = {
+ State.INACTIVE: [
+ State.STARTING_PRIVATE_HOST,
+ State.CONNECTING,
+ State.CONNECTION_FAILED,
+ ],
+ State.STARTING_PRIVATE_HOST: [
+ State.PRIVATE_HOST,
+ State.CONNECTION_FAILED,
+ State.DISCONNECTING,
+ ],
+ State.PRIVATE_HOST: [
+ State.OPEN_HOST,
+ State.DISCONNECTING,
+ ],
+ State.OPEN_HOST: [
+ State.PRIVATE_HOST,
+ State.DISCONNECTING,
+ ],
+ State.CONNECTING: [
+ State.AUTHENTICATING,
+ State.CONNECTION_FAILED,
+ State.DISCONNECTING,
+ State.INACTIVE,
+ ],
+ State.AUTHENTICATING: [
+ State.VERIFYING_SERVER_IDENTITY,
+ State.JOINED_CLIENT,
+ State.CONNECTION_FAILED,
+ State.DISCONNECTING,
+ State.INACTIVE,
+ ],
+ State.VERIFYING_SERVER_IDENTITY: [
+ State.AUTHENTICATING,
+ State.CONNECTION_FAILED,
+ State.DISCONNECTING,
+ State.INACTIVE,
+ ],
+ State.JOINED_CLIENT: [
+ State.DISCONNECTING,
+ State.SERVER_LOST,
+ ],
+ State.DISCONNECTING: [
+ State.INACTIVE,
+ State.CONNECTING,
+ ],
+ State.CONNECTION_FAILED: [
+ State.INACTIVE,
+ State.CONNECTING,
+ State.DISCONNECTING,
+ ],
+ State.SERVER_LOST: [
+ State.INACTIVE,
+ State.CONNECTING,
+ State.DISCONNECTING,
+ ],
+ }
+ return to_state in allowed.get(from_state, [])
+
+
+func _fail(message: String) -> void:
+ _set_state(State.CONNECTION_FAILED, message)
+ connection_error.emit(message)
+
+
+func _teardown_peer() -> void:
+ for peer_id: int in _recovery_attempts.keys():
+ remote_recovery_presentation_changed.emit(
+ peer_id, false, _recovery_attempts[peer_id]
+ )
+ _recovery_attempts.clear()
+ _pending_authentication.clear()
+ _pending_identity_challenges.clear()
+ _authenticated_identity_cache.clear()
+ _client_identity_attempt.clear()
+ _pending_server_proof.clear()
+ _registry.clear()
+ if _spawn_service != null:
+ _spawn_service.clear_remote_players()
+ if _transport != null:
+ _transport.disconnect_transport()
+ multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
+ _connection_deadline = 0.0
+ _input_accumulator = 0.0
+ _snapshot_accumulator = 0.0
+ _server_capabilities = PackedStringArray()
+ _server_identity_fingerprint = ""
+ _server_identity_public_key = ""
+ _session_identity_keys.clear()
+ _session_operator_fingerprints.clear()
+ _operator_peer_ids.clear()
+ _local_operator = false
+ _host_port = 0
+ _dedicated_host = false
diff --git a/network/network_session.gd.uid b/network/network_session.gd.uid
new file mode 100644
index 0000000..1a423e8
--- /dev/null
+++ b/network/network_session.gd.uid
@@ -0,0 +1 @@
+uid://u62a32ktf0y5
diff --git a/network/network_shop_protocol.gd b/network/network_shop_protocol.gd
new file mode 100644
index 0000000..11900e9
--- /dev/null
+++ b/network/network_shop_protocol.gd
@@ -0,0 +1,123 @@
+class_name NetworkShopProtocol
+extends RefCounted
+
+const CAPABILITY: StringName = &"shop_v1"
+const ART_CAPABILITY: StringName = &"art_shop_v1"
+const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL
+const MAX_ID_LENGTH: int = 96
+const MAX_MESSAGE_LENGTH: int = 160
+const MAX_QUANTITY: int = 99
+
+enum ProductCategory {
+ SUPPLY,
+ ROD,
+ REEL_SPEED_UPGRADE,
+ BARRIER_POWER_UPGRADE,
+ COOLER_CAPACITY_UPGRADE,
+ ART_KIT,
+ ART_UPGRADE,
+}
+
+
+static func validate_request(data: Variant) -> String:
+ if typeof(data) != TYPE_DICTIONARY:
+ return "Purchase could not be completed."
+ var payload: Dictionary = data
+ for key: String in [
+ "request_id",
+ "session_id",
+ "shop_id",
+ "product_id",
+ "category",
+ "quantity",
+ "wallet_balance",
+ "current_state",
+ ]:
+ if not payload.has(key):
+ return "Purchase could not be completed."
+ if (
+ typeof(payload["request_id"]) != TYPE_STRING
+ or typeof(payload["session_id"]) != TYPE_STRING
+ or typeof(payload["shop_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
+ or typeof(payload["product_id"]) not in [
+ TYPE_STRING, TYPE_STRING_NAME
+ ]
+ or typeof(payload["category"]) != TYPE_INT
+ or typeof(payload["quantity"]) != TYPE_INT
+ or typeof(payload["wallet_balance"]) != TYPE_INT
+ or typeof(payload["current_state"]) != TYPE_INT
+ ):
+ return "Purchase could not be completed."
+ var request_id: String = payload["request_id"]
+ var session_id: String = payload["session_id"]
+ var shop_id: String = str(payload["shop_id"])
+ var product_id: String = str(payload["product_id"])
+ var category: int = payload["category"]
+ var quantity: int = payload["quantity"]
+ var wallet_balance: int = payload["wallet_balance"]
+ var current_state: int = payload["current_state"]
+ if (
+ payload.has("bait_unlocked")
+ and typeof(payload["bait_unlocked"]) != TYPE_BOOL
+ ):
+ return "Purchase could not be completed."
+ if (
+ request_id.is_empty()
+ or request_id.length() > MAX_ID_LENGTH
+ or session_id.is_empty()
+ or session_id.length() > MAX_ID_LENGTH
+ or shop_id.is_empty()
+ or shop_id.length() > MAX_ID_LENGTH
+ or product_id.is_empty()
+ or product_id.length() > MAX_ID_LENGTH
+ or category < 0
+ or category >= ProductCategory.size()
+ or quantity < 1
+ or quantity > MAX_QUANTITY
+ or wallet_balance < 0
+ or current_state < 0
+ ):
+ return "Purchase could not be completed."
+ return ""
+
+
+static func validate_result(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var payload: Dictionary = data
+ return (
+ _valid_id(payload.get("result_id"))
+ and _valid_id(payload.get("request_id"))
+ and typeof(payload.get("session_id")) == TYPE_STRING
+ and typeof(payload.get("target_peer_id")) == TYPE_INT
+ and typeof(payload.get("accepted")) == TYPE_BOOL
+ and typeof(payload.get("product_id")) in [
+ TYPE_STRING, TYPE_STRING_NAME
+ ]
+ and not str(payload["product_id"]).is_empty()
+ and str(payload["product_id"]).length() <= MAX_ID_LENGTH
+ and typeof(payload.get("category")) == TYPE_INT
+ and int(payload["category"]) >= 0
+ and int(payload["category"]) < ProductCategory.size()
+ and typeof(payload.get("quantity")) == TYPE_INT
+ and int(payload["quantity"]) >= 0
+ and int(payload["quantity"]) <= MAX_QUANTITY
+ and typeof(payload.get("total_cost")) == TYPE_INT
+ and int(payload["total_cost"]) >= 0
+ and typeof(payload.get("expected_wallet")) == TYPE_INT
+ and int(payload["expected_wallet"]) >= 0
+ and typeof(payload.get("expected_state")) == TYPE_INT
+ and int(payload["expected_state"]) >= 0
+ and typeof(payload.get("resulting_state")) == TYPE_INT
+ and int(payload["resulting_state"]) >= 0
+ and typeof(payload.get("message")) == TYPE_STRING
+ and str(payload["message"]).length() <= MAX_MESSAGE_LENGTH
+ )
+
+
+static func _valid_id(value: Variant) -> bool:
+ return (
+ typeof(value) == TYPE_STRING
+ and not str(value).is_empty()
+ and str(value).length() <= MAX_ID_LENGTH
+ )
diff --git a/network/network_shop_protocol.gd.uid b/network/network_shop_protocol.gd.uid
new file mode 100644
index 0000000..ce71a8c
--- /dev/null
+++ b/network/network_shop_protocol.gd.uid
@@ -0,0 +1 @@
+uid://cmb5ud0b1rrmg
diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd
new file mode 100644
index 0000000..4dd0f98
--- /dev/null
+++ b/network/network_shop_service.gd
@@ -0,0 +1,885 @@
+class_name NetworkShopService
+extends Node
+
+const FishingShopStockType = preload(
+ "res://economy/fishing_shop_stock.gd"
+)
+const ItemDataType = preload("res://items/item_data.gd")
+const OwnedItemType = preload("res://items/owned_item.gd")
+const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
+
+const SHOP_ID: StringName = &"main_fishing_shop"
+const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade"
+const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade"
+const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade"
+const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
+
+signal local_purchase_pending(request_id: String)
+signal local_purchase_finished(
+ request_id: String,
+ accepted: bool,
+ message: String,
+ product_id: StringName,
+ category: int,
+ quantity: int,
+ total_cost: int,
+)
+
+var _session: NetworkSession
+var _spawn_service: PlayerSpawnService
+var _network_fishing: NetworkFishingService
+var _interaction: FishingShopInteraction
+var _wallet: PlayerWallet
+var _bag: PlayerBag
+var _item_catalog: ItemCatalog
+var _upgrades: PlayerFishingUpgrades
+var _cooler_capacity: PlayerCoolerCapacity
+var _art_unlocks: PlayerArtUnlocks
+var _save_manager: PlayerSaveManager
+var _request_ledgers: Dictionary[int, Dictionary] = {}
+var _pending_by_peer: Dictionary[int, String] = {}
+var _result_owners: Dictionary[String, int] = {}
+var _acknowledged_results: Dictionary[String, bool] = {}
+var _received_results: Dictionary[String, bool] = {}
+var _applied_results: Dictionary[String, bool] = {}
+var _pending_local_request: Dictionary = {}
+var _reservations: PlayerAssetReservationService
+
+
+func setup(
+ session: NetworkSession,
+ spawn_service: PlayerSpawnService,
+ network_fishing: NetworkFishingService,
+ interaction: FishingShopInteraction,
+ wallet: PlayerWallet,
+ bag: PlayerBag,
+ item_catalog: ItemCatalog,
+ upgrades: PlayerFishingUpgrades,
+ cooler_capacity: PlayerCoolerCapacity,
+ art_unlocks: PlayerArtUnlocks,
+ save_manager: PlayerSaveManager,
+ reservations: PlayerAssetReservationService,
+) -> void:
+ _session = session
+ _spawn_service = spawn_service
+ _network_fishing = network_fishing
+ _interaction = interaction
+ _wallet = wallet
+ _bag = bag
+ _item_catalog = item_catalog
+ _upgrades = upgrades
+ _cooler_capacity = cooler_capacity
+ _art_unlocks = art_unlocks
+ _save_manager = save_manager
+ _reservations = reservations
+ if not _session.peer_removed.is_connected(_on_peer_removed):
+ _session.peer_removed.connect(_on_peer_removed)
+ if not _session.state_changed.is_connected(_on_session_state_changed):
+ _session.state_changed.connect(_on_session_state_changed)
+
+
+func can_request_purchase() -> bool:
+ return (
+ _session != null
+ and _session.is_gameplay_session_active()
+ and (
+ _session.is_host()
+ or _session.supports_server_capability(
+ NetworkShopProtocol.CAPABILITY
+ )
+ )
+ )
+
+
+func can_request_art_purchase() -> bool:
+ return (
+ can_request_purchase()
+ and (
+ _session.is_host()
+ or _session.supports_server_capability(
+ NetworkShopProtocol.ART_CAPABILITY
+ )
+ )
+ )
+
+
+func is_local_purchase_pending() -> bool:
+ return not _pending_local_request.is_empty()
+
+
+func request_supply(item_id: StringName) -> String:
+ var owned: int = _bag.get_quantity(item_id) if _bag != null else 0
+ var item: ItemDataType = (
+ _item_catalog.get_item_by_id(item_id)
+ if _item_catalog != null else null
+ )
+ var quantity: int = FishingShopStockType.get_purchase_quantity(
+ item_id,
+ owned,
+ item.max_stack if item != null else FishingShopStockType.WORM_MAX_STACK,
+ )
+ return _request_purchase(
+ item_id,
+ NetworkShopProtocol.ProductCategory.SUPPLY,
+ quantity,
+ owned
+ )
+
+
+func request_reel_speed_upgrade() -> String:
+ return _request_purchase(
+ REEL_PRODUCT_ID,
+ NetworkShopProtocol.ProductCategory.REEL_SPEED_UPGRADE,
+ 1,
+ _upgrades.get_reel_speed_level() if _upgrades != null else 0
+ )
+
+
+func request_barrier_power_upgrade() -> String:
+ return _request_purchase(
+ BARRIER_PRODUCT_ID,
+ NetworkShopProtocol.ProductCategory.BARRIER_POWER_UPGRADE,
+ 1,
+ _upgrades.get_barrier_power_level() if _upgrades != null else 0
+ )
+
+
+func request_cooler_capacity_upgrade() -> String:
+ return _request_purchase(
+ COOLER_PRODUCT_ID,
+ NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE,
+ 1,
+ _cooler_capacity.get_level() if _cooler_capacity != null else 0
+ )
+
+
+func request_art_kit() -> String:
+ return _request_purchase(
+ ArtShopStockType.ART_KIT_ITEM_ID,
+ NetworkShopProtocol.ProductCategory.ART_KIT,
+ 1,
+ _bag.get_quantity(ArtShopStockType.ART_KIT_ITEM_ID)
+ if _bag != null else 0,
+ )
+
+
+func request_art_upgrade(product_id: StringName) -> String:
+ if (
+ _bag == null
+ or not _bag.owns_item(ArtShopStockType.ART_KIT_ITEM_ID)
+ ):
+ local_purchase_finished.emit(
+ "",
+ false,
+ "Own an Art Kit before buying upgrades.",
+ product_id,
+ NetworkShopProtocol.ProductCategory.ART_UPGRADE,
+ 0,
+ 0,
+ )
+ return ""
+ return _request_purchase(
+ product_id,
+ NetworkShopProtocol.ProductCategory.ART_UPGRADE,
+ 1,
+ _art_unlocks.get_unlock_mask() if _art_unlocks != null else 0,
+ )
+
+
+func _request_purchase(
+ product_id: StringName,
+ category: int,
+ quantity: int,
+ current_state: int,
+) -> String:
+ if is_local_purchase_pending():
+ local_purchase_finished.emit(
+ "", false, "Purchasing…", product_id, category, 0, 0
+ )
+ return ""
+ if not can_request_purchase():
+ local_purchase_finished.emit(
+ "",
+ false,
+ "Purchases are not supported by this server.",
+ product_id,
+ category,
+ 0,
+ 0
+ )
+ return ""
+ if (
+ category in [
+ NetworkShopProtocol.ProductCategory.ART_KIT,
+ NetworkShopProtocol.ProductCategory.ART_UPGRADE,
+ ]
+ and not can_request_art_purchase()
+ ):
+ local_purchase_finished.emit(
+ "", false, "Art supplies require a newer server.",
+ product_id, category, 0, 0,
+ )
+ return ""
+ var request_id: String = _new_id("shop")
+ var request: Dictionary = {
+ "request_id": request_id,
+ "session_id": _session.get_session_id(),
+ "shop_id": str(SHOP_ID),
+ "product_id": str(product_id),
+ "category": category,
+ "quantity": quantity,
+ "wallet_balance": _wallet.get_balance() if _wallet != null else 0,
+ "current_state": current_state,
+ }
+ if (
+ category == NetworkShopProtocol.ProductCategory.SUPPLY
+ and FishingShopStockType.is_bait_topoff(product_id)
+ ):
+ request["bait_unlocked"] = (
+ _bag != null and _bag.is_bait_unlocked(product_id)
+ )
+ _pending_local_request = request.duplicate(true)
+ local_purchase_pending.emit(request_id)
+ if _session.is_host():
+ _handle_purchase_request(_session.get_local_peer_id(), request)
+ else:
+ submit_purchase_request.rpc_id(1, request)
+ return request_id
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ NetworkShopProtocol.RELIABLE_CHANNEL
+)
+func submit_purchase_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ ):
+ return
+ _handle_purchase_request(sender_id, data)
+
+
+func _handle_purchase_request(peer_id: int, data: Dictionary) -> void:
+ var validation_error: String = NetworkShopProtocol.validate_request(data)
+ var request_id: String = str(data.get("request_id", ""))
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ if (
+ not request_id.is_empty()
+ and request_id.length() <= NetworkShopProtocol.MAX_ID_LENGTH
+ and ledger.has(request_id)
+ ):
+ _send_result(peer_id, ledger[request_id])
+ return
+ if not validation_error.is_empty():
+ _record_and_send(
+ peer_id,
+ _rejected_result(data, validation_error)
+ )
+ return
+ if str(data["session_id"]) != _session.get_session_id():
+ _record_and_send(
+ peer_id,
+ _rejected_result(data, "Purchase could not be completed.")
+ )
+ return
+ if (
+ _pending_by_peer.has(peer_id)
+ and _pending_by_peer[peer_id] != request_id
+ ):
+ _record_and_send(
+ peer_id,
+ _rejected_result(data, "A purchase is already pending.")
+ )
+ return
+ if not _is_shop_available_for_peer(peer_id):
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ var message: String = (
+ "The shop is unavailable."
+ if avatar == null or _interaction == null
+ else "Move closer to the shop."
+ )
+ _record_and_send(peer_id, _rejected_result(data, message))
+ return
+ var result: Dictionary = _build_authoritative_result(peer_id, data)
+ _pending_by_peer[peer_id] = request_id
+ _record_and_send(peer_id, result)
+
+
+func _build_authoritative_result(
+ peer_id: int,
+ request: Dictionary,
+) -> Dictionary:
+ var product_id := StringName(str(request["product_id"]))
+ var category: int = request["category"]
+ var quantity: int = request["quantity"]
+ var wallet_balance: int = request["wallet_balance"]
+ var current_state: int = request["current_state"]
+ var cost: int = -1
+ var resulting_state: int = current_state
+ var rejection: String = ""
+ if StringName(str(request["shop_id"])) != SHOP_ID:
+ rejection = "The shop is unavailable."
+ elif quantity < 1:
+ rejection = "Purchase could not be completed."
+ else:
+ match category:
+ NetworkShopProtocol.ProductCategory.SUPPLY:
+ var item: ItemDataType = _item_catalog.get_item_by_id(
+ product_id
+ ) if _item_catalog != null else null
+ var bait_topoff: bool = (
+ FishingShopStockType.is_bait_topoff(product_id)
+ )
+ var permanent_unlock: bool = (
+ FishingShopStockType.is_permanent_unlock(product_id, item)
+ )
+ if (
+ item == null
+ or not item.is_valid()
+ or product_id not in (
+ FishingShopStockType.get_stock_item_ids()
+ )
+ or (
+ item.category != ItemDataType.Category.CONSUMABLE
+ and not bait_topoff
+ and not permanent_unlock
+ )
+ or (not item.stackable and not permanent_unlock)
+ or (
+ not item.usable
+ and not bait_topoff
+ and not permanent_unlock
+ and not FishingShopStockType.is_passive_supply(
+ product_id
+ )
+ )
+ or current_state >= item.max_stack
+ or current_state + quantity > item.max_stack
+ ):
+ rejection = (
+ "Your Bag is full."
+ if item != null and current_state >= item.max_stack
+ else "Purchase could not be completed."
+ )
+ else:
+ var expected_quantity: int = (
+ FishingShopStockType.get_purchase_quantity(
+ product_id, current_state, item.max_stack
+ )
+ )
+ if quantity != expected_quantity:
+ rejection = "Purchase could not be completed."
+ else:
+ cost = FishingShopStockType.get_purchase_cost(
+ product_id,
+ quantity,
+ bool(request.get("bait_unlocked", true)),
+ )
+ if cost < 0:
+ rejection = "Purchase could not be completed."
+ resulting_state = current_state + quantity
+ NetworkShopProtocol.ProductCategory.ROD:
+ rejection = "This item is not sold here."
+ NetworkShopProtocol.ProductCategory.REEL_SPEED_UPGRADE:
+ if (
+ product_id != REEL_PRODUCT_ID
+ or current_state
+ >= PlayerFishingUpgrades.MAX_REEL_SPEED_LEVEL
+ ):
+ rejection = "Upgrade is already at maximum."
+ else:
+ cost = (
+ PlayerFishingUpgrades.REEL_SPEED_COSTS[
+ current_state
+ ]
+ )
+ resulting_state = current_state + 1
+ NetworkShopProtocol.ProductCategory.BARRIER_POWER_UPGRADE:
+ if (
+ product_id != BARRIER_PRODUCT_ID
+ or current_state
+ >= PlayerFishingUpgrades.MAX_BARRIER_POWER_LEVEL
+ ):
+ rejection = "Upgrade is already at maximum."
+ else:
+ cost = (
+ PlayerFishingUpgrades.BARRIER_POWER_COSTS[
+ current_state
+ ]
+ )
+ resulting_state = current_state + 1
+ NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
+ if (
+ product_id != COOLER_PRODUCT_ID
+ or current_state >= PlayerCoolerCapacity.MAX_LEVEL
+ ):
+ rejection = "Upgrade is already at maximum."
+ else:
+ cost = (
+ PlayerCoolerCapacity.EXPANSION_COSTS[
+ current_state
+ ]
+ )
+ resulting_state = current_state + 1
+ NetworkShopProtocol.ProductCategory.ART_KIT:
+ var art_item: ItemDataType = _item_catalog.get_item_by_id(
+ product_id
+ ) if _item_catalog != null else null
+ cost = ArtShopStockType.get_price(product_id)
+ if (
+ product_id != ArtShopStockType.ART_KIT_ITEM_ID
+ or art_item == null
+ or not art_item.is_valid()
+ or art_item.category != ItemDataType.Category.TOOL
+ or art_item.stackable
+ or not art_item.equippable
+ or not art_item.hotbar_allowed
+ or current_state != 0
+ ):
+ rejection = (
+ "Art Kit already owned."
+ if current_state > 0
+ else "Purchase could not be completed."
+ )
+ else:
+ resulting_state = 1
+ NetworkShopProtocol.ProductCategory.ART_UPGRADE:
+ cost = ArtShopStockType.get_price(product_id)
+ if (
+ not PlayerArtUnlocks.is_product_id(product_id)
+ or current_state < 0
+ or (current_state & ~PlayerArtUnlocks.ALL_UNLOCK_MASK) != 0
+ or PlayerArtUnlocks.resulting_mask(
+ current_state, product_id
+ ) == current_state
+ ):
+ rejection = "Upgrade is already unlocked."
+ else:
+ resulting_state = PlayerArtUnlocks.resulting_mask(
+ current_state, product_id
+ )
+ if rejection.is_empty() and (cost < 0 or wallet_balance < cost):
+ rejection = "Insufficient funds."
+ if not rejection.is_empty():
+ return _rejected_result(request, rejection)
+ return {
+ "result_id": _new_id("shop_result"),
+ "request_id": str(request["request_id"]),
+ "session_id": _session.get_session_id(),
+ "target_peer_id": peer_id,
+ "accepted": true,
+ "product_id": str(product_id),
+ "category": category,
+ "quantity": quantity,
+ "total_cost": cost,
+ "expected_wallet": wallet_balance,
+ "expected_state": current_state,
+ "resulting_state": resulting_state,
+ "message": "Purchase complete.",
+ }
+
+
+func _rejected_result(request: Dictionary, message: String) -> Dictionary:
+ var request_id: String = str(request.get("request_id", ""))
+ if (
+ request_id.is_empty()
+ or request_id.length() > NetworkShopProtocol.MAX_ID_LENGTH
+ ):
+ request_id = "invalid"
+ var category: int = clampi(
+ int(request.get("category", 0)),
+ 0,
+ NetworkShopProtocol.ProductCategory.size() - 1
+ )
+ return {
+ "result_id": _new_id("shop_result"),
+ "request_id": request_id,
+ "session_id": _session.get_session_id() if _session != null else "",
+ "target_peer_id": 0,
+ "accepted": false,
+ "product_id": str(request.get("product_id", "invalid")).left(
+ NetworkShopProtocol.MAX_ID_LENGTH
+ ),
+ "category": category,
+ "quantity": 0,
+ "total_cost": 0,
+ "expected_wallet": maxi(int(request.get("wallet_balance", 0)), 0),
+ "expected_state": maxi(int(request.get("current_state", 0)), 0),
+ "resulting_state": maxi(int(request.get("current_state", 0)), 0),
+ "message": message.left(NetworkShopProtocol.MAX_MESSAGE_LENGTH),
+ }
+
+
+func _record_and_send(peer_id: int, result: Dictionary) -> void:
+ result["target_peer_id"] = peer_id
+ var request_id: String = result["request_id"]
+ var ledger: Dictionary = _request_ledgers.get(peer_id, {})
+ ledger[request_id] = result.duplicate(true)
+ _bound_dictionary(ledger)
+ _request_ledgers[peer_id] = ledger
+ _result_owners[str(result["result_id"])] = peer_id
+ _bound_dictionary(_result_owners)
+ _send_result(peer_id, result)
+
+
+func _send_result(peer_id: int, result: Dictionary) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _apply_purchase_result(result)
+ else:
+ receive_purchase_result.rpc_id(peer_id, result)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ NetworkShopProtocol.RELIABLE_CHANNEL
+)
+func receive_purchase_result(data: Dictionary) -> void:
+ _apply_purchase_result(data)
+
+
+func _apply_purchase_result(data: Dictionary) -> void:
+ if (
+ not NetworkShopProtocol.validate_result(data)
+ or _session == null
+ or str(data["session_id"]) != _session.get_session_id()
+ or int(data["target_peer_id"]) != _session.get_local_peer_id()
+ ):
+ return
+ var result_id: String = data["result_id"]
+ if _received_results.has(result_id):
+ _acknowledge_result(data, _applied_results.has(result_id), "")
+ return
+ if (
+ _pending_local_request.is_empty()
+ or str(data["request_id"])
+ != str(_pending_local_request.get("request_id", ""))
+ ):
+ return
+ if not bool(data["accepted"]):
+ _received_results[result_id] = true
+ _bound_dictionary(_received_results)
+ _finish_local_purchase(data, false, str(data["message"]))
+ _acknowledge_result(data, false, str(data["message"]))
+ return
+ var validation_message: String = _validate_local_result(data)
+ if not validation_message.is_empty():
+ _fail_local_apply(data, validation_message)
+ return
+ var wallet_snapshot: int = _wallet.get_balance()
+ var bag_snapshot: Array[OwnedItemType] = _bag.get_all_items()
+ var bait_unlock_snapshot: Array[StringName] = (
+ _bag.get_unlocked_bait_ids()
+ )
+ var reel_snapshot: int = _upgrades.get_reel_speed_level()
+ var barrier_snapshot: int = _upgrades.get_barrier_power_level()
+ var cooler_snapshot: int = _cooler_capacity.get_level()
+ var art_snapshot: int = _art_unlocks.get_unlock_mask()
+ var applied: bool = _apply_local_product(data)
+ if not applied or not _save_manager.save_if_dirty():
+ _bag.replace_all_items(bag_snapshot)
+ _bag.replace_unlocked_bait_ids(bait_unlock_snapshot)
+ _upgrades.restore_levels(reel_snapshot, barrier_snapshot)
+ _cooler_capacity.restore_level(cooler_snapshot)
+ _art_unlocks.restore_mask(art_snapshot)
+ _wallet.restore_balance(wallet_snapshot)
+ _save_manager.save_if_dirty()
+ _fail_local_apply(data, "Purchase could not be completed.")
+ return
+ _applied_results[result_id] = true
+ _bound_dictionary(_applied_results)
+ _received_results[result_id] = true
+ _bound_dictionary(_received_results)
+ _finish_local_purchase(data, true, "Purchase complete.")
+ _acknowledge_result(data, true, "")
+
+
+func _validate_local_result(data: Dictionary) -> String:
+ if (
+ _wallet == null
+ or _bag == null
+ or _upgrades == null
+ or _cooler_capacity == null
+ or _art_unlocks == null
+ or _save_manager == null
+ or _wallet.get_balance() != int(data["expected_wallet"])
+ or str(data["product_id"])
+ != str(_pending_local_request.get("product_id", ""))
+ or int(data["category"])
+ != int(_pending_local_request.get("category", -1))
+ or int(data["quantity"])
+ != int(_pending_local_request.get("quantity", -1))
+ ):
+ return "Purchase could not be completed."
+ var category: int = data["category"]
+ var product_id := StringName(str(data["product_id"]))
+ var expected_state: int = data["expected_state"]
+ var cost: int = data["total_cost"]
+ if (
+ _reservations != null
+ and _reservations.get_available_fish_coin() < cost
+ ):
+ return "Reserved in a letter."
+ var resulting_state: int = int(data["resulting_state"])
+ if (
+ category != NetworkShopProtocol.ProductCategory.ART_UPGRADE
+ and resulting_state != expected_state + int(data["quantity"])
+ ):
+ return "Purchase could not be completed."
+ if not _wallet.can_afford(cost):
+ return "Insufficient funds."
+ match category:
+ NetworkShopProtocol.ProductCategory.SUPPLY:
+ var item: ItemDataType = (
+ _item_catalog.get_item_by_id(product_id)
+ if _item_catalog != null else null
+ )
+ var bait_unlocked: bool = _bag.is_bait_unlocked(product_id)
+ if (
+ item == null
+ or _bag.get_quantity(product_id) != expected_state
+ or int(data["quantity"]) != FishingShopStockType.get_purchase_quantity(
+ product_id, expected_state, item.max_stack
+ )
+ ):
+ return "Purchase could not be completed."
+ if cost != FishingShopStockType.get_purchase_cost(
+ product_id, int(data["quantity"]), bait_unlocked
+ ):
+ return "Purchase could not be completed."
+ if not _bag.can_add_item(product_id, data["quantity"]):
+ return "Your Bag is full."
+ NetworkShopProtocol.ProductCategory.REEL_SPEED_UPGRADE:
+ if _upgrades.get_reel_speed_level() != expected_state:
+ return "Purchase could not be completed."
+ if _upgrades.get_next_reel_speed_cost() != cost:
+ return "Purchase could not be completed."
+ NetworkShopProtocol.ProductCategory.BARRIER_POWER_UPGRADE:
+ if _upgrades.get_barrier_power_level() != expected_state:
+ return "Purchase could not be completed."
+ if _upgrades.get_next_barrier_power_cost() != cost:
+ return "Purchase could not be completed."
+ NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
+ if _cooler_capacity.get_level() != expected_state:
+ return "Purchase could not be completed."
+ if _cooler_capacity.get_next_cost() != cost:
+ return "Purchase could not be completed."
+ NetworkShopProtocol.ProductCategory.ART_KIT:
+ if (
+ product_id != ArtShopStockType.ART_KIT_ITEM_ID
+ or _bag.get_quantity(product_id) != expected_state
+ or not _bag.can_add_item(product_id, 1)
+ or cost != ArtShopStockType.ART_KIT_PRICE
+ ):
+ return "Purchase could not be completed."
+ NetworkShopProtocol.ProductCategory.ART_UPGRADE:
+ if (
+ _art_unlocks.get_unlock_mask() != expected_state
+ or not PlayerArtUnlocks.is_product_id(product_id)
+ or resulting_state != PlayerArtUnlocks.resulting_mask(
+ expected_state, product_id
+ )
+ or cost != ArtShopStockType.UPGRADE_PRICE
+ ):
+ return "Purchase could not be completed."
+ _:
+ return "Purchase could not be completed."
+ return ""
+
+
+func _apply_local_product(data: Dictionary) -> bool:
+ var category: int = data["category"]
+ var product_id := StringName(str(data["product_id"]))
+ match category:
+ NetworkShopProtocol.ProductCategory.SUPPLY:
+ return (
+ _wallet.debit(data["total_cost"])
+ and _bag.add_item(product_id, data["quantity"])
+ )
+ NetworkShopProtocol.ProductCategory.REEL_SPEED_UPGRADE:
+ return _upgrades.purchase_reel_speed(_wallet)
+ NetworkShopProtocol.ProductCategory.BARRIER_POWER_UPGRADE:
+ return _upgrades.purchase_barrier_power(_wallet)
+ NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
+ return _cooler_capacity.purchase(_wallet)
+ NetworkShopProtocol.ProductCategory.ART_KIT:
+ return (
+ _wallet.debit(int(data["total_cost"]))
+ and _bag.add_item(product_id, 1)
+ )
+ NetworkShopProtocol.ProductCategory.ART_UPGRADE:
+ return _art_unlocks.purchase_product(
+ product_id, _wallet, int(data["total_cost"])
+ )
+ return false
+
+
+func _fail_local_apply(data: Dictionary, message: String) -> void:
+ _finish_local_purchase(data, false, message)
+ _acknowledge_result(data, false, message)
+
+
+func _finish_local_purchase(
+ data: Dictionary,
+ accepted: bool,
+ message: String,
+) -> void:
+ _pending_local_request.clear()
+ local_purchase_finished.emit(
+ str(data["request_id"]),
+ accepted,
+ message,
+ StringName(str(data["product_id"])),
+ int(data["category"]),
+ int(data["quantity"]),
+ int(data["total_cost"])
+ )
+
+
+func _acknowledge_result(
+ data: Dictionary,
+ applied: bool,
+ message: String,
+) -> void:
+ if _session.is_host():
+ _handle_acknowledgement(
+ _session.get_local_peer_id(),
+ str(data["result_id"]),
+ str(data["request_id"]),
+ applied,
+ message
+ )
+ else:
+ acknowledge_purchase_result.rpc_id(
+ 1,
+ str(data["result_id"]),
+ str(data["request_id"]),
+ applied,
+ message.left(NetworkShopProtocol.MAX_MESSAGE_LENGTH)
+ )
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ NetworkShopProtocol.RELIABLE_CHANNEL
+)
+func acknowledge_purchase_result(
+ result_id: String,
+ request_id: String,
+ applied: bool,
+ message: String,
+) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ ):
+ return
+ _handle_acknowledgement(
+ sender_id, result_id, request_id, applied, message
+ )
+
+
+func _handle_acknowledgement(
+ peer_id: int,
+ result_id: String,
+ request_id: String,
+ _applied: bool,
+ message: String,
+) -> void:
+ if (
+ result_id.is_empty()
+ or result_id.length() > NetworkShopProtocol.MAX_ID_LENGTH
+ or request_id.is_empty()
+ or request_id.length() > NetworkShopProtocol.MAX_ID_LENGTH
+ or message.length() > NetworkShopProtocol.MAX_MESSAGE_LENGTH
+ or _result_owners.get(result_id, 0) != peer_id
+ ):
+ return
+ _acknowledged_results[result_id] = true
+ _bound_dictionary(_acknowledged_results)
+ if _pending_by_peer.get(peer_id, "") == request_id:
+ _pending_by_peer.erase(peer_id)
+
+
+func _is_shop_available_for_peer(peer_id: int) -> bool:
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_gameplay_session_active()
+ or _spawn_service == null
+ or _interaction == null
+ or not is_instance_valid(_interaction)
+ ):
+ return false
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ return (
+ avatar != null
+ and not avatar.is_water_recovery_active()
+ and (
+ _network_fishing == null
+ or not _network_fishing.has_peer_attempt(peer_id)
+ )
+ and _interaction.is_avatar_in_range(avatar)
+ )
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ _request_ledgers.erase(peer_id)
+ _pending_by_peer.erase(peer_id)
+ for result_id: String in _result_owners.keys():
+ if _result_owners[result_id] == peer_id:
+ _result_owners.erase(result_id)
+ _acknowledged_results.erase(result_id)
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ var had_pending: bool = is_local_purchase_pending()
+ _clear_session_state()
+ if had_pending:
+ local_purchase_finished.emit(
+ "",
+ false,
+ "Connection lost.",
+ StringName(),
+ 0,
+ 0,
+ 0
+ )
+
+
+func _clear_session_state() -> void:
+ _request_ledgers.clear()
+ _pending_by_peer.clear()
+ _result_owners.clear()
+ _acknowledged_results.clear()
+ _received_results.clear()
+ _applied_results.clear()
+ _pending_local_request.clear()
+
+
+func _bound_dictionary(values: Dictionary) -> void:
+ while values.size() > MAX_LEDGER_ENTRIES_PER_PEER:
+ values.erase(values.keys().front())
+
+
+func _new_id(prefix: String) -> String:
+ return "%s:%s" % [
+ prefix,
+ Crypto.new().generate_random_bytes(16).hex_encode(),
+ ]
diff --git a/network/network_shop_service.gd.uid b/network/network_shop_service.gd.uid
new file mode 100644
index 0000000..0a102ea
--- /dev/null
+++ b/network/network_shop_service.gd.uid
@@ -0,0 +1 @@
+uid://4ci4dbhyfc11
diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd
new file mode 100644
index 0000000..e42519c
--- /dev/null
+++ b/network/network_surface_drawing_service.gd
@@ -0,0 +1,2224 @@
+class_name NetworkSurfaceDrawingService
+extends Node
+
+const BrushHighlightShader: Shader = preload(
+ "res://drawing/surface_drawing_highlight.gdshader"
+)
+const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
+
+signal hud_state_changed(
+ is_active: bool,
+ mode_name: String,
+ color_name: String,
+ color_value: Color,
+ brush_size: int,
+ grid_size: int,
+ status: String,
+)
+signal session_artwork_changed(canvas_count: int, painted_cell_count: int)
+
+const SOLID_SURFACE_MASK: int = 1
+const MAX_DRAW_DISTANCE: float = 16.0
+const SURFACE_VALIDATION_DEPTH: float = 0.5
+const MIN_SURFACE_NORMAL_DOT: float = 0.35
+const EDIT_INTERVAL_MSEC: int = 45
+const REQUEST_WINDOW_MSEC: int = 1000
+const MAX_REQUESTS_PER_WINDOW: int = 32
+const MAX_RECENT_REQUEST_IDS: int = 64
+const GRID_PREVIEW_SURFACE_OFFSET: float = 0.03
+const POINTER_EDGE_MARGIN: float = 2.0
+const INVALID_POINTER_SCREEN_POSITION := Vector2(-1.0, -1.0)
+
+enum GuideAction {
+ NONE,
+ HIDE,
+ RESTORE,
+ FINALIZE,
+}
+
+var _session: NetworkSession
+var _spawn_service: PlayerSpawnService
+var _relationships: PlayerRelationshipStore
+var _local_player: Player
+var _bag: PlayerBag
+var _hotbar: PlayerHotbar
+var _art_unlocks: PlayerArtUnlocks
+var _drawing_root: Node3D
+var _canvas_states: Dictionary[String, Dictionary] = {}
+var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {}
+var _selected_canvas_id: String = ""
+var _brush_preview: MultiMeshInstance3D
+var _brush_preview_multimesh: MultiMesh
+var _brush_preview_material: ShaderMaterial
+var _placement_preview: MeshInstance3D
+var _placement_preview_material: StandardMaterial3D
+var _active: bool = false
+var _placing_grid: bool = false
+var _brush_size: int = 1
+var _grid_size: int = SurfaceDrawingProtocol.DEFAULT_GRID_SIZE
+var _color_ids: Array[StringName] = []
+var _color_index: int = 0
+var _painting: bool = false
+var _erasing: bool = false
+var _eraser_mode: bool = false
+var _armed_guide_action: int = GuideAction.NONE
+var _armed_return_placement_mode: bool = false
+var _armed_return_eraser_mode: bool = false
+var _camera_look_active: bool = false
+var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
+var _last_edited_cell := Vector3i(-1, -1, -1)
+var _active_stroke_id: String = ""
+var _last_local_stroke_id: String = ""
+var _last_edit_msec: int = 0
+var _aim_hit: Dictionary = {}
+var _pointer_screen_position: Vector2 = Vector2.ZERO
+var _request_sequence: int = 0
+var _canvas_sequence: int = 0
+var _peer_request_times: Dictionary[int, PackedInt64Array] = {}
+var _peer_request_ids: Dictionary[int, PackedStringArray] = {}
+var _stroke_history_by_peer: Dictionary[int, Dictionary] = {}
+var _cell_last_stroke: Dictionary[String, String] = {}
+var _peer_art_entitlements: Dictionary[int, Dictionary] = {}
+
+
+func setup(
+ session: NetworkSession,
+ spawn_service: PlayerSpawnService,
+ relationships: PlayerRelationshipStore,
+ local_player: Player,
+ drawing_root: Node3D,
+ bag: PlayerBag,
+ hotbar: PlayerHotbar,
+ art_unlocks: PlayerArtUnlocks,
+) -> void:
+ _session = session
+ _spawn_service = spawn_service
+ _relationships = relationships
+ _local_player = local_player
+ _drawing_root = drawing_root
+ _bag = bag
+ _hotbar = hotbar
+ _art_unlocks = art_unlocks
+ _refresh_local_unlocks()
+ if _session != null:
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.peer_removed.connect(_on_peer_removed)
+ if _relationships != null:
+ _relationships.relationship_changed.connect(
+ _on_relationship_changed
+ )
+ if _bag != null and not _bag.contents_changed.is_connected(
+ _on_local_art_entitlement_changed
+ ):
+ _bag.contents_changed.connect(_on_local_art_entitlement_changed)
+ if _art_unlocks != null and not _art_unlocks.unlocks_changed.is_connected(
+ _on_local_art_unlocks_changed
+ ):
+ _art_unlocks.unlocks_changed.connect(_on_local_art_unlocks_changed)
+ _create_brush_preview()
+ _create_placement_preview()
+ set_process(true)
+ _emit_hud_state("")
+
+
+func handle_input(
+ event: InputEvent,
+ can_open: bool,
+ pointer_screen_position: Vector2 = INVALID_POINTER_SCREEN_POSITION,
+) -> bool:
+ if not _active:
+ return false
+ if not can_open:
+ deactivate()
+ return false
+ if event.is_action_pressed("ui_cancel"):
+ if _armed_guide_action != GuideAction.NONE:
+ _cancel_armed_guide_action()
+ return true
+ if _placing_grid:
+ _set_placement_mode(false)
+ return true
+ return false
+ if event is InputEventKey:
+ var key_event := event as InputEventKey
+ if not key_event.pressed or key_event.echo:
+ return false
+ if key_event.ctrl_pressed and key_event.physical_keycode == KEY_Z:
+ request_undo_last_stroke()
+ return true
+ match key_event.physical_keycode:
+ KEY_Q:
+ if not _placing_grid:
+ _cycle_color(-1)
+ return true
+ KEY_E:
+ if not _placing_grid:
+ _cycle_color(1)
+ return true
+ KEY_R:
+ _set_placement_mode(not _placing_grid)
+ return true
+ if event is InputEventMouseButton:
+ var mouse_event := event as InputEventMouseButton
+ # Ordinary wheel input always belongs to Hotbar selection. Brush size is
+ # selected explicitly in the Art Kit toolbar.
+ if mouse_event.button_index in [
+ MOUSE_BUTTON_WHEEL_UP,
+ MOUSE_BUTTON_WHEEL_DOWN,
+ ]:
+ return false
+ _set_pointer_from_input(
+ mouse_event.position,
+ pointer_screen_position,
+ )
+ _update_aim()
+ match mouse_event.button_index:
+ MOUSE_BUTTON_LEFT:
+ if _placing_grid:
+ if mouse_event.pressed:
+ if _armed_guide_action != GuideAction.NONE:
+ _execute_armed_guide_action()
+ elif mouse_event.shift_pressed and mouse_event.ctrl_pressed:
+ _finalize_selected_guide()
+ elif mouse_event.shift_pressed:
+ _remove_selected_guide()
+ else:
+ _request_canvas_at_aim()
+ return true
+ if mouse_event.pressed:
+ _begin_stroke()
+ _erasing = (
+ mouse_event.pressed
+ and (mouse_event.shift_pressed or _eraser_mode)
+ )
+ _painting = mouse_event.pressed and not _erasing
+ if _painting or _erasing:
+ _submit_current_brush(_erasing, true)
+ else:
+ _finish_stroke()
+ return true
+ MOUSE_BUTTON_RIGHT:
+ _camera_look_active = mouse_event.pressed
+ _reset_stroke()
+ return false
+ if event is InputEventMouseMotion:
+ var motion_event := event as InputEventMouseMotion
+ if _camera_look_active:
+ return false
+ _set_pointer_from_input(
+ motion_event.position,
+ pointer_screen_position,
+ )
+ _update_aim()
+ return true
+ return false
+
+
+func activate(
+ pointer_screen_position: Vector2 = INVALID_POINTER_SCREEN_POSITION,
+) -> void:
+ if _active or not can_activate():
+ return
+ _active = true
+ _placing_grid = false
+ _eraser_mode = false
+ _clear_armed_guide_action(false)
+ _refresh_local_unlocks()
+ _rebuild_placement_preview()
+ _reset_stroke()
+ var initial_pointer_position: Vector2 = pointer_screen_position
+ if not _is_valid_pointer_screen_position(initial_pointer_position):
+ initial_pointer_position = get_viewport().get_mouse_position()
+ _pointer_screen_position = _clamped_pointer_position(
+ initial_pointer_position
+ )
+ _prior_mouse_mode = Input.mouse_mode
+ Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
+ _update_aim()
+ _refresh_stencil_visibility()
+ _emit_hud_state("marker selected")
+
+
+func deactivate() -> void:
+ if not _active:
+ return
+ _active = false
+ _placing_grid = false
+ _eraser_mode = false
+ _clear_armed_guide_action(false)
+ _camera_look_active = false
+ _reset_stroke()
+ _selected_canvas_id = ""
+ _aim_hit.clear()
+ _hide_previews()
+ _refresh_stencil_visibility()
+ Input.mouse_mode = _prior_mouse_mode
+ _emit_hud_state("")
+
+
+func is_active() -> bool:
+ return _active
+
+
+func is_placement_mode() -> bool:
+ return _placing_grid
+
+
+func can_activate() -> bool:
+ return (
+ _drawing_available()
+ and _owns_art_kit()
+ and _art_kit_is_selected()
+ )
+
+
+func set_placement_mode(enabled: bool) -> void:
+ _set_placement_mode(enabled)
+
+
+func set_color_id(color_id: StringName) -> bool:
+ var color_index: int = _color_ids.find(color_id)
+ if color_index < 0:
+ return false
+ _clear_armed_guide_action(true)
+ _eraser_mode = false
+ _color_index = color_index
+ _reset_stroke()
+ _emit_hud_state("")
+ return true
+
+
+func set_eraser_mode(enabled: bool) -> void:
+ if not _active:
+ return
+ _clear_armed_guide_action(true)
+ _eraser_mode = enabled
+ if _eraser_mode and _placing_grid:
+ _placing_grid = false
+ _reset_stroke()
+ _selected_canvas_id = ""
+ _update_aim()
+ _refresh_stencil_visibility()
+ _emit_hud_state("eraser selected" if _eraser_mode else "marker selected")
+
+
+func is_eraser_mode() -> bool:
+ return _eraser_mode
+
+
+func arm_guide_action(action: int) -> bool:
+ if (
+ not _active
+ or action not in [GuideAction.HIDE, GuideAction.RESTORE, GuideAction.FINALIZE]
+ ):
+ return false
+ if _armed_guide_action == action:
+ _cancel_armed_guide_action()
+ return false
+ if _armed_guide_action == GuideAction.NONE:
+ _armed_return_placement_mode = _placing_grid
+ _armed_return_eraser_mode = _eraser_mode
+ _armed_guide_action = action
+ _placing_grid = true
+ _eraser_mode = false
+ _reset_stroke()
+ _selected_canvas_id = ""
+ _update_aim()
+ _refresh_stencil_visibility()
+ _emit_hud_state(_guide_action_status(action))
+ return true
+
+
+func get_armed_guide_action() -> int:
+ return _armed_guide_action
+
+
+func cancel_armed_guide_action() -> void:
+ _cancel_armed_guide_action()
+
+
+func set_brush_size(value: int) -> bool:
+ if _art_unlocks == null or not _art_unlocks.is_brush_size_unlocked(value):
+ return false
+ _set_brush_size(value)
+ return true
+
+
+func set_grid_size(value: int) -> bool:
+ if _art_unlocks == null or not _art_unlocks.is_grid_size_unlocked(value):
+ return false
+ if _grid_size == value:
+ return true
+ _grid_size = value
+ _rebuild_placement_preview()
+ _update_previews()
+ _emit_hud_state("")
+ return true
+
+
+func get_brush_size() -> int:
+ return _brush_size
+
+
+func get_grid_size() -> int:
+ return _grid_size
+
+
+func get_color_id() -> StringName:
+ return _current_color_id()
+
+
+func get_pointer_screen_position() -> Vector2:
+ return _pointer_screen_position
+
+
+func set_unlocked_color_ids(unlocked_ids: Array[StringName]) -> void:
+ _color_ids = SurfaceDrawingPalette.filter_unlocked_ids(unlocked_ids)
+ _color_index = clampi(_color_index, 0, _color_ids.size() - 1)
+ _emit_hud_state("")
+
+
+func _refresh_local_unlocks() -> void:
+ if _art_unlocks == null:
+ _color_ids = [SurfaceDrawingPalette.DEFAULT_COLOR_ID]
+ return
+ var previous_color: StringName = _current_color_id()
+ _color_ids = _art_unlocks.get_unlocked_color_ids()
+ var previous_index: int = _color_ids.find(previous_color)
+ _color_index = previous_index if previous_index >= 0 else 0
+ if not _art_unlocks.is_brush_size_unlocked(_brush_size):
+ _brush_size = PlayerArtUnlocks.BASE_BRUSH_SIZE
+ if not _art_unlocks.is_grid_size_unlocked(_grid_size):
+ _grid_size = PlayerArtUnlocks.BASE_GRID_SIZE
+
+
+func _owns_art_kit() -> bool:
+ return _bag != null and _bag.owns_item(ArtShopStockType.ART_KIT_ITEM_ID)
+
+
+func _art_kit_is_selected() -> bool:
+ return (
+ _hotbar != null
+ and _hotbar.get_selected_assignment_kind()
+ == PlayerHotbar.AssignmentKind.ITEM
+ and _hotbar.get_selected_item_id() == ArtShopStockType.ART_KIT_ITEM_ID
+ )
+
+
+func _local_entitlement() -> Dictionary:
+ return {
+ "has_kit": _owns_art_kit(),
+ "unlock_mask": (
+ _art_unlocks.get_unlock_mask() if _art_unlocks != null else 0
+ ),
+ }
+
+
+func _publish_local_entitlement() -> void:
+ if _session == null or not _session.is_gameplay_session_active():
+ return
+ var entitlement: Dictionary = _local_entitlement()
+ if _session.is_host():
+ _peer_art_entitlements[_session.get_local_peer_id()] = entitlement
+ else:
+ submit_art_entitlement.rpc_id(
+ 1,
+ _session.get_session_id(),
+ bool(entitlement["has_kit"]),
+ int(entitlement["unlock_mask"]),
+ )
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func submit_art_entitlement(
+ session_id: String,
+ has_kit: bool,
+ unlock_mask: int,
+) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ or session_id != _session.get_session_id()
+ or unlock_mask < 0
+ or (unlock_mask & ~PlayerArtUnlocks.ALL_UNLOCK_MASK) != 0
+ ):
+ return
+ _peer_art_entitlements[sender_id] = {
+ "has_kit": has_kit,
+ "unlock_mask": unlock_mask,
+ }
+
+
+func _peer_entitlement(peer_id: int) -> Dictionary:
+ if _session != null and peer_id == _session.get_local_peer_id():
+ return _local_entitlement()
+ return Dictionary(_peer_art_entitlements.get(peer_id, {}))
+
+
+func _peer_owns_art_kit(peer_id: int) -> bool:
+ return bool(_peer_entitlement(peer_id).get("has_kit", false))
+
+
+func _peer_can_place_grid(peer_id: int, grid_size: int) -> bool:
+ var entitlement: Dictionary = _peer_entitlement(peer_id)
+ return (
+ bool(entitlement.get("has_kit", false))
+ and _mask_unlocks_grid(
+ int(entitlement.get("unlock_mask", 0)), grid_size
+ )
+ )
+
+
+func _peer_can_edit(peer_id: int, request: Dictionary) -> bool:
+ var entitlement: Dictionary = _peer_entitlement(peer_id)
+ if not bool(entitlement.get("has_kit", false)):
+ return false
+ var unlock_mask: int = int(entitlement.get("unlock_mask", 0))
+ if not _mask_unlocks_brush(unlock_mask, int(request["brush_size"])):
+ return false
+ for value: Variant in request["edits"]:
+ var edit: Dictionary = value
+ var color_id := StringName(str(edit.get("color_id", "")))
+ if not color_id.is_empty() and not _mask_unlocks_color(
+ unlock_mask, color_id
+ ):
+ return false
+ return true
+
+
+func _mask_unlocks_color(unlock_mask: int, color_id: StringName) -> bool:
+ if color_id == SurfaceDrawingPalette.DEFAULT_COLOR_ID:
+ return true
+ for product_id: StringName in PlayerArtUnlocks.COLOR_PRODUCTS:
+ if (
+ PlayerArtUnlocks.color_id_for_product(product_id) == color_id
+ and _mask_owns_product(unlock_mask, product_id)
+ ):
+ return true
+ return false
+
+
+func _mask_unlocks_brush(unlock_mask: int, brush_size: int) -> bool:
+ if brush_size == PlayerArtUnlocks.BASE_BRUSH_SIZE:
+ return true
+ for product_id: StringName in PlayerArtUnlocks.BRUSH_PRODUCTS:
+ if (
+ PlayerArtUnlocks.brush_size_for_product(product_id) == brush_size
+ and _mask_owns_product(unlock_mask, product_id)
+ ):
+ return true
+ return false
+
+
+func _mask_unlocks_grid(unlock_mask: int, grid_size: int) -> bool:
+ if grid_size == PlayerArtUnlocks.BASE_GRID_SIZE:
+ return true
+ for product_id: StringName in PlayerArtUnlocks.GRID_PRODUCTS:
+ if (
+ PlayerArtUnlocks.grid_size_for_product(product_id) == grid_size
+ and _mask_owns_product(unlock_mask, product_id)
+ ):
+ return true
+ return false
+
+
+func _mask_owns_product(unlock_mask: int, product_id: StringName) -> bool:
+ var bit: int = PlayerArtUnlocks.get_product_bit(product_id)
+ return bit >= 0 and (unlock_mask & (1 << bit)) != 0
+
+
+func request_canvas_at_surface(
+ origin: Vector3,
+ normal: Vector3,
+ tangent: Vector3,
+) -> bool:
+ if (
+ not can_activate()
+ or _art_unlocks == null
+ or not _art_unlocks.is_grid_size_unlocked(_grid_size)
+ or normal.is_zero_approx()
+ or tangent.is_zero_approx()
+ ):
+ return false
+ var data: Dictionary = {
+ "request_id": _new_request_id("canvas"),
+ "session_id": _session.get_session_id(),
+ "origin": SurfaceDrawingProtocol.vector_to_array(origin),
+ "normal": SurfaceDrawingProtocol.vector_to_array(normal.normalized()),
+ "tangent": SurfaceDrawingProtocol.vector_to_array(tangent.normalized()),
+ "width": _grid_size,
+ "height": _grid_size,
+ "cell_size": SurfaceDrawingProtocol.CELL_SIZE,
+ }
+ if _session.is_host():
+ _handle_canvas_request(_session.get_local_peer_id(), data)
+ else:
+ submit_canvas_request.rpc_id(1, data)
+ return true
+
+
+func request_cell_edits(
+ canvas_id: String,
+ edits: Array[Dictionary],
+ stroke_id: String = "",
+) -> bool:
+ if (
+ not can_activate()
+ or canvas_id.is_empty()
+ or edits.is_empty()
+ ):
+ return false
+ var resolved_stroke_id: String = stroke_id
+ if resolved_stroke_id.is_empty():
+ resolved_stroke_id = _new_request_id("stroke")
+ _last_local_stroke_id = resolved_stroke_id
+ var data: Dictionary = {
+ "request_id": _new_request_id("edit"),
+ "session_id": _session.get_session_id(),
+ "canvas_id": canvas_id,
+ "stroke_id": resolved_stroke_id,
+ "brush_size": _brush_size,
+ "edits": edits,
+ }
+ if not SurfaceDrawingProtocol.validate_edit_request(data):
+ return false
+ if _session.is_host():
+ _handle_edit_request(_session.get_local_peer_id(), data)
+ else:
+ submit_edit_request.rpc_id(1, data)
+ return true
+
+
+func request_guide_visibility(
+ canvas_id: String,
+ should_be_visible: bool,
+ should_finalize: bool = false,
+) -> bool:
+ if not can_activate() or canvas_id.is_empty():
+ return false
+ var data: Dictionary = {
+ "request_id": _new_request_id("guide"),
+ "session_id": _session.get_session_id(),
+ "canvas_id": canvas_id,
+ "guide_visible": should_be_visible and not should_finalize,
+ "finalized": should_finalize,
+ }
+ if not SurfaceDrawingProtocol.validate_guide_request(data):
+ return false
+ if _session.is_host():
+ _handle_guide_request(_session.get_local_peer_id(), data)
+ else:
+ submit_guide_request.rpc_id(1, data)
+ return true
+
+
+func request_undo_last_stroke() -> bool:
+ if (
+ not can_activate()
+ or _last_local_stroke_id.is_empty()
+ ):
+ _emit_hud_state("nothing to undo")
+ return false
+ var data: Dictionary = {
+ "request_id": _new_request_id("undo"),
+ "session_id": _session.get_session_id(),
+ "stroke_id": _last_local_stroke_id,
+ }
+ if not SurfaceDrawingProtocol.validate_undo_request(data):
+ return false
+ _last_local_stroke_id = ""
+ if _session.is_host():
+ _handle_undo_request(_session.get_local_peer_id(), data)
+ else:
+ submit_undo_request.rpc_id(1, data)
+ _emit_hud_state("undoing last stroke...")
+ return true
+
+
+func get_canvas_count() -> int:
+ return _canvas_states.size()
+
+
+func get_painted_cell_count() -> int:
+ var count: int = 0
+ for state: Dictionary in _canvas_states.values():
+ count += (state.get("cells", []) as Array).size()
+ return count
+
+
+func clear_session_artwork() -> bool:
+ if not _drawing_available() or not _session.is_host():
+ return false
+ _clear_session_artwork_state()
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if (
+ peer_id != _session.get_local_peer_id()
+ and _session.peer_supports_capability(
+ peer_id, SurfaceDrawingProtocol.CAPABILITY
+ )
+ ):
+ receive_session_artwork_reset.rpc_id(
+ peer_id, _session.get_session_id()
+ )
+ return true
+
+
+func get_canvas_ids() -> Array[String]:
+ var result: Array[String] = []
+ for canvas_id: String in _canvas_states:
+ result.append(canvas_id)
+ result.sort()
+ return result
+
+
+func get_canvas_state(canvas_id: String) -> Dictionary:
+ return Dictionary(_canvas_states.get(canvas_id, {})).duplicate(true)
+
+
+func _process(_delta: float) -> void:
+ if not _active:
+ return
+ if not can_activate():
+ deactivate()
+ return
+ _update_aim()
+ if _placing_grid:
+ return
+ if _painting:
+ _submit_current_brush(false)
+ elif _erasing:
+ _submit_current_brush(true)
+
+
+func _drawing_available() -> bool:
+ return (
+ _session != null
+ and _session.is_gameplay_session_active()
+ and (
+ _session.is_host()
+ or _session.supports_server_capability(
+ SurfaceDrawingProtocol.CAPABILITY
+ )
+ )
+ )
+
+
+func _update_aim() -> void:
+ _aim_hit = _raycast_from_pointer()
+ var previous_canvas_id: String = _selected_canvas_id
+ var found_canvas_id: String = ""
+ var nearest_plane_distance: float = INF
+ if not _aim_hit.is_empty():
+ var hit_position: Vector3 = _aim_hit["position"]
+ for canvas_id: String in _canvas_nodes:
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes[canvas_id]
+ if canvas.is_finalized():
+ continue
+ var plane_distance: float = canvas.get_surface_plane_distance(
+ hit_position
+ )
+ if (
+ canvas.contains_world_point(
+ hit_position,
+ SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05,
+ )
+ and plane_distance < nearest_plane_distance
+ ):
+ found_canvas_id = canvas_id
+ nearest_plane_distance = plane_distance
+ _selected_canvas_id = found_canvas_id
+ if previous_canvas_id != _selected_canvas_id:
+ _reset_stroke()
+ _update_previews()
+
+
+func _raycast_from_pointer() -> Dictionary:
+ if _local_player == null or not is_instance_valid(_local_player):
+ return {}
+ var camera: Camera3D = _local_player.get_active_gameplay_camera()
+ if camera == null or not camera.current:
+ return {}
+ var from: Vector3 = camera.project_ray_origin(_pointer_screen_position)
+ var direction: Vector3 = camera.project_ray_normal(
+ _pointer_screen_position
+ ).normalized()
+ var to: Vector3 = from + direction * MAX_DRAW_DISTANCE
+ var query := PhysicsRayQueryParameters3D.create(
+ from,
+ to,
+ SOLID_SURFACE_MASK,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ query.exclude = [_local_player.get_rid()]
+ var hit: Dictionary = camera.get_world_3d().direct_space_state.intersect_ray(query)
+ if hit.is_empty() or not _is_static_surface(hit.get("collider")):
+ return {}
+ return hit
+
+
+func _update_previews() -> void:
+ if _placing_grid:
+ _update_placement_preview()
+ _hide_brush_preview()
+ return
+ if _placement_preview != null:
+ _placement_preview.hide()
+ _update_brush_preview()
+
+
+func _update_placement_preview() -> void:
+ if _placement_preview == null or _aim_hit.is_empty():
+ if _placement_preview != null:
+ _placement_preview.hide()
+ return
+ var placement: Dictionary = _resolved_placement()
+ var normal: Vector3 = placement["normal"]
+ var hit_position: Vector3 = placement["origin"]
+ var tangent: Vector3 = placement["tangent"]
+ var bitangent: Vector3 = normal.cross(tangent).normalized()
+ _placement_preview.global_transform = Transform3D(
+ Basis(tangent, bitangent, normal),
+ hit_position + normal * GRID_PREVIEW_SURFACE_OFFSET,
+ )
+ if _placement_preview_material != null:
+ _placement_preview_material.albedo_color = (
+ Color(0.34, 1.0, 0.72, 0.88)
+ if bool(placement["snapped"])
+ else Color(0.46, 0.91, 0.95, 0.72)
+ )
+ _placement_preview.show()
+
+
+func _update_brush_preview() -> void:
+ if (
+ _brush_preview == null
+ or _brush_preview_multimesh == null
+ or _aim_hit.is_empty()
+ or _selected_canvas_id.is_empty()
+ ):
+ _hide_brush_preview()
+ return
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(
+ _selected_canvas_id
+ )
+ if canvas == null:
+ _hide_brush_preview()
+ return
+ var center: Vector2i = canvas.cell_at_world_point(_aim_hit["position"])
+ var cells: Array[Vector2i] = _brush_cells(canvas, center)
+ if cells.is_empty():
+ _hide_brush_preview()
+ return
+ var root_inverse: Transform3D = _drawing_root.global_transform.affine_inverse()
+ for index: int in range(cells.size()):
+ var cell: Vector2i = cells[index]
+ var world_transform: Transform3D = canvas.get_cell_surface_transform(
+ cell.x, cell.y, 0.94
+ )
+ _brush_preview_multimesh.set_instance_transform(
+ index, root_inverse * world_transform
+ )
+ _brush_preview_multimesh.visible_instance_count = cells.size()
+ _brush_preview.show()
+
+
+func _hide_brush_preview() -> void:
+ if _brush_preview_multimesh != null:
+ _brush_preview_multimesh.visible_instance_count = 0
+ if _brush_preview != null:
+ _brush_preview.hide()
+
+
+func _hide_previews() -> void:
+ _hide_brush_preview()
+ if _placement_preview != null:
+ _placement_preview.hide()
+
+
+func _resolved_placement() -> Dictionary:
+ var normal: Vector3 = _normalized_or(
+ _aim_hit.get("normal", Vector3.UP), Vector3.UP
+ )
+ var states: Array[Dictionary] = []
+ for state: Dictionary in _canvas_states.values():
+ states.append(state)
+ return SurfaceDrawingPlacement.resolve(
+ _aim_hit.get("position", Vector3.ZERO),
+ normal,
+ _surface_tangent(normal),
+ states,
+ _grid_size,
+ )
+
+
+func _request_canvas_at_aim() -> void:
+ if _aim_hit.is_empty():
+ _emit_hud_state("aim at a solid surface before placing a grid")
+ return
+ var placement: Dictionary = _resolved_placement()
+ var normal: Vector3 = placement["normal"]
+ var origin: Vector3 = placement["origin"]
+ if not _selected_canvas_id.is_empty():
+ var existing_state: Dictionary = _canvas_states.get(
+ _selected_canvas_id, {}
+ )
+ if not existing_state.is_empty():
+ if not bool(existing_state.get("guide_visible", true)):
+ if request_guide_visibility(_selected_canvas_id, true):
+ if not _session.is_host():
+ _emit_hud_state("restoring grid guide...")
+ else:
+ _emit_hud_state("a shared grid already covers this area")
+ return
+ if _overlaps_existing_canvas(origin, normal, _grid_size):
+ _emit_hud_state("a shared grid already covers this area")
+ return
+ if request_canvas_at_surface(origin, normal, placement["tangent"]):
+ _emit_hud_state(
+ "placing snapped shared grid..."
+ if bool(placement["snapped"])
+ else "placing shared grid..."
+ )
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func submit_canvas_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_canvas_request(sender_id, data)
+
+
+func _handle_canvas_request(peer_id: int, data: Dictionary) -> void:
+ var requested_size: int = int(data.get("width", 0))
+ if (
+ not _session.is_host()
+ or not SurfaceDrawingProtocol.validate_canvas_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or not _accept_request(peer_id, str(data["request_id"]))
+ or not _peer_can_place_grid(peer_id, requested_size)
+ or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES
+ or _active_canvas_count() >= SurfaceDrawingProtocol.MAX_ACTIVE_CANVASES
+ or _allocated_grid_cells() + requested_size * requested_size
+ > SurfaceDrawingProtocol.MAX_SESSION_GRID_CELLS
+ ):
+ return
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ var requested_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ data["origin"]
+ )
+ if (
+ avatar == null
+ or avatar.global_position.distance_to(requested_origin)
+ > MAX_DRAW_DISTANCE + 2.0
+ ):
+ return
+ var requested_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ data["normal"]
+ ).normalized()
+ var surface_hit: Dictionary = _validate_surface(
+ requested_origin, requested_normal
+ )
+ if surface_hit.is_empty():
+ return
+ var normal: Vector3 = _normalized_or(
+ surface_hit.get("normal", requested_normal), requested_normal
+ )
+ if _overlaps_existing_canvas(
+ surface_hit["position"], normal, requested_size
+ ):
+ return
+ var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ data["tangent"]
+ )
+ tangent = (tangent - normal * tangent.dot(normal)).normalized()
+ if tangent.is_zero_approx():
+ tangent = _surface_tangent(normal)
+ _canvas_sequence += 1
+ var canvas_id: String = "%s-%d" % [
+ _session.get_session_id().left(16), _canvas_sequence,
+ ]
+ var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
+ if record == null or not record.identity_authenticated:
+ return
+ var state: Dictionary = {
+ "session_id": _session.get_session_id(),
+ "canvas_id": canvas_id,
+ "origin": SurfaceDrawingProtocol.vector_to_array(
+ surface_hit["position"]
+ ),
+ "normal": SurfaceDrawingProtocol.vector_to_array(normal),
+ "tangent": SurfaceDrawingProtocol.vector_to_array(tangent),
+ "width": requested_size,
+ "height": requested_size,
+ "cell_size": SurfaceDrawingProtocol.CELL_SIZE,
+ "revision": 0,
+ "guide_visible": true,
+ "finalized": false,
+ "layer": _canvas_sequence,
+ "creator_fingerprint": record.identity_fingerprint,
+ "cells": [],
+ }
+ _apply_canvas_state(state)
+ _broadcast_canvas_state(state)
+ _emit_hud_state("shared grid placed • everyone can draw here")
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func submit_guide_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_guide_request(sender_id, data)
+
+
+func _handle_guide_request(peer_id: int, data: Dictionary) -> void:
+ if (
+ not _session.is_host()
+ or not SurfaceDrawingProtocol.validate_guide_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or not _accept_request(peer_id, str(data["request_id"]))
+ or not _peer_owns_art_kit(peer_id)
+ ):
+ return
+ var canvas_id: String = str(data["canvas_id"])
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ if state.is_empty() or avatar == null:
+ return
+ var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"])
+ if avatar.global_position.distance_to(origin) > MAX_DRAW_DISTANCE + 2.0:
+ return
+ var was_finalized: bool = bool(state.get("finalized", false))
+ var finalized: bool = bool(data["finalized"])
+ if was_finalized:
+ return
+ var guide_visible: bool = bool(data["guide_visible"]) and not finalized
+ if (
+ bool(state.get("guide_visible", true)) == guide_visible
+ and was_finalized == finalized
+ ):
+ return
+ state["revision"] = int(state["revision"]) + 1
+ state["guide_visible"] = guide_visible
+ state["finalized"] = finalized
+ _canvas_states[canvas_id] = state
+ var update: Dictionary = {
+ "session_id": _session.get_session_id(),
+ "canvas_id": canvas_id,
+ "revision": int(state["revision"]),
+ "guide_visible": guide_visible,
+ "finalized": finalized,
+ }
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
+ if canvas != null:
+ canvas.apply_guide_update(update)
+ _broadcast_guide_update(update)
+ _emit_hud_state(
+ "grid finalized • artwork remains editable through new grids"
+ if finalized
+ else "grid guide restored" if guide_visible else "grid guide hidden"
+ )
+ _emit_artwork_changed()
+
+
+func _submit_current_brush(erasing: bool, force: bool = false) -> void:
+ if _selected_canvas_id.is_empty() or _aim_hit.is_empty():
+ return
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(
+ _selected_canvas_id
+ )
+ if canvas == null:
+ return
+ var center: Vector2i = canvas.cell_at_world_point(_aim_hit["position"])
+ if center.x < 0:
+ return
+ var stroke_key := Vector3i(
+ _selected_canvas_id.hash(), center.x, center.y
+ )
+ var now: int = Time.get_ticks_msec()
+ if (
+ not force
+ and (
+ stroke_key == _last_edited_cell
+ or now - _last_edit_msec < EDIT_INTERVAL_MSEC
+ )
+ ):
+ return
+ var edits: Array[Dictionary] = []
+ for cell: Vector2i in _brush_cells(canvas, center):
+ edits.append({
+ "x": cell.x,
+ "y": cell.y,
+ "color_id": "" if erasing else str(_current_color_id()),
+ })
+ if edits.is_empty():
+ return
+ _last_edited_cell = stroke_key
+ _last_edit_msec = now
+ if _active_stroke_id.is_empty():
+ _begin_stroke()
+ request_cell_edits(_selected_canvas_id, edits, _active_stroke_id)
+
+
+func _brush_cells(
+ canvas: SurfaceDrawingCanvas,
+ center: Vector2i,
+) -> Array[Vector2i]:
+ var result: Array[Vector2i] = []
+ if canvas == null or center.x < 0 or center.y < 0:
+ return result
+ var start_offset: int = -floori(float(_brush_size - 1) * 0.5)
+ for y_offset: int in range(start_offset, start_offset + _brush_size):
+ for x_offset: int in range(start_offset, start_offset + _brush_size):
+ var cell := Vector2i(center.x + x_offset, center.y + y_offset)
+ if (
+ cell.x < 0
+ or cell.x >= canvas.grid_width
+ or cell.y < 0
+ or cell.y >= canvas.grid_height
+ ):
+ continue
+ result.append(cell)
+ return result
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func submit_edit_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_edit_request(sender_id, data)
+
+
+func _handle_edit_request(peer_id: int, data: Dictionary) -> void:
+ if (
+ not _session.is_host()
+ or not SurfaceDrawingProtocol.validate_edit_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or not _accept_request(peer_id, str(data["request_id"]))
+ or not _peer_can_edit(peer_id, data)
+ ):
+ return
+ var canvas_id: String = str(data["canvas_id"])
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ var avatar: Player = _spawn_service.get_avatar(peer_id)
+ var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
+ if (
+ state.is_empty()
+ or bool(state.get("finalized", false))
+ or avatar == null
+ or record == null
+ ):
+ return
+ var stroke_id: String = str(data["stroke_id"])
+ var grid_width: int = int(state["width"])
+ var grid_height: int = int(state["height"])
+ var mutations: Dictionary[String, Dictionary] = {}
+ for edit_value: Variant in data["edits"]:
+ var edit: Dictionary = edit_value
+ var x: int = int(edit["x"])
+ var y: int = int(edit["y"])
+ if x >= grid_width or y >= grid_height:
+ continue
+ var cell_position: Vector3 = _state_cell_position(
+ state, x, y
+ )
+ if (
+ avatar.global_position.distance_to(cell_position)
+ > MAX_DRAW_DISTANCE + 2.0
+ or _validate_surface(
+ cell_position,
+ SurfaceDrawingProtocol.array_to_vector(state["normal"]),
+ ).is_empty()
+ ):
+ continue
+ var color_id := StringName(str(edit["color_id"]))
+ var authoritative: Dictionary = {
+ "x": x,
+ "y": y,
+ "color_id": str(color_id),
+ "author_fingerprint": (
+ record.identity_fingerprint if not color_id.is_empty() else ""
+ ),
+ }
+ _queue_cell_mutation(
+ mutations,
+ canvas_id,
+ authoritative,
+ peer_id,
+ stroke_id,
+ )
+ _queue_overlapping_finished_mutations(
+ mutations,
+ canvas_id,
+ cell_position,
+ peer_id,
+ stroke_id,
+ )
+ if mutations.is_empty():
+ return
+ _publish_cell_mutations(mutations)
+
+
+func _queue_cell_mutation(
+ mutations: Dictionary[String, Dictionary],
+ canvas_id: String,
+ edit: Dictionary,
+ peer_id: int,
+ stroke_id: String,
+) -> void:
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ if state.is_empty():
+ return
+ var x: int = int(edit["x"])
+ var y: int = int(edit["y"])
+ var cell_key: int = _cell_key_for_state(state, x, y)
+ var canvas_mutations: Dictionary = mutations.get(
+ canvas_id, {}
+ )
+ var current: Dictionary = (
+ _cell_value_from_edit(canvas_mutations[cell_key])
+ if canvas_mutations.has(cell_key)
+ else _state_cell(state, x, y)
+ )
+ var next: Dictionary = _cell_value_from_edit(edit)
+ if _same_cell_value(current, next):
+ return
+ _record_stroke_change(
+ peer_id, stroke_id, canvas_id, x, y, current, next
+ )
+ canvas_mutations[cell_key] = edit.duplicate(true)
+ mutations[canvas_id] = canvas_mutations
+ _cell_last_stroke[_mutation_key(canvas_id, x, y)] = stroke_id
+
+
+func _queue_overlapping_finished_mutations(
+ mutations: Dictionary[String, Dictionary],
+ target_canvas_id: String,
+ world_position: Vector3,
+ peer_id: int,
+ stroke_id: String,
+) -> void:
+ var target_state: Dictionary = _canvas_states.get(target_canvas_id, {})
+ if target_state.is_empty():
+ return
+ var target_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ target_state["normal"]
+ ).normalized()
+ for canvas_id: String in _canvas_states:
+ if canvas_id == target_canvas_id:
+ continue
+ var state: Dictionary = _canvas_states[canvas_id]
+ if not bool(state.get("finalized", false)):
+ continue
+ var normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["normal"]
+ ).normalized()
+ if normal.dot(target_normal) < 0.9:
+ continue
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
+ if (
+ canvas == null
+ or not canvas.contains_world_point(
+ world_position,
+ SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05,
+ )
+ ):
+ continue
+ var center_cell: Vector2i = canvas.cell_at_world_point(world_position)
+ if center_cell.x < 0:
+ continue
+ for y_offset: int in range(-1, 2):
+ for x_offset: int in range(-1, 2):
+ var cell := Vector2i(
+ center_cell.x + x_offset,
+ center_cell.y + y_offset,
+ )
+ if (
+ cell.x < 0
+ or cell.x >= canvas.grid_width
+ or cell.y < 0
+ or cell.y >= canvas.grid_height
+ ):
+ continue
+ var relative: Vector3 = (
+ canvas.get_cell_world_position(cell.x, cell.y)
+ - world_position
+ )
+ if (
+ absf(relative.dot(canvas.get_surface_tangent()))
+ >= SurfaceDrawingProtocol.CELL_SIZE
+ or absf(relative.dot(canvas.get_surface_bitangent()))
+ >= SurfaceDrawingProtocol.CELL_SIZE
+ or _state_cell(state, cell.x, cell.y).is_empty()
+ ):
+ continue
+ _queue_cell_mutation(
+ mutations,
+ canvas_id,
+ {
+ "x": cell.x,
+ "y": cell.y,
+ "color_id": "",
+ "author_fingerprint": "",
+ },
+ peer_id,
+ stroke_id,
+ )
+
+
+func _record_stroke_change(
+ peer_id: int,
+ stroke_id: String,
+ canvas_id: String,
+ x: int,
+ y: int,
+ before: Dictionary,
+ after: Dictionary,
+) -> void:
+ var history: Dictionary = _stroke_history_by_peer.get(peer_id, {})
+ if str(history.get("stroke_id", "")) != stroke_id:
+ history = {"stroke_id": stroke_id, "changes": {}}
+ var changes: Dictionary = history["changes"]
+ var key: String = _mutation_key(canvas_id, x, y)
+ var change: Dictionary = changes.get(key, {})
+ if change.is_empty():
+ change = {
+ "canvas_id": canvas_id,
+ "x": x,
+ "y": y,
+ "before": before.duplicate(true),
+ "before_stroke": str(_cell_last_stroke.get(key, "")),
+ }
+ change["after"] = after.duplicate(true)
+ changes[key] = change
+ history["changes"] = changes
+ _stroke_history_by_peer[peer_id] = history
+
+
+func _publish_cell_mutations(
+ mutations: Dictionary[String, Dictionary],
+) -> void:
+ var canvas_ids: Array[String] = mutations.keys()
+ canvas_ids.sort()
+ for canvas_id: String in canvas_ids:
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ if state.is_empty():
+ continue
+ var cells: Dictionary[int, Dictionary] = _cells_by_key(
+ state["cells"], int(state["width"])
+ )
+ var canvas_mutations: Dictionary = mutations[canvas_id]
+ var cell_keys: Array[int] = []
+ for cell_key_value: Variant in canvas_mutations.keys():
+ cell_keys.append(int(cell_key_value))
+ cell_keys.sort()
+ var edits: Array[Dictionary] = []
+ for cell_key: int in cell_keys:
+ var edit: Dictionary = canvas_mutations[cell_key]
+ if str(edit["color_id"]).is_empty():
+ cells.erase(cell_key)
+ else:
+ cells[cell_key] = edit.duplicate(true)
+ edits.append(edit.duplicate(true))
+ if edits.is_empty():
+ continue
+ state["revision"] = int(state["revision"]) + 1
+ state["cells"] = _sorted_cells(cells)
+ _canvas_states[canvas_id] = state
+ var update: Dictionary = {
+ "session_id": _session.get_session_id(),
+ "canvas_id": canvas_id,
+ "revision": int(state["revision"]),
+ "edits": edits,
+ }
+ var local_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
+ if local_canvas != null:
+ local_canvas.apply_update(update)
+ _broadcast_canvas_update(update)
+ _emit_artwork_changed()
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func submit_undo_request(data: Dictionary) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if _session.is_host() and _session.is_authenticated_peer(sender_id):
+ _handle_undo_request(sender_id, data)
+
+
+func _handle_undo_request(peer_id: int, data: Dictionary) -> void:
+ if (
+ not _session.is_host()
+ or not SurfaceDrawingProtocol.validate_undo_request(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ or not _accept_request(peer_id, str(data["request_id"]))
+ or not _peer_owns_art_kit(peer_id)
+ ):
+ return
+ var stroke_id: String = str(data["stroke_id"])
+ var history: Dictionary = _stroke_history_by_peer.get(peer_id, {})
+ var mutations: Dictionary[String, Dictionary] = {}
+ var restored_count: int = 0
+ if str(history.get("stroke_id", "")) == stroke_id:
+ var changes: Dictionary = history.get("changes", {})
+ for mutation_key: String in changes:
+ if str(_cell_last_stroke.get(mutation_key, "")) != stroke_id:
+ continue
+ var change: Dictionary = changes[mutation_key]
+ var canvas_id: String = str(change["canvas_id"])
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ if state.is_empty():
+ continue
+ var x: int = int(change["x"])
+ var y: int = int(change["y"])
+ var current: Dictionary = _state_cell(state, x, y)
+ if not _same_cell_value(current, change.get("after", {})):
+ continue
+ var before: Dictionary = change.get("before", {})
+ var restored: Dictionary = _authoritative_edit_from_value(
+ x, y, before
+ )
+ var canvas_mutations: Dictionary = mutations.get(
+ canvas_id, {}
+ )
+ canvas_mutations[_cell_key_for_state(state, x, y)] = restored
+ mutations[canvas_id] = canvas_mutations
+ var previous_stroke: String = str(
+ change.get("before_stroke", "")
+ )
+ if previous_stroke.is_empty():
+ _cell_last_stroke.erase(mutation_key)
+ else:
+ _cell_last_stroke[mutation_key] = previous_stroke
+ restored_count += 1
+ _stroke_history_by_peer.erase(peer_id)
+ if not mutations.is_empty():
+ _publish_cell_mutations(mutations)
+ _send_undo_result(peer_id, restored_count)
+
+
+func _send_undo_result(peer_id: int, restored_count: int) -> void:
+ if peer_id == _session.get_local_peer_id():
+ _receive_undo_result(restored_count)
+ else:
+ receive_undo_result.rpc_id(peer_id, restored_count)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func receive_undo_result(restored_count: int) -> void:
+ _receive_undo_result(restored_count)
+
+
+func _receive_undo_result(restored_count: int) -> void:
+ _emit_hud_state(
+ "last stroke undone"
+ if restored_count > 0
+ else "last stroke changed elsewhere and could not be undone"
+ )
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func receive_canvas_state(data: Dictionary) -> void:
+ _apply_canvas_state(data)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func receive_canvas_update(data: Dictionary) -> void:
+ _apply_canvas_update(data)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func receive_guide_update(data: Dictionary) -> void:
+ _apply_guide_update(data)
+
+
+@rpc(
+ "authority",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func receive_session_artwork_reset(session_id: String) -> void:
+ if session_id == _session.get_session_id():
+ _clear_session_artwork_state()
+
+
+@rpc(
+ "any_peer",
+ "call_remote",
+ "reliable",
+ SurfaceDrawingProtocol.RELIABLE_CHANNEL,
+)
+func request_canvas_snapshot(request_id: String) -> void:
+ var sender_id: int = multiplayer.get_remote_sender_id()
+ if (
+ not _session.is_host()
+ or not _session.is_authenticated_peer(sender_id)
+ or request_id.is_empty()
+ or request_id.length() > SurfaceDrawingProtocol.MAX_REQUEST_ID_LENGTH
+ or not _accept_request(sender_id, request_id)
+ or not _session.peer_supports_capability(
+ sender_id, SurfaceDrawingProtocol.CAPABILITY
+ )
+ ):
+ return
+ for state: Dictionary in _canvas_states.values():
+ receive_canvas_state.rpc_id(sender_id, state)
+
+
+func _apply_canvas_state(data: Dictionary) -> void:
+ if (
+ not SurfaceDrawingProtocol.validate_canvas_state(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ var canvas_id: String = str(data["canvas_id"])
+ var previous_state: Dictionary = _canvas_states.get(canvas_id, {})
+ if (
+ not previous_state.is_empty()
+ and int(data["revision"]) <= int(previous_state["revision"])
+ ):
+ return
+ _canvas_states[canvas_id] = data.duplicate(true)
+ var previous_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
+ if previous_canvas != null:
+ previous_canvas.queue_free()
+ var canvas := SurfaceDrawingCanvas.new()
+ canvas.name = "Drawing_%s" % canvas_id
+ _drawing_root.add_child(canvas)
+ if not canvas.setup(data, _relationships, SOLID_SURFACE_MASK):
+ canvas.queue_free()
+ return
+ _canvas_nodes[canvas_id] = canvas
+ _refresh_stencil_visibility()
+ _emit_artwork_changed()
+ if (
+ _active
+ and str(data.get("creator_fingerprint", ""))
+ == _session.get_local_identity_fingerprint()
+ ):
+ _emit_hud_state("shared grid placed • move and click to place another")
+
+
+func _apply_canvas_update(data: Dictionary) -> void:
+ if (
+ not SurfaceDrawingProtocol.validate_canvas_update(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ var canvas_id: String = str(data["canvas_id"])
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
+ if state.is_empty() or canvas == null or int(data["revision"]) <= int(state["revision"]):
+ return
+ var cells: Dictionary[int, Dictionary] = _cells_by_key(
+ state["cells"], int(state["width"])
+ )
+ var grid_width: int = int(state["width"])
+ var grid_height: int = int(state["height"])
+ for edit_value: Variant in data["edits"]:
+ var edit: Dictionary = edit_value
+ var x: int = int(edit["x"])
+ var y: int = int(edit["y"])
+ if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
+ return
+ var key: int = y * grid_width + x
+ if str(edit["color_id"]).is_empty():
+ cells.erase(key)
+ else:
+ cells[key] = edit.duplicate(true)
+ state["revision"] = int(data["revision"])
+ state["cells"] = _sorted_cells(cells)
+ _canvas_states[canvas_id] = state
+ canvas.apply_update(data)
+ _emit_artwork_changed()
+
+
+func _apply_guide_update(data: Dictionary) -> void:
+ if (
+ not SurfaceDrawingProtocol.validate_guide_update(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ var canvas_id: String = str(data["canvas_id"])
+ var state: Dictionary = _canvas_states.get(canvas_id, {})
+ var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
+ if (
+ state.is_empty()
+ or canvas == null
+ or int(data["revision"]) <= int(state["revision"])
+ ):
+ return
+ state["revision"] = int(data["revision"])
+ state["finalized"] = bool(data["finalized"])
+ state["guide_visible"] = (
+ bool(data["guide_visible"]) and not bool(data["finalized"])
+ )
+ _canvas_states[canvas_id] = state
+ canvas.apply_guide_update(data)
+ if _active:
+ _emit_hud_state(
+ "grid finalized • new grids may overlap its artwork"
+ if bool(data["finalized"])
+ else "grid guide restored"
+ if bool(data["guide_visible"])
+ else "grid guide hidden • pixels now meet edge-to-edge"
+ )
+ _emit_artwork_changed()
+
+
+func _broadcast_canvas_state(data: Dictionary) -> void:
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if peer_id == _session.get_local_peer_id():
+ continue
+ if _session.peer_supports_capability(
+ peer_id, SurfaceDrawingProtocol.CAPABILITY
+ ):
+ receive_canvas_state.rpc_id(peer_id, data)
+
+
+func _broadcast_canvas_update(data: Dictionary) -> void:
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if peer_id == _session.get_local_peer_id():
+ continue
+ if _session.peer_supports_capability(
+ peer_id, SurfaceDrawingProtocol.CAPABILITY
+ ):
+ receive_canvas_update.rpc_id(peer_id, data)
+
+
+func _broadcast_guide_update(data: Dictionary) -> void:
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if peer_id == _session.get_local_peer_id():
+ continue
+ if _session.peer_supports_capability(
+ peer_id, SurfaceDrawingProtocol.CAPABILITY
+ ):
+ receive_guide_update.rpc_id(peer_id, data)
+
+
+func _validate_surface(origin: Vector3, normal: Vector3) -> Dictionary:
+ if _drawing_root == null or normal.is_zero_approx():
+ return {}
+ var world: World3D = _drawing_root.get_world_3d()
+ if world == null:
+ return {}
+ var direction: Vector3 = normal.normalized()
+ var query := PhysicsRayQueryParameters3D.create(
+ origin + direction * SURFACE_VALIDATION_DEPTH,
+ origin - direction * SURFACE_VALIDATION_DEPTH,
+ SOLID_SURFACE_MASK,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ var hit: Dictionary = world.direct_space_state.intersect_ray(query)
+ if hit.is_empty() or not _is_static_surface(hit.get("collider")):
+ return {}
+ var hit_normal: Vector3 = _normalized_or(
+ hit.get("normal", direction), direction
+ )
+ if absf(hit_normal.dot(direction)) < MIN_SURFACE_NORMAL_DOT:
+ return {}
+ return hit
+
+
+func _is_static_surface(value: Variant) -> bool:
+ return value is StaticBody3D
+
+
+func _surface_tangent(normal: Vector3) -> Vector3:
+ var camera: Camera3D = (
+ _local_player.get_active_gameplay_camera()
+ if _local_player != null
+ else null
+ )
+ var candidate: Vector3 = (
+ camera.global_basis.x if camera != null else Vector3.RIGHT
+ )
+ candidate = (candidate - normal * candidate.dot(normal)).normalized()
+ if candidate.is_zero_approx():
+ candidate = Vector3.UP.cross(normal).normalized()
+ if candidate.is_zero_approx():
+ candidate = Vector3.RIGHT
+ return candidate
+
+
+func _state_cell_position(state: Dictionary, x: int, y: int) -> Vector3:
+ var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"])
+ var normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["normal"]
+ ).normalized()
+ var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["tangent"]
+ ).normalized()
+ var bitangent: Vector3 = normal.cross(tangent).normalized()
+ var size: float = float(state["cell_size"])
+ return (
+ origin
+ + tangent * (float(x) + 0.5 - float(state["width"]) * 0.5) * size
+ + bitangent * (float(y) + 0.5 - float(state["height"]) * 0.5) * size
+ )
+
+
+func _overlaps_existing_canvas(
+ origin: Vector3,
+ normal: Vector3,
+ requested_size: int,
+) -> bool:
+ for state: Dictionary in _canvas_states.values():
+ if bool(state.get("finalized", false)):
+ continue
+ var existing_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["origin"]
+ )
+ var existing_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["normal"]
+ ).normalized()
+ if absf(normal.dot(existing_normal)) < 0.85:
+ continue
+ var relative: Vector3 = origin - existing_origin
+ if absf(relative.dot(existing_normal)) > SURFACE_VALIDATION_DEPTH:
+ continue
+ var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector(
+ state["tangent"]
+ ).normalized()
+ var bitangent: Vector3 = existing_normal.cross(tangent).normalized()
+ var width: float = (
+ (float(state["width"]) + float(requested_size))
+ * float(state["cell_size"])
+ * 0.5
+ )
+ var height: float = (
+ (float(state["height"]) + float(requested_size))
+ * float(state["cell_size"])
+ * 0.5
+ )
+ var clearance: float = float(state["cell_size"]) * 0.5
+ if (
+ absf(relative.dot(tangent)) < width - clearance
+ and absf(relative.dot(bitangent)) < height - clearance
+ ):
+ return true
+ return false
+
+
+func _active_canvas_count() -> int:
+ var count: int = 0
+ for state: Dictionary in _canvas_states.values():
+ if not bool(state.get("finalized", false)):
+ count += 1
+ return count
+
+
+func _allocated_grid_cells() -> int:
+ var count: int = 0
+ for state: Dictionary in _canvas_states.values():
+ count += int(state.get("width", 0)) * int(state.get("height", 0))
+ return count
+
+
+func _create_brush_preview() -> void:
+ if _drawing_root == null:
+ return
+ var quad := QuadMesh.new()
+ quad.size = Vector2.ONE
+ _brush_preview_material = ShaderMaterial.new()
+ _brush_preview_material.shader = BrushHighlightShader
+ quad.material = _brush_preview_material
+ _brush_preview_multimesh = MultiMesh.new()
+ _brush_preview_multimesh.transform_format = MultiMesh.TRANSFORM_3D
+ _brush_preview_multimesh.mesh = quad
+ _brush_preview_multimesh.instance_count = (
+ SurfaceDrawingProtocol.MAX_EDITS_PER_REQUEST
+ )
+ _brush_preview_multimesh.visible_instance_count = 0
+ _brush_preview = MultiMeshInstance3D.new()
+ _brush_preview.name = "MarkerBrushPreview"
+ _brush_preview.multimesh = _brush_preview_multimesh
+ _brush_preview.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
+ _brush_preview.hide()
+ _drawing_root.add_child(_brush_preview)
+
+
+func _create_placement_preview() -> void:
+ _rebuild_placement_preview()
+
+
+func _rebuild_placement_preview() -> void:
+ if _drawing_root == null:
+ return
+ if _placement_preview != null:
+ _placement_preview.queue_free()
+ _placement_preview = null
+ var mesh := ImmediateMesh.new()
+ _placement_preview_material = StandardMaterial3D.new()
+ _placement_preview_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ _placement_preview_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ _placement_preview_material.cull_mode = BaseMaterial3D.CULL_DISABLED
+ _placement_preview_material.albedo_color = Color(0.46, 0.91, 0.95, 0.72)
+ mesh.surface_begin(Mesh.PRIMITIVE_LINES, _placement_preview_material)
+ var half_width: float = (
+ float(_grid_size)
+ * SurfaceDrawingProtocol.CELL_SIZE
+ * 0.5
+ )
+ var half_height: float = (
+ float(_grid_size)
+ * SurfaceDrawingProtocol.CELL_SIZE
+ * 0.5
+ )
+ for x: int in range(_grid_size + 1):
+ var horizontal: float = (
+ -half_width + float(x) * SurfaceDrawingProtocol.CELL_SIZE
+ )
+ mesh.surface_add_vertex(Vector3(horizontal, -half_height, 0.0))
+ mesh.surface_add_vertex(Vector3(horizontal, half_height, 0.0))
+ for y: int in range(_grid_size + 1):
+ var vertical: float = (
+ -half_height + float(y) * SurfaceDrawingProtocol.CELL_SIZE
+ )
+ mesh.surface_add_vertex(Vector3(-half_width, vertical, 0.0))
+ mesh.surface_add_vertex(Vector3(half_width, vertical, 0.0))
+ mesh.surface_end()
+ _placement_preview = MeshInstance3D.new()
+ _placement_preview.name = "MarkerGridPlacementPreview"
+ _placement_preview.mesh = mesh
+ _placement_preview.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
+ _placement_preview.hide()
+ _drawing_root.add_child(_placement_preview)
+
+
+func _set_placement_mode(enabled: bool) -> void:
+ if not _active:
+ return
+ _clear_armed_guide_action(true)
+ if _placing_grid == enabled:
+ _emit_hud_state("")
+ return
+ _placing_grid = enabled
+ _reset_stroke()
+ _selected_canvas_id = ""
+ _update_aim()
+ _refresh_stencil_visibility()
+ _emit_hud_state(
+ (
+ "click place/restore • shift hide • ctrl shift finish • shift scroll zoom"
+ if _placing_grid
+ else "click draw • shift click erase • ctrl z undo • shift scroll zoom"
+ )
+ )
+
+
+func _execute_armed_guide_action() -> void:
+ var action: int = _armed_guide_action
+ match action:
+ GuideAction.HIDE:
+ _remove_selected_guide()
+ GuideAction.RESTORE:
+ _restore_selected_guide()
+ GuideAction.FINALIZE:
+ _finalize_selected_guide()
+ # Guide actions are persistent modes. Keep the selected action armed so
+ # multiple grids can be handled without returning to the toolbar after
+ # every click; selecting the active toggle again explicitly exits it.
+ _emit_hud_state(_guide_action_status(action))
+
+
+func _cancel_armed_guide_action() -> void:
+ if _armed_guide_action == GuideAction.NONE:
+ return
+ _clear_armed_guide_action(true)
+ _emit_hud_state("")
+
+
+func _clear_armed_guide_action(restore_tool: bool) -> void:
+ if _armed_guide_action == GuideAction.NONE:
+ return
+ _armed_guide_action = GuideAction.NONE
+ if restore_tool:
+ _placing_grid = _armed_return_placement_mode
+ _eraser_mode = _armed_return_eraser_mode
+ _armed_return_placement_mode = false
+ _armed_return_eraser_mode = false
+ _reset_stroke()
+ _update_aim()
+ _refresh_stencil_visibility()
+
+
+func _guide_action_verb(action: int) -> String:
+ match action:
+ GuideAction.HIDE:
+ return "hide"
+ GuideAction.RESTORE:
+ return "show"
+ GuideAction.FINALIZE:
+ return "finish"
+ return "use"
+
+
+func _guide_action_status(action: int) -> String:
+ return "%s grid mode • click grids • toggle again to exit" % (
+ _guide_action_verb(action).capitalize()
+ )
+
+
+func _remove_selected_guide() -> void:
+ if _selected_canvas_id.is_empty():
+ _emit_hud_state("aim at a placed grid before removing its guide")
+ return
+ var state: Dictionary = _canvas_states.get(_selected_canvas_id, {})
+ if state.is_empty():
+ return
+ if not bool(state.get("guide_visible", true)):
+ _emit_hud_state("this grid guide is hidden • click to restore")
+ return
+ if request_guide_visibility(_selected_canvas_id, false):
+ if not _session.is_host():
+ _emit_hud_state("hiding grid guide...")
+
+
+func _restore_selected_guide() -> void:
+ if _selected_canvas_id.is_empty():
+ _emit_hud_state("aim at a hidden grid before restoring its guide")
+ return
+ var state: Dictionary = _canvas_states.get(_selected_canvas_id, {})
+ if state.is_empty() or bool(state.get("finalized", false)):
+ return
+ if bool(state.get("guide_visible", true)):
+ _emit_hud_state("this grid guide is already visible")
+ return
+ if request_guide_visibility(_selected_canvas_id, true):
+ if not _session.is_host():
+ _emit_hud_state("restoring grid guide...")
+
+
+func _finalize_selected_guide() -> void:
+ if _selected_canvas_id.is_empty():
+ _emit_hud_state("aim at an active grid before finishing it")
+ return
+ var state: Dictionary = _canvas_states.get(_selected_canvas_id, {})
+ if state.is_empty() or bool(state.get("finalized", false)):
+ return
+ if request_guide_visibility(_selected_canvas_id, false, true):
+ if not _session.is_host():
+ _emit_hud_state("finishing grid • artwork remains shared...")
+
+
+func _clamped_pointer_position(value: Vector2) -> Vector2:
+ var viewport_size: Vector2 = get_viewport().get_visible_rect().size
+ return Vector2(
+ clampf(value.x, POINTER_EDGE_MARGIN, viewport_size.x - POINTER_EDGE_MARGIN),
+ clampf(value.y, POINTER_EDGE_MARGIN, viewport_size.y - POINTER_EDGE_MARGIN),
+ )
+
+
+func _set_pointer_from_input(
+ event_position: Vector2,
+ pointer_screen_position: Vector2,
+) -> void:
+ # GameUI receives input through a scaled SubViewport, so event_position is
+ # expressed in that UI viewport's render coordinates. Camera3D ray methods
+ # require coordinates in the camera's root viewport instead. GameUI supplies
+ # that authoritative window position; direct callers may still use an event
+ # position when they share this service's viewport.
+ var resolved_position: Vector2 = pointer_screen_position
+ if not _is_valid_pointer_screen_position(resolved_position):
+ resolved_position = event_position
+ _pointer_screen_position = _clamped_pointer_position(resolved_position)
+
+
+func _is_valid_pointer_screen_position(value: Vector2) -> bool:
+ return value.x >= 0.0 and value.y >= 0.0
+
+
+func _cycle_color(direction: int) -> void:
+ if _color_ids.is_empty():
+ return
+ _clear_armed_guide_action(true)
+ _eraser_mode = false
+ _color_index = posmod(_color_index + direction, _color_ids.size())
+ _reset_stroke()
+ _emit_hud_state("")
+
+
+func _set_brush_size(value: int) -> void:
+ var requested: int = clampi(value, 1, 4)
+ if _art_unlocks != null and not _art_unlocks.is_brush_size_unlocked(
+ requested
+ ):
+ return
+ _brush_size = requested
+ _reset_stroke()
+ _emit_hud_state("")
+
+
+func _current_color_id() -> StringName:
+ return (
+ _color_ids[_color_index]
+ if not _color_ids.is_empty()
+ else SurfaceDrawingPalette.DEFAULT_COLOR_ID
+ )
+
+
+func _current_color() -> Color:
+ return SurfaceDrawingPalette.get_color(_current_color_id())
+
+
+func _emit_hud_state(status: String) -> void:
+ hud_state_changed.emit(
+ _active,
+ "place grid" if _placing_grid else "marker",
+ SurfaceDrawingPalette.get_display_name(_current_color_id()),
+ _current_color(),
+ _brush_size,
+ _grid_size,
+ status,
+ )
+
+
+func _refresh_stencil_visibility() -> void:
+ for canvas_id: String in _canvas_nodes:
+ _canvas_nodes[canvas_id].set_stencil_visible(_active)
+
+
+func _begin_stroke() -> void:
+ _active_stroke_id = _new_request_id("stroke")
+ _last_edited_cell = Vector3i(-1, -1, -1)
+
+
+func _finish_stroke() -> void:
+ _painting = false
+ _erasing = false
+ _active_stroke_id = ""
+ _last_edited_cell = Vector3i(-1, -1, -1)
+
+
+func _reset_stroke() -> void:
+ _finish_stroke()
+
+
+func _new_request_id(prefix: String) -> String:
+ _request_sequence += 1
+ return "%s-%d-%d" % [prefix, Time.get_ticks_msec(), _request_sequence]
+
+
+func _cells_by_key(
+ values: Array,
+ grid_width: int,
+) -> Dictionary[int, Dictionary]:
+ var result: Dictionary[int, Dictionary] = {}
+ for value: Variant in values:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var cell: Dictionary = value
+ var key: int = (
+ int(cell.get("y", -1)) * grid_width
+ + int(cell.get("x", -1))
+ )
+ result[key] = cell.duplicate(true)
+ return result
+
+
+func _state_cell(state: Dictionary, x: int, y: int) -> Dictionary:
+ var cells: Dictionary[int, Dictionary] = _cells_by_key(
+ state.get("cells", []), int(state["width"])
+ )
+ return Dictionary(
+ cells.get(_cell_key_for_state(state, x, y), {})
+ ).duplicate(true)
+
+
+func _cell_value_from_edit(edit: Dictionary) -> Dictionary:
+ if edit.is_empty() or str(edit.get("color_id", "")).is_empty():
+ return {}
+ return {
+ "color_id": str(edit["color_id"]),
+ "author_fingerprint": str(edit.get("author_fingerprint", "")),
+ }
+
+
+func _authoritative_edit_from_value(
+ x: int,
+ y: int,
+ value: Dictionary,
+) -> Dictionary:
+ return {
+ "x": x,
+ "y": y,
+ "color_id": str(value.get("color_id", "")),
+ "author_fingerprint": str(value.get("author_fingerprint", "")),
+ }
+
+
+func _same_cell_value(first: Dictionary, second: Dictionary) -> bool:
+ return (
+ str(first.get("color_id", "")) == str(second.get("color_id", ""))
+ and str(first.get("author_fingerprint", ""))
+ == str(second.get("author_fingerprint", ""))
+ )
+
+
+func _cell_key_for_state(state: Dictionary, x: int, y: int) -> int:
+ return y * int(state["width"]) + x
+
+
+func _mutation_key(canvas_id: String, x: int, y: int) -> String:
+ return "%s:%d:%d" % [canvas_id, x, y]
+
+
+func _sorted_cells(values: Dictionary[int, Dictionary]) -> Array[Dictionary]:
+ var keys: Array[int] = values.keys()
+ keys.sort()
+ var result: Array[Dictionary] = []
+ for key: int in keys:
+ result.append(values[key].duplicate(true))
+ return result
+
+
+func _normalized_or(value: Variant, fallback: Vector3) -> Vector3:
+ var vector: Vector3 = value if typeof(value) == TYPE_VECTOR3 else fallback
+ return fallback if vector.is_zero_approx() else vector.normalized()
+
+
+func _accept_request(peer_id: int, request_id: String) -> bool:
+ var recent_ids: PackedStringArray = _peer_request_ids.get(
+ peer_id, PackedStringArray()
+ )
+ if request_id in recent_ids:
+ return false
+ var now: int = Time.get_ticks_msec()
+ var request_times: PackedInt64Array = _peer_request_times.get(
+ peer_id, PackedInt64Array()
+ )
+ while (
+ not request_times.is_empty()
+ and now - request_times[0] >= REQUEST_WINDOW_MSEC
+ ):
+ request_times.remove_at(0)
+ if request_times.size() >= MAX_REQUESTS_PER_WINDOW:
+ _peer_request_times[peer_id] = request_times
+ return false
+ request_times.append(now)
+ recent_ids.append(request_id)
+ while recent_ids.size() > MAX_RECENT_REQUEST_IDS:
+ recent_ids.remove_at(0)
+ _peer_request_times[peer_id] = request_times
+ _peer_request_ids[peer_id] = recent_ids
+ return true
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ _peer_request_times.erase(peer_id)
+ _peer_request_ids.erase(peer_id)
+ _stroke_history_by_peer.erase(peer_id)
+ _peer_art_entitlements.erase(peer_id)
+
+
+func _on_local_art_entitlement_changed() -> void:
+ if _active and not can_activate():
+ deactivate()
+ _publish_local_entitlement()
+
+
+func _on_local_art_unlocks_changed(_unlock_mask: int) -> void:
+ _refresh_local_unlocks()
+ _rebuild_placement_preview()
+ _publish_local_entitlement()
+ _emit_hud_state("")
+
+
+func _on_relationship_changed(_fingerprint: String) -> void:
+ for canvas: SurfaceDrawingCanvas in _canvas_nodes.values():
+ canvas.refresh_relationship_visibility()
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if state == NetworkSession.State.JOINED_CLIENT:
+ _publish_local_entitlement()
+ request_canvas_snapshot.rpc_id(1, _new_request_id("snapshot"))
+ return
+ if state in [
+ NetworkSession.State.PRIVATE_HOST,
+ NetworkSession.State.OPEN_HOST,
+ ]:
+ _publish_local_entitlement()
+ return
+ if state not in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ return
+ deactivate()
+ _clear_session_artwork_state()
+ _canvas_sequence = 0
+ _peer_request_times.clear()
+ _peer_request_ids.clear()
+ _peer_art_entitlements.clear()
+
+
+func _clear_session_artwork_state() -> void:
+ _canvas_states.clear()
+ for canvas: SurfaceDrawingCanvas in _canvas_nodes.values():
+ canvas.queue_free()
+ _canvas_nodes.clear()
+ _canvas_sequence = 0
+ _selected_canvas_id = ""
+ _stroke_history_by_peer.clear()
+ _cell_last_stroke.clear()
+ _last_local_stroke_id = ""
+ _hide_previews()
+ _emit_artwork_changed()
+
+
+func _emit_artwork_changed() -> void:
+ session_artwork_changed.emit(
+ get_canvas_count(), get_painted_cell_count()
+ )
diff --git a/network/network_surface_drawing_service.gd.uid b/network/network_surface_drawing_service.gd.uid
new file mode 100644
index 0000000..2f78f04
--- /dev/null
+++ b/network/network_surface_drawing_service.gd.uid
@@ -0,0 +1 @@
+uid://bkiepgu8j5r6t
diff --git a/network/network_transport.gd b/network/network_transport.gd
new file mode 100644
index 0000000..ce68172
--- /dev/null
+++ b/network/network_transport.gd
@@ -0,0 +1,35 @@
+class_name NetworkTransport
+extends Node
+
+@warning_ignore("unused_signal")
+signal transport_error(message: String)
+
+var _peer: MultiplayerPeer
+var _route_description: String = ""
+
+
+func start_host(
+ _port: int,
+ _max_clients: int,
+ _bind_address: String = "*",
+) -> Error:
+ return ERR_UNAVAILABLE
+
+
+func connect_to_route(_route: ConnectionRoute) -> Error:
+ return ERR_UNAVAILABLE
+
+
+func disconnect_transport() -> void:
+ if _peer != null:
+ _peer.close()
+ _peer = null
+ _route_description = ""
+
+
+func get_multiplayer_peer() -> MultiplayerPeer:
+ return _peer
+
+
+func get_route_description() -> String:
+ return _route_description
diff --git a/network/network_transport.gd.uid b/network/network_transport.gd.uid
new file mode 100644
index 0000000..42076b6
--- /dev/null
+++ b/network/network_transport.gd.uid
@@ -0,0 +1 @@
+uid://pcqgk35ifhxc
diff --git a/network/network_world_time_service.gd b/network/network_world_time_service.gd
new file mode 100644
index 0000000..246af77
--- /dev/null
+++ b/network/network_world_time_service.gd
@@ -0,0 +1,153 @@
+class_name NetworkWorldTimeService
+extends Node
+
+const SYNC_INTERVAL_SECONDS: float = 5.0
+const MAX_SESSION_ID_LENGTH: int = 96
+
+var _session: NetworkSession
+var _world_time: WorldTimeService
+var _sync_elapsed: float = 0.0
+var _sequence: int = 0
+var _last_received_sequence: int = -1
+var _active_session_id: String = ""
+
+
+func setup(session: NetworkSession, world_time: WorldTimeService) -> void:
+ _session = session
+ _world_time = world_time
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ if not _world_time.authoritative_time_set.is_connected(
+ _on_authoritative_time_set
+ ):
+ _world_time.authoritative_time_set.connect(
+ _on_authoritative_time_set
+ )
+ set_process(true)
+
+
+func _process(delta: float) -> void:
+ if _session == null or _world_time == null or not _session.is_host():
+ return
+ _sync_elapsed += delta
+ if _sync_elapsed < SYNC_INTERVAL_SECONDS:
+ return
+ _sync_elapsed = 0.0
+ _broadcast_snapshot()
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if _world_time == null or _session == null:
+ return
+ if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]:
+ if _active_session_id != _session.get_session_id():
+ _active_session_id = _session.get_session_id()
+ _sequence = 0
+ _last_received_sequence = -1
+ _sync_elapsed = 0.0
+ _world_time.set_persistence_tracking_enabled(true)
+ _world_time.begin_session(
+ _world_time.get_persistent_time_hours()
+ )
+ return
+ if state == NetworkSession.State.JOINED_CLIENT:
+ _active_session_id = _session.get_session_id()
+ _last_received_sequence = -1
+ _sync_elapsed = 0.0
+ _world_time.set_persistence_tracking_enabled(false)
+ if _session.supports_server_capability(
+ NetworkProtocol.WORLD_TIME_CAPABILITY
+ ):
+ _world_time.begin_session()
+ else:
+ _world_time.end_session()
+ return
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _active_session_id = ""
+ _sequence = 0
+ _last_received_sequence = -1
+ _sync_elapsed = 0.0
+ _world_time.set_persistence_tracking_enabled(false)
+ _world_time.end_session()
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.peer_supports_capability(
+ peer_id, NetworkProtocol.WORLD_TIME_CAPABILITY
+ )
+ ):
+ return
+ _send_snapshot(peer_id)
+
+
+func _on_authoritative_time_set(_time_hours: float) -> void:
+ if _session == null or not _session.is_host():
+ return
+ _sync_elapsed = 0.0
+ _broadcast_snapshot()
+
+
+func _broadcast_snapshot() -> void:
+ if _session == null or not _session.is_host():
+ return
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if peer_id == _session.get_local_peer_id():
+ continue
+ if _session.peer_supports_capability(
+ peer_id, NetworkProtocol.WORLD_TIME_CAPABILITY
+ ):
+ _send_snapshot(peer_id)
+
+
+func _send_snapshot(peer_id: int) -> void:
+ _sequence += 1
+ receive_world_time_snapshot.rpc_id(peer_id, {
+ "session_id": _session.get_session_id(),
+ "time_hours": _world_time.get_time_hours(),
+ "sequence": _sequence,
+ })
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_world_time_snapshot(data: Dictionary) -> void:
+ if (
+ _session == null
+ or _world_time == null
+ or not _session.is_joined_client()
+ or not _session.supports_server_capability(
+ NetworkProtocol.WORLD_TIME_CAPABILITY
+ )
+ or not validate_snapshot(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ var sequence: int = int(data["sequence"])
+ if sequence <= _last_received_sequence:
+ return
+ _last_received_sequence = sequence
+ _world_time.synchronize_time(float(data["time_hours"]))
+
+
+static func validate_snapshot(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ typeof(value.get("session_id")) == TYPE_STRING
+ and not str(value["session_id"]).is_empty()
+ and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH
+ and typeof(value.get("time_hours")) in [TYPE_FLOAT, TYPE_INT]
+ and is_finite(float(value["time_hours"]))
+ and float(value["time_hours"]) >= 0.0
+ and float(value["time_hours"]) < WorldTimeService.HOURS_PER_DAY
+ and typeof(value.get("sequence")) == TYPE_INT
+ and int(value["sequence"]) >= 0
+ )
diff --git a/network/network_world_time_service.gd.uid b/network/network_world_time_service.gd.uid
new file mode 100644
index 0000000..a68e332
--- /dev/null
+++ b/network/network_world_time_service.gd.uid
@@ -0,0 +1 @@
+uid://blig76aes25j
diff --git a/network/network_world_weather_service.gd b/network/network_world_weather_service.gd
new file mode 100644
index 0000000..43d5762
--- /dev/null
+++ b/network/network_world_weather_service.gd
@@ -0,0 +1,159 @@
+class_name NetworkWorldWeatherService
+extends Node
+
+const SYNC_INTERVAL_SECONDS: float = 15.0
+const MAX_SESSION_ID_LENGTH: int = 96
+const MAX_REMAINING_SECONDS: float = 1800.0
+
+var _session: NetworkSession
+var _world_weather: WorldWeatherService
+var _sync_elapsed: float = 0.0
+var _sequence: int = 0
+var _last_received_sequence: int = -1
+var _active_session_id: String = ""
+
+
+func setup(
+ session: NetworkSession,
+ world_weather: WorldWeatherService,
+) -> void:
+ _session = session
+ _world_weather = world_weather
+ _session.state_changed.connect(_on_session_state_changed)
+ _session.peer_authenticated.connect(_on_peer_authenticated)
+ _world_weather.weather_changed.connect(_on_weather_changed)
+ set_process(true)
+
+
+func _process(delta: float) -> void:
+ if _session == null or _world_weather == null or not _session.is_host():
+ return
+ _sync_elapsed += delta
+ if _sync_elapsed < SYNC_INTERVAL_SECONDS:
+ return
+ _sync_elapsed = 0.0
+ _broadcast_snapshot()
+
+
+func _on_session_state_changed(state: NetworkSession.State) -> void:
+ if _world_weather == null or _session == null:
+ return
+ if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]:
+ if _active_session_id != _session.get_session_id():
+ _active_session_id = _session.get_session_id()
+ _sequence = 0
+ _last_received_sequence = -1
+ _sync_elapsed = 0.0
+ _world_weather.set_persistence_tracking_enabled(true)
+ _world_weather.begin_authoritative_session(
+ _active_session_id.hash()
+ )
+ return
+ if state == NetworkSession.State.JOINED_CLIENT:
+ _active_session_id = _session.get_session_id()
+ _last_received_sequence = -1
+ _sync_elapsed = 0.0
+ _world_weather.set_persistence_tracking_enabled(false)
+ if _session.supports_server_capability(
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ ):
+ _world_weather.begin_remote_session()
+ else:
+ _world_weather.end_session()
+ return
+ if state in [
+ NetworkSession.State.INACTIVE,
+ NetworkSession.State.DISCONNECTING,
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _active_session_id = ""
+ _sequence = 0
+ _last_received_sequence = -1
+ _sync_elapsed = 0.0
+ _world_weather.set_persistence_tracking_enabled(false)
+ _world_weather.end_session()
+
+
+func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
+ if (
+ _session == null
+ or not _session.is_host()
+ or not _session.peer_supports_capability(
+ peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ )
+ ):
+ return
+ _send_snapshot(peer_id)
+
+
+func _on_weather_changed(
+ _weather: WorldWeatherService.Weather,
+ _seconds_remaining: float,
+) -> void:
+ if _session != null and _session.is_host():
+ _broadcast_snapshot()
+
+
+func _broadcast_snapshot() -> void:
+ if _session == null or not _session.is_host():
+ return
+ for peer_id: int in _session.get_authenticated_peer_ids():
+ if peer_id == _session.get_local_peer_id():
+ continue
+ if _session.peer_supports_capability(
+ peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ ):
+ _send_snapshot(peer_id)
+
+
+func _send_snapshot(peer_id: int) -> void:
+ _sequence += 1
+ receive_world_weather_snapshot.rpc_id(peer_id, {
+ "session_id": _session.get_session_id(),
+ "weather": int(_world_weather.get_weather()),
+ "seconds_remaining": _world_weather.get_seconds_remaining(),
+ "sequence": _sequence,
+ })
+
+
+@rpc("authority", "call_remote", "reliable", 0)
+func receive_world_weather_snapshot(data: Dictionary) -> void:
+ if (
+ _session == null
+ or _world_weather == null
+ or not _session.is_joined_client()
+ or not _session.supports_server_capability(
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ )
+ or not validate_snapshot(data)
+ or str(data["session_id"]) != _session.get_session_id()
+ ):
+ return
+ var sequence: int = int(data["sequence"])
+ if sequence <= _last_received_sequence:
+ return
+ _last_received_sequence = sequence
+ _world_weather.apply_authoritative_snapshot(
+ int(data["weather"]) as WorldWeatherService.Weather,
+ float(data["seconds_remaining"]),
+ )
+
+
+static func validate_snapshot(data: Variant) -> bool:
+ if typeof(data) != TYPE_DICTIONARY:
+ return false
+ var value: Dictionary = data
+ return (
+ typeof(value.get("session_id")) == TYPE_STRING
+ and not str(value["session_id"]).is_empty()
+ and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH
+ and typeof(value.get("weather")) == TYPE_INT
+ and WorldWeatherService.is_valid_weather(int(value["weather"]))
+ and typeof(value.get("seconds_remaining")) in [TYPE_FLOAT, TYPE_INT]
+ and is_finite(float(value["seconds_remaining"]))
+ and float(value["seconds_remaining"]) >= 0.0
+ and float(value["seconds_remaining"]) <= MAX_REMAINING_SECONDS
+ and typeof(value.get("sequence")) == TYPE_INT
+ and int(value["sequence"]) >= 0
+ )
diff --git a/network/network_world_weather_service.gd.uid b/network/network_world_weather_service.gd.uid
new file mode 100644
index 0000000..ffb9a1e
--- /dev/null
+++ b/network/network_world_weather_service.gd.uid
@@ -0,0 +1 @@
+uid://llj46fx0oco7
diff --git a/network/peer_registry.gd b/network/peer_registry.gd
new file mode 100644
index 0000000..2cab061
--- /dev/null
+++ b/network/peer_registry.gd
@@ -0,0 +1,116 @@
+class_name PeerRegistry
+extends RefCounted
+
+class PeerRecord:
+ extends RefCounted
+
+ var peer_id: int = 0
+ var profile_id: String = ""
+ var display_name: String = ""
+ var protocol_version: int = 0
+ var joined_at_unix: int = 0
+ var appearance_snapshot: Dictionary = (
+ CharacterCustomizationCatalog.default_snapshot()
+ )
+ var identity_fingerprint: String = ""
+ var identity_public_key: String = ""
+ var identity_authenticated: bool = false
+ var profile_authorization: Dictionary = {}
+ var capability_flags: PackedStringArray = PackedStringArray()
+
+
+var _records: Dictionary[int, PeerRecord] = {}
+
+
+func add_peer(
+ peer_id: int,
+ profile_id: String,
+ display_name: String,
+ protocol_version: int,
+ identity_fingerprint: String = "",
+ identity_public_key: String = "",
+ capability_flags: PackedStringArray = PackedStringArray(),
+) -> bool:
+ if (
+ peer_id <= 0
+ or profile_id.is_empty()
+ or (
+ not identity_fingerprint.is_empty()
+ and has_fingerprint(identity_fingerprint)
+ )
+ ):
+ return false
+ var record := PeerRecord.new()
+ record.peer_id = peer_id
+ record.profile_id = profile_id
+ record.display_name = display_name
+ record.protocol_version = protocol_version
+ record.joined_at_unix = int(Time.get_unix_time_from_system())
+ record.identity_fingerprint = identity_fingerprint
+ record.identity_public_key = identity_public_key
+ record.identity_authenticated = NetworkIdentityCrypto.valid_fingerprint(
+ identity_fingerprint
+ )
+ record.capability_flags = capability_flags.duplicate()
+ _records[peer_id] = record
+ return true
+
+
+func remove_peer(peer_id: int) -> PeerRecord:
+ var record: PeerRecord = _records.get(peer_id)
+ _records.erase(peer_id)
+ return record
+
+
+func get_peer(peer_id: int) -> PeerRecord:
+ return _records.get(peer_id)
+
+
+func update_display_name(peer_id: int, display_name: String) -> bool:
+ var record: PeerRecord = _records.get(peer_id)
+ if record == null or display_name.is_empty():
+ return false
+ record.display_name = display_name
+ return true
+
+
+func update_appearance(peer_id: int, snapshot: Dictionary) -> bool:
+ var record: PeerRecord = _records.get(peer_id)
+ if record == null or not CharacterCustomizationCatalog.validate_snapshot(snapshot):
+ return false
+ record.appearance_snapshot = snapshot.duplicate(true)
+ return true
+
+
+func has_peer(peer_id: int) -> bool:
+ return _records.has(peer_id)
+
+
+func has_profile(profile_id: String) -> bool:
+ for record: PeerRecord in _records.values():
+ if record.profile_id == profile_id:
+ return true
+ return false
+
+
+func has_fingerprint(fingerprint: String) -> bool:
+ for record: PeerRecord in _records.values():
+ if record.identity_fingerprint == fingerprint:
+ return true
+ return false
+
+
+func get_peer_ids() -> Array[int]:
+ var result: Array[int] = []
+ for peer_id: int in _records:
+ result.append(peer_id)
+ result.sort()
+ return result
+
+
+func size() -> int:
+ return _records.size()
+
+
+func clear() -> void:
+ _records.clear()
diff --git a/network/peer_registry.gd.uid b/network/peer_registry.gd.uid
new file mode 100644
index 0000000..da096b2
--- /dev/null
+++ b/network/peer_registry.gd.uid
@@ -0,0 +1 @@
+uid://de3jtu2e0ut65
diff --git a/network/player_data_root.gd b/network/player_data_root.gd
new file mode 100644
index 0000000..525c545
--- /dev/null
+++ b/network/player_data_root.gd
@@ -0,0 +1,489 @@
+class_name PlayerDataRoot
+extends Node
+
+signal status_changed(message: String)
+signal conflict_detected(message: String, conflict_path: String)
+
+const BOOTSTRAP_PATH := "user://data_root_bootstrap.json"
+const BOOTSTRAP_TEMP_PATH := "user://data_root_bootstrap.json.tmp"
+const BOOTSTRAP_VERSION := 1
+const MANIFEST_VERSION := 1
+const LAYOUT_VERSION := 1
+const MANIFEST_FILENAME := "netfishing_data.json"
+const README_FILENAME := "README.txt"
+const ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
+const APPLICATION_ID := "netfishing"
+const APP_DATA_PORTABLE_PATH := "user://portable-data"
+const PATH_ALLOWED: StringName = &"allowed"
+const PATH_SOURCE_PROJECT: StringName = &"source_project"
+const PATH_INSTALLATION: StringName = &"installation"
+
+enum Mode {
+ UNRESOLVED,
+ SELECTED_FOLDER,
+ APP_DATA,
+ ENVIRONMENT_OVERRIDE,
+ COMMAND_LINE_OVERRIDE,
+}
+
+var mode := Mode.UNRESOLVED
+var root_path := ""
+var root_id := ""
+var device_id := ""
+var error_message := ""
+var requires_selection := false
+var override_active := false
+
+
+func resolve() -> bool:
+ _load_bootstrap_identity()
+ var command_line: String = _command_line_override()
+ if not command_line.is_empty():
+ override_active = true
+ mode = Mode.COMMAND_LINE_OVERRIDE
+ return _activate_existing(command_line, "", false)
+ if OS.has_environment(ENVIRONMENT_VARIABLE):
+ override_active = true
+ mode = Mode.ENVIRONMENT_OVERRIDE
+ var environment_path: String = OS.get_environment(ENVIRONMENT_VARIABLE)
+ if not environment_path.is_absolute_path():
+ return _fail("NETFISHING_DATA_DIR must be an absolute path.")
+ return _activate_existing(environment_path, "", false)
+ var bootstrap: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
+ if bootstrap.is_empty() and FileAccess.file_exists(BOOTSTRAP_PATH + ".backup"):
+ bootstrap = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
+ if not bootstrap.is_empty():
+ if bootstrap.get("format_version") != BOOTSTRAP_VERSION:
+ return _fail("The data-folder pointer uses an unsupported version.")
+ var selected: String = str(bootstrap.get("selected_absolute_path", ""))
+ var expected: String = str(bootstrap.get("expected_root_id", ""))
+ if selected.is_empty():
+ return _fail("The data-folder pointer is incomplete.")
+ mode = (
+ Mode.APP_DATA
+ if selected == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
+ else Mode.SELECTED_FOLDER
+ )
+ return _activate_existing(selected, expected, false)
+ requires_selection = true
+ error_message = ""
+ return false
+
+
+func default_visible_path() -> String:
+ var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
+ if documents.is_empty():
+ return ""
+ var candidate: String = _normalize(documents.path_join("NETFISHING"))
+ if not candidate.is_absolute_path():
+ return ""
+ var classification: StringName = classify_candidate_path(
+ candidate,
+ ProjectSettings.globalize_path("res://"),
+ OS.get_executable_path().get_base_dir(),
+ )
+ return candidate if classification == PATH_ALLOWED else ""
+
+
+func select_new_root(path: String, app_data: bool = false) -> bool:
+ if override_active:
+ return _fail("The data folder is controlled by a process override.")
+ if device_id.length() != 32:
+ device_id = Crypto.new().generate_random_bytes(16).hex_encode()
+ var normalized: String = _normalize(path)
+ if app_data:
+ normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
+ if not _validate_candidate(normalized, true):
+ return false
+ var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
+ if FileAccess.file_exists(manifest_path):
+ var manifest: Dictionary = _read_json(manifest_path)
+ if not _valid_manifest(manifest):
+ return _fail("The selected folder has a malformed NETfishing manifest.")
+ root_id = str(manifest["root_id"])
+ else:
+ if not _directory_is_empty(normalized) and not app_data:
+ return _fail(
+ "Choose an empty folder or an existing NETfishing data folder."
+ )
+ root_id = Crypto.new().generate_random_bytes(16).hex_encode()
+ if not _create_layout(normalized, root_id):
+ return _fail("The NETfishing data folder could not be created.")
+ if not _write_bootstrap(normalized, root_id):
+ return _fail("The data-folder pointer could not be saved.")
+ root_path = normalized
+ mode = Mode.APP_DATA if app_data else Mode.SELECTED_FOLDER
+ requires_selection = false
+ error_message = ""
+ status_changed.emit("Data folder ready.")
+ return true
+
+
+func create_unbound_root(path: String) -> Dictionary:
+ var normalized: String = _normalize(path)
+ if not _validate_candidate(normalized, true):
+ return {"ok": false, "message": error_message}
+ if not _directory_is_empty(normalized):
+ return {"ok": false, "message": "The destination staging folder is not empty."}
+ var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
+ if not _create_layout(normalized, id):
+ return {"ok": false, "message": "The portable layout could not be created."}
+ return {"ok": true, "root_id": id}
+
+
+func create_app_data_layout_for_migration(path: String) -> Dictionary:
+ var normalized: String = _normalize(path)
+ if not _validate_candidate(normalized, false):
+ return {"ok": false, "message": error_message}
+ var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
+ if FileAccess.file_exists(manifest_path):
+ var manifest: Dictionary = _read_json(manifest_path)
+ return (
+ {"ok": true, "root_id": str(manifest.get("root_id", ""))}
+ if _valid_manifest(manifest)
+ else {"ok": false, "message": "The app-data manifest is malformed."}
+ )
+ var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
+ if not _create_layout(normalized, id):
+ return {"ok": false, "message": "The app-data layout could not be created."}
+ return {"ok": true, "root_id": id}
+
+
+func use_existing_root(path: String) -> bool:
+ if override_active:
+ return _fail("The data folder is controlled by a process override.")
+ if device_id.length() != 32:
+ device_id = Crypto.new().generate_random_bytes(16).hex_encode()
+ var normalized: String = _normalize(path)
+ if not _activate_existing(normalized, "", true):
+ return false
+ if not _write_bootstrap(root_path, root_id):
+ root_path = ""
+ root_id = ""
+ return _fail("The data-folder pointer could not be saved.")
+ mode = (
+ Mode.APP_DATA
+ if normalized == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
+ else Mode.SELECTED_FOLDER
+ )
+ return true
+
+
+func activate_process_root(path: String) -> bool:
+ _load_bootstrap_identity()
+ override_active = true
+ mode = Mode.COMMAND_LINE_OVERRIDE
+ return _activate_existing(path, "", false)
+
+
+func path_for(store_owner: StringName) -> String:
+ var relative: String = {
+ &"player_save": "player/player_save.json",
+ &"network_profile": "player/network_profile.json",
+ &"player_appearance": "player/player_appearance.json",
+ &"saved_servers": "social/saved_servers.json",
+ &"known_players": "social/known_players.json",
+ &"player_relationships": "social/player_relationships.json",
+ &"server_trust": "social/server_trust.json",
+ &"host_bans": "social/host_bans.json",
+ }.get(store_owner, "")
+ return root_path.path_join(relative) if not relative.is_empty() else ""
+
+
+func conflict_directory() -> String:
+ return root_path.path_join("backups/conflicts")
+
+
+func identity_backup_directory() -> String:
+ return root_path.path_join("identity-backups")
+
+
+func migration_backup_directory() -> String:
+ return root_path.path_join("backups/migrations")
+
+
+func storage_mode_text() -> String:
+ return {
+ Mode.SELECTED_FOLDER: "Selected folder",
+ Mode.APP_DATA: "App data",
+ Mode.ENVIRONMENT_OVERRIDE: "Environment override",
+ Mode.COMMAND_LINE_OVERRIDE: "Command-line override",
+ }.get(mode, "Unavailable")
+
+
+func open_folder() -> bool:
+ return not root_path.is_empty() and OS.shell_open(root_path) == OK
+
+
+func report_conflict(message: String, conflict_path: String) -> void:
+ error_message = message
+ conflict_detected.emit(message, conflict_path)
+ status_changed.emit(message)
+
+
+func _activate_existing(path: String, expected_id: String, permit_creation: bool) -> bool:
+ var normalized: String = _normalize(path)
+ if not _validate_candidate(normalized, permit_creation):
+ return false
+ var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
+ if not FileAccess.file_exists(manifest_path):
+ return _fail("The selected folder is not a NETfishing data folder.")
+ var manifest: Dictionary = _read_json(manifest_path)
+ if not _valid_manifest(manifest):
+ return _fail("The NETfishing data-folder manifest is malformed.")
+ var found_id: String = str(manifest["root_id"])
+ if not expected_id.is_empty() and expected_id != found_id:
+ return _fail("The selected data folder does not match this device pointer.")
+ if not _test_writable(normalized):
+ return _fail("The NETfishing data folder is unavailable or unwritable.")
+ root_path = normalized
+ root_id = found_id
+ requires_selection = false
+ error_message = ""
+ if PortableFileGuard.has_syncthing_conflict(root_path.path_join("player")):
+ status_changed.emit("Syncthing conflict copies were found. Review the data folder.")
+ return true
+
+
+func _validate_candidate(path: String, create: bool) -> bool:
+ var normalized: String = _normalize(path)
+ if normalized.is_empty() or not normalized.is_absolute_path():
+ return _fail("Choose an absolute filesystem folder.")
+ var app_data_path: String = _normalize(
+ ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
+ )
+ var exported_app_data: bool = (
+ normalized == app_data_path
+ and not OS.has_feature("editor")
+ )
+ var classification: StringName = classify_candidate_path(
+ normalized,
+ ProjectSettings.globalize_path("res://"),
+ OS.get_executable_path().get_base_dir(),
+ )
+ if classification == PATH_SOURCE_PROJECT and not exported_app_data:
+ return _fail("The project folder cannot be used as the player data folder.")
+ if classification == PATH_INSTALLATION and not exported_app_data:
+ return _fail(
+ "The application installation folder cannot be used as the player data folder."
+ )
+ if not DirAccess.dir_exists_absolute(normalized):
+ if not create or DirAccess.make_dir_recursive_absolute(normalized) != OK:
+ return _fail("The selected data folder is unavailable.")
+ return _test_writable(normalized)
+
+
+static func classify_candidate_path(
+ candidate_path: String,
+ project_reference: String,
+ installation_reference: String,
+ case_insensitive: bool = OS.get_name() == "Windows",
+) -> StringName:
+ var candidate: String = _normalize_comparison_path(
+ candidate_path, case_insensitive
+ )
+ if candidate.is_empty() or not candidate.is_absolute_path():
+ return PATH_ALLOWED
+ var project: String = _normalize_reference_path(
+ project_reference, case_insensitive
+ )
+ if not project.is_empty() and _is_same_or_child_path(candidate, project):
+ return PATH_SOURCE_PROJECT
+ var installation: String = _normalize_reference_path(
+ installation_reference, case_insensitive
+ )
+ if (
+ not installation.is_empty()
+ and _is_same_or_child_path(candidate, installation)
+ ):
+ return PATH_INSTALLATION
+ return PATH_ALLOWED
+
+
+static func _normalize_reference_path(path: String, case_insensitive: bool) -> String:
+ var normalized: String = _normalize_comparison_path(path, case_insensitive)
+ if normalized.is_empty() or not normalized.is_absolute_path():
+ return ""
+ if normalized == "/" or _is_windows_drive_root(normalized):
+ return ""
+ return normalized
+
+
+static func _normalize_comparison_path(path: String, case_insensitive: bool) -> String:
+ var normalized: String = path.strip_edges().replace("\\", "/").simplify_path()
+ while normalized.length() > 1 and normalized.ends_with("/"):
+ if _is_windows_drive_root(normalized):
+ break
+ normalized = normalized.left(-1)
+ return normalized.to_lower() if case_insensitive else normalized
+
+
+static func _is_windows_drive_root(path: String) -> bool:
+ return path.length() == 3 and path[1] == ":" and path[2] == "/"
+
+
+static func _is_same_or_child_path(candidate: String, reference: String) -> bool:
+ return candidate == reference or candidate.begins_with(reference + "/")
+
+
+func _test_writable(path: String) -> bool:
+ var probe: String = path.path_join(
+ ".netfishing-write-%s.tmp" % device_id.left(12)
+ )
+ var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
+ if file == null:
+ return false
+ file.store_string("probe")
+ file.close()
+ return DirAccess.remove_absolute(probe) == OK
+
+
+func _create_layout(path: String, id: String) -> bool:
+ for relative: String in [
+ "player", "social", "backups/saves", "backups/migrations",
+ "backups/conflicts", "identity-backups",
+ ]:
+ if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
+ return false
+ var now: int = int(Time.get_unix_time_from_system())
+ var manifest: Dictionary = {
+ "format_version": MANIFEST_VERSION,
+ "layout_version": LAYOUT_VERSION,
+ "application": APPLICATION_ID,
+ "root_id": id,
+ "created_at_unix": now,
+ "last_opened_at_unix": now,
+ }
+ if not _write_text_atomic(
+ path.path_join(MANIFEST_FILENAME), JSON.stringify(manifest, "\t")
+ ):
+ return false
+ var readme: String = (
+ "NETfishing player data\n\n"
+ + "This folder is safe to synchronize with tools such as Syncthing.\n"
+ + "Active private identity keys remain device-local.\n"
+ + "Encrypted identity backups require their passphrase.\n"
+ + "Chat and Session Mail are not stored here.\n"
+ + "Do not play the same profile on two devices at the same time.\n"
+ + "Conflicting edits are preserved under backups/conflicts; they are not merged.\n"
+ )
+ return _write_text_atomic(path.path_join(README_FILENAME), readme)
+
+
+func _write_bootstrap(path: String, id: String) -> bool:
+ var now: int = int(Time.get_unix_time_from_system())
+ var data: Dictionary = {
+ "format_version": BOOTSTRAP_VERSION,
+ "selected_absolute_path": path,
+ "expected_root_id": id,
+ "device_id": device_id,
+ "selected_at_unix": now,
+ "last_successfully_opened_at_unix": now,
+ }
+ return _write_text_atomic(
+ ProjectSettings.globalize_path(BOOTSTRAP_PATH),
+ JSON.stringify(data, "\t"),
+ )
+
+
+func _load_bootstrap_identity() -> void:
+ var data: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
+ if data.is_empty():
+ data = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
+ device_id = str(data.get("device_id", ""))
+ if device_id.length() != 32:
+ device_id = Crypto.new().generate_random_bytes(16).hex_encode()
+
+
+func _command_line_override() -> String:
+ var args: PackedStringArray = OS.get_cmdline_user_args()
+ for index: int in args.size():
+ var value: String = args[index]
+ if value.begins_with("--data-dir="):
+ return value.trim_prefix("--data-dir=")
+ if value == "--data-dir" and index + 1 < args.size():
+ return args[index + 1]
+ return ""
+
+
+func _valid_manifest(data: Dictionary) -> bool:
+ return (
+ data.get("format_version") == MANIFEST_VERSION
+ and data.get("layout_version") == LAYOUT_VERSION
+ and data.get("application") == APPLICATION_ID
+ and typeof(data.get("root_id")) == TYPE_STRING
+ and str(data["root_id"]).length() == 32
+ )
+
+
+func _directory_is_empty(path: String) -> bool:
+ var access: DirAccess = DirAccess.open(path)
+ if access == null:
+ return true
+ access.list_dir_begin()
+ var entry_name: String = access.get_next()
+ while entry_name in [".", ".."]:
+ entry_name = access.get_next()
+ access.list_dir_end()
+ return entry_name.is_empty()
+
+
+func _normalize(path: String) -> String:
+ var normalized: String = path.replace("\\", "/").simplify_path()
+ while normalized.length() > 1 and normalized.ends_with("/"):
+ if _is_windows_drive_root(normalized):
+ break
+ normalized = normalized.left(-1)
+ return normalized
+
+
+func _read_json(path: String, maximum := 1024 * 1024) -> Dictionary:
+ if not FileAccess.file_exists(path):
+ return {}
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ if file == null or file.get_length() > maximum:
+ return {}
+ var json: JSON = JSON.new()
+ var error: Error = json.parse(file.get_as_text())
+ file.close()
+ return json.data if error == OK and typeof(json.data) == TYPE_DICTIONARY else {}
+
+
+func _write_text_atomic(path: String, text: String) -> bool:
+ var absolute: String = (
+ ProjectSettings.globalize_path(path)
+ if path.begins_with("user://")
+ else path
+ )
+ if DirAccess.make_dir_recursive_absolute(absolute.get_base_dir()) != OK:
+ return false
+ var temporary: String = absolute + ".tmp"
+ var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
+ if file == null:
+ return false
+ file.store_string(text)
+ file.flush()
+ var ok: bool = file.get_error() == OK
+ file.close()
+ if not ok:
+ return false
+ var backup: String = absolute + ".backup"
+ if FileAccess.file_exists(backup):
+ DirAccess.remove_absolute(backup)
+ var had_primary: bool = FileAccess.file_exists(absolute)
+ if had_primary and DirAccess.rename_absolute(absolute, backup) != OK:
+ DirAccess.remove_absolute(temporary)
+ return false
+ if DirAccess.rename_absolute(temporary, absolute) != OK:
+ if had_primary:
+ DirAccess.rename_absolute(backup, absolute)
+ return false
+ if FileAccess.file_exists(backup):
+ DirAccess.remove_absolute(backup)
+ return true
+
+
+func _fail(message: String) -> bool:
+ error_message = message
+ status_changed.emit(message)
+ return false
diff --git a/network/player_data_root.gd.uid b/network/player_data_root.gd.uid
new file mode 100644
index 0000000..0aa1857
--- /dev/null
+++ b/network/player_data_root.gd.uid
@@ -0,0 +1 @@
+uid://dpiv6wttry0j4
diff --git a/network/player_identity_store.gd b/network/player_identity_store.gd
new file mode 100644
index 0000000..80d8aa3
--- /dev/null
+++ b/network/player_identity_store.gd
@@ -0,0 +1,6 @@
+class_name PlayerIdentityStore
+extends "res://network/local_signing_identity_store.gd"
+
+
+func _ready() -> void:
+ configure("player_identity")
diff --git a/network/player_identity_store.gd.uid b/network/player_identity_store.gd.uid
new file mode 100644
index 0000000..ce591b3
--- /dev/null
+++ b/network/player_identity_store.gd.uid
@@ -0,0 +1 @@
+uid://cqlgolkbx2riq
diff --git a/network/player_list_entry.gd b/network/player_list_entry.gd
new file mode 100644
index 0000000..f401da6
--- /dev/null
+++ b/network/player_list_entry.gd
@@ -0,0 +1,18 @@
+class_name PlayerListEntry
+extends RefCounted
+
+var peer_id := 0
+var full_fingerprint := ""
+var compact_fingerprint := ""
+var display_name := ""
+var is_host := false
+var is_operator := false
+var is_local_player := false
+var continuity_state := ""
+var ping_to_host_ms := -1
+var muted := false
+var blocked := false
+var can_kick := false
+var can_ban := false
+var can_manage_operator := false
+var revision := 0
diff --git a/network/player_list_entry.gd.uid b/network/player_list_entry.gd.uid
new file mode 100644
index 0000000..fa479f9
--- /dev/null
+++ b/network/player_list_entry.gd.uid
@@ -0,0 +1 @@
+uid://c71playerentry001
diff --git a/network/player_relationship_store.gd b/network/player_relationship_store.gd
new file mode 100644
index 0000000..2ea4502
--- /dev/null
+++ b/network/player_relationship_store.gd
@@ -0,0 +1,147 @@
+class_name PlayerRelationshipStore
+extends Node
+
+signal relationship_changed(fingerprint: String)
+
+const FORMAT_VERSION := 1
+const MAX_RECORDS := 500
+
+var _records: Dictionary = {}
+var _loaded := false
+var _write_blocked := false
+var _store_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _store_path = path
+ _data_root = data_root
+
+
+func is_muted(fingerprint: String) -> bool:
+ _ensure_loaded()
+ return bool(_records.get(fingerprint, {}).get("muted", false))
+
+
+func is_blocked(fingerprint: String) -> bool:
+ _ensure_loaded()
+ return bool(_records.get(fingerprint, {}).get("blocked", false))
+
+
+func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
+ if not _valid_target(fingerprint, display_name):
+ return false
+ _ensure_loaded()
+ var record := _record(fingerprint, display_name)
+ record["muted"] = value or bool(record.get("blocked", false))
+ return _commit(fingerprint, record)
+
+
+func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool:
+ if not _valid_target(fingerprint, display_name):
+ return false
+ _ensure_loaded()
+ var record := _record(fingerprint, display_name)
+ record["blocked"] = value
+ # Blocking owns the accompanying mute. Removing the block establishes a
+ # fresh visibility boundary for future messages; already suppressed
+ # messages retain their immutable local suppression flag.
+ record["muted"] = value
+ return _commit(fingerprint, record)
+
+
+func get_records() -> Array[Dictionary]:
+ _ensure_loaded()
+ var result: Array[Dictionary] = []
+ for value: Dictionary in _records.values():
+ if bool(value.get("muted", false)) or bool(value.get("blocked", false)):
+ result.append(value.duplicate(true))
+ result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ return str(a.get("last_known_display_name", "")).naturalnocasecmp_to(
+ str(b.get("last_known_display_name", ""))
+ ) < 0
+ )
+ return result
+
+
+func _record(fingerprint: String, display_name: String) -> Dictionary:
+ var now := int(Time.get_unix_time_from_system())
+ var record: Dictionary = _records.get(fingerprint, {
+ "fingerprint": fingerprint,
+ "created_unix": now,
+ "muted": false,
+ "blocked": false,
+ })
+ record["last_known_display_name"] = display_name
+ record["updated_unix"] = now
+ return record
+
+
+func _commit(fingerprint: String, record: Dictionary) -> bool:
+ if _write_blocked:
+ return false
+ var previous: Dictionary = _records.duplicate(true)
+ if not bool(record["muted"]) and not bool(record["blocked"]):
+ _records.erase(fingerprint)
+ else:
+ _records[fingerprint] = record
+ while _records.size() > MAX_RECORDS:
+ _records.erase(_records.keys().front())
+ if not _save():
+ _records = previous
+ return false
+ relationship_changed.emit(fingerprint)
+ return true
+
+
+func _valid_target(fingerprint: String, display_name: String) -> bool:
+ return (
+ NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+ and NetworkProfilePreferences.is_valid_display_name(display_name)
+ )
+
+
+func _ensure_loaded() -> void:
+ if _loaded:
+ return
+ _loaded = true
+ if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
+ return
+ var file := FileAccess.open(_store_path, FileAccess.READ)
+ if file == null:
+ return
+ var json := JSON.new()
+ if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return
+ var data: Dictionary = json.data
+ if data.get("format_version") != FORMAT_VERSION:
+ _write_blocked = true
+ return
+ for value: Variant in data.get("records", []):
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var record: Dictionary = value
+ var fingerprint := str(record.get("fingerprint", ""))
+ if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
+ continue
+ record["blocked"] = bool(record.get("blocked", false))
+ record["muted"] = bool(record.get("muted", false)) or record["blocked"]
+ _records[fingerprint] = record.duplicate(true)
+ _expected_hash = PortableFileGuard.hash_file(_store_path)
+
+
+func _save() -> bool:
+ var bytes := JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "records": _records.values(),
+ }, "\t").to_utf8_buffer()
+ var result := PortableFileGuard.write_guarded(
+ _store_path, bytes, _expected_hash, _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
diff --git a/network/player_relationship_store.gd.uid b/network/player_relationship_store.gd.uid
new file mode 100644
index 0000000..a63c807
--- /dev/null
+++ b/network/player_relationship_store.gd.uid
@@ -0,0 +1 @@
+uid://c71relationships01
diff --git a/network/player_spawn_service.gd b/network/player_spawn_service.gd
new file mode 100644
index 0000000..1352758
--- /dev/null
+++ b/network/player_spawn_service.gd
@@ -0,0 +1,102 @@
+class_name PlayerSpawnService
+extends Node
+
+const PlayerScene: PackedScene = preload("res://player/player.tscn")
+
+signal avatar_spawned(peer_id: int, avatar: Player)
+signal avatar_removed(peer_id: int)
+
+var _players_root: Node3D
+var _local_player: Player
+var _spawn_transform: Transform3D
+var _avatars: Dictionary[int, Player] = {}
+var _local_peer_id: int = 1
+
+
+func setup(
+ players_root: Node3D,
+ local_player: Player,
+ spawn_transform: Transform3D,
+) -> void:
+ _players_root = players_root
+ _local_player = local_player
+ _spawn_transform = spawn_transform
+
+
+func register_local_player(peer_id: int) -> void:
+ if _local_player == null:
+ return
+ for existing_id: int in _avatars.keys():
+ if _avatars[existing_id] == _local_player:
+ _avatars.erase(existing_id)
+ _local_peer_id = peer_id
+ _avatars[peer_id] = _local_player
+ _local_player.name = "Player_%d" % peer_id
+ _local_player.set_local_control(true)
+ _local_player.set_network_peer_id(peer_id)
+
+
+func spawn_remote_player(
+ peer_id: int,
+ transform: Transform3D,
+ authoritative_simulation: bool = false,
+) -> Player:
+ if peer_id <= 0 or peer_id == _local_peer_id:
+ return null
+ var existing: Player = _avatars.get(peer_id)
+ if existing != null and is_instance_valid(existing):
+ return existing
+ var avatar := PlayerScene.instantiate() as Player
+ avatar.name = "Player_%d" % peer_id
+ avatar.set_local_control(false)
+ avatar.set_network_peer_id(peer_id)
+ _players_root.add_child(avatar)
+ avatar.global_transform = transform
+ avatar.configure_network_remote(authoritative_simulation)
+ _avatars[peer_id] = avatar
+ avatar_spawned.emit(peer_id, avatar)
+ return avatar
+
+
+func remove_peer(peer_id: int) -> void:
+ if peer_id == _local_peer_id:
+ return
+ var avatar: Player = _avatars.get(peer_id)
+ _avatars.erase(peer_id)
+ if avatar != null and is_instance_valid(avatar):
+ avatar.queue_free()
+ avatar_removed.emit(peer_id)
+
+
+func clear_remote_players() -> void:
+ for peer_id: int in _avatars.keys():
+ if peer_id != _local_peer_id:
+ remove_peer(peer_id)
+
+
+func get_avatar(peer_id: int) -> Player:
+ return _avatars.get(peer_id)
+
+
+func set_peer_presentation_visible(peer_id: int, should_be_visible: bool) -> void:
+ var avatar: Player = _avatars.get(peer_id)
+ if avatar != null and is_instance_valid(avatar):
+ avatar.set_remote_presentation_visible(should_be_visible)
+
+
+func get_peer_ids() -> Array[int]:
+ var result: Array[int] = []
+ for peer_id: int in _avatars:
+ result.append(peer_id)
+ result.sort()
+ return result
+
+
+func get_spawn_transform_for_index(index: int) -> Transform3D:
+ if index <= 0:
+ return _spawn_transform
+ var angle: float = float(index) * 2.3999632297
+ var ring: float = 1.4 + floor(float(index - 1) / 8.0) * 1.2
+ var result: Transform3D = _spawn_transform
+ result.origin += Vector3(cos(angle) * ring, 0.0, sin(angle) * ring)
+ return result
diff --git a/network/player_spawn_service.gd.uid b/network/player_spawn_service.gd.uid
new file mode 100644
index 0000000..93d44f8
--- /dev/null
+++ b/network/player_spawn_service.gd.uid
@@ -0,0 +1 @@
+uid://8w70d0nrf4a7
diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd
new file mode 100644
index 0000000..ab8f21d
--- /dev/null
+++ b/network/portable_data_migration.gd
@@ -0,0 +1,335 @@
+class_name PortableDataMigration
+extends RefCounted
+
+const LEGACY_FILES := {
+ "player_save.json": "player/player_save.json",
+ "network_profile.json": "player/network_profile.json",
+ "player_appearance.json": "player/player_appearance.json",
+ "saved_servers.json": "social/saved_servers.json",
+ "known_players.json": "social/known_players.json",
+ "player_relationships.json": "social/player_relationships.json",
+ "server_trust.json": "social/server_trust.json",
+ "host_bans.json": "social/host_bans.json",
+}
+
+
+static func legacy_files_present() -> bool:
+ for filename: String in LEGACY_FILES:
+ if FileAccess.file_exists("user://".path_join(filename)):
+ return true
+ return false
+
+
+static func migrate_legacy_to(
+ data_root: PlayerDataRoot,
+ destination: String,
+) -> Dictionary:
+ var normalized: String = destination.simplify_path().trim_suffix("/")
+ if DirAccess.dir_exists_absolute(normalized):
+ var existing_manifest: String = normalized.path_join(
+ PlayerDataRoot.MANIFEST_FILENAME
+ )
+ if FileAccess.file_exists(existing_manifest):
+ return {
+ "ok": false,
+ "requires_existing_root_decision": true,
+ "message": "The selected folder already contains NETfishing data.",
+ }
+ if not _directory_empty(normalized):
+ return {
+ "ok": false,
+ "message": "Choose an empty folder or create a NETfishing subfolder.",
+ }
+ var staging: String = "%s.migration-%s" % [
+ normalized, Crypto.new().generate_random_bytes(8).hex_encode()
+ ]
+ if DirAccess.make_dir_recursive_absolute(staging) != OK:
+ return {"ok": false, "message": "Migration staging could not be created."}
+ var created: Dictionary = data_root.create_unbound_root(staging)
+ if not bool(created.get("ok", false)):
+ _remove_tree(staging)
+ return {"ok": false, "message": str(created.get("message", ""))}
+ var copied: Array[String] = []
+ for source_name: String in LEGACY_FILES:
+ var source: String = ProjectSettings.globalize_path(
+ "user://".path_join(source_name)
+ )
+ if not FileAccess.file_exists(source):
+ continue
+ var target: String = staging.path_join(str(LEGACY_FILES[source_name]))
+ var result: Dictionary = _copy_verified_json(source, target)
+ if not bool(result.get("ok", false)):
+ _remove_tree(staging)
+ return {
+ "ok": false,
+ "message": "Migration failed while validating %s." % source_name,
+ }
+ copied.append(source_name)
+ if DirAccess.dir_exists_absolute(normalized):
+ DirAccess.remove_absolute(normalized)
+ if DirAccess.rename_absolute(staging, normalized) != OK:
+ _remove_tree(staging)
+ return {"ok": false, "message": "Migration could not activate its destination."}
+ var manifest: Dictionary = _read_json(
+ normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
+ )
+ var migrated_root_id: String = str(manifest.get("root_id", ""))
+ if migrated_root_id.is_empty() or not data_root.use_existing_root(normalized):
+ return {"ok": false, "message": "Migration completed but could not switch roots."}
+ var recovery: String = ProjectSettings.globalize_path(
+ "user://migration-recovery"
+ ).path_join(
+ Time.get_datetime_string_from_system().replace(":", "-")
+ )
+ DirAccess.make_dir_recursive_absolute(recovery)
+ for source_name: String in copied:
+ _copy_bytes(
+ ProjectSettings.globalize_path("user://".path_join(source_name)),
+ recovery.path_join(source_name),
+ )
+ return {
+ "ok": true,
+ "message": "Player data moved successfully.",
+ "recovery_path": recovery,
+ }
+
+
+static func migrate_active_to(
+ data_root: PlayerDataRoot,
+ destination: String,
+) -> Dictionary:
+ var normalized: String = destination.simplify_path().trim_suffix("/")
+ if normalized == data_root.root_path:
+ return {"ok": true, "message": "This data folder is already active."}
+ if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized):
+ if FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
+ return {
+ "ok": false,
+ "requires_existing_root_decision": true,
+ "message": (
+ "The selected folder already contains NETfishing data. "
+ + "Choose it through the existing-data recovery flow."
+ ),
+ }
+ return {"ok": false, "message": "Choose an empty folder or a NETfishing subfolder."}
+ var staging: String = "%s.migration-%s" % [
+ normalized, Crypto.new().generate_random_bytes(8).hex_encode()
+ ]
+ if DirAccess.make_dir_recursive_absolute(staging) != OK:
+ return {"ok": false, "message": "Migration staging could not be created."}
+ var created: Dictionary = data_root.create_unbound_root(staging)
+ if not bool(created.get("ok", false)):
+ _remove_tree(staging)
+ return {"ok": false, "message": str(created.get("message", ""))}
+ for relative: String in [
+ "player/player_save.json",
+ "player/network_profile.json",
+ "player/player_appearance.json",
+ "social/saved_servers.json",
+ "social/known_players.json",
+ "social/player_relationships.json",
+ "social/server_trust.json",
+ "social/host_bans.json",
+ ]:
+ var source: String = data_root.root_path.path_join(relative)
+ if not FileAccess.file_exists(source):
+ continue
+ if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
+ _remove_tree(staging)
+ return {"ok": false, "message": "Migration validation failed for %s." % relative}
+ if DirAccess.dir_exists_absolute(normalized):
+ DirAccess.remove_absolute(normalized)
+ if DirAccess.rename_absolute(staging, normalized) != OK:
+ _remove_tree(staging)
+ return {"ok": false, "message": "Migration could not activate its destination."}
+ var old_root: String = data_root.root_path
+ if not data_root.use_existing_root(normalized):
+ return {"ok": false, "message": "Migration copied data but did not change the pointer."}
+ return {
+ "ok": true,
+ "message": "Data folder changed. Previous data was preserved.",
+ "previous_root": old_root,
+ }
+
+
+static func adopt_legacy_app_data(data_root: PlayerDataRoot) -> Dictionary:
+ var source_root: String = ProjectSettings.globalize_path("user://").trim_suffix("/")
+ var root: String = ProjectSettings.globalize_path(
+ PlayerDataRoot.APP_DATA_PORTABLE_PATH
+ )
+ if DirAccess.make_dir_recursive_absolute(root) != OK:
+ return {"ok": false, "message": "The app-data folder could not be created."}
+ var created: Dictionary = data_root.create_app_data_layout_for_migration(root)
+ if not bool(created.get("ok", false)):
+ return created
+ for source_name: String in LEGACY_FILES:
+ var source: String = source_root.path_join(source_name)
+ if not FileAccess.file_exists(source):
+ continue
+ var target: String = root.path_join(str(LEGACY_FILES[source_name]))
+ if not bool(_copy_verified_json(source, target).get("ok", false)):
+ return {
+ "ok": false,
+ "message": "Could not validate %s in app-data mode." % source_name,
+ }
+ if not data_root.use_existing_root(root):
+ return {"ok": false, "message": data_root.error_message}
+ return {
+ "ok": true,
+ "message": "Existing player data remains in app data.",
+ "recovery_path": source_root,
+ }
+
+
+static func replace_existing_with_legacy(
+ data_root: PlayerDataRoot,
+ destination: String,
+) -> Dictionary:
+ var normalized: String = destination.simplify_path().trim_suffix("/")
+ var manifest: String = normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
+ if not FileAccess.file_exists(manifest):
+ return {"ok": false, "message": "The selected folder is not a NETfishing data root."}
+ var recovery: String = ProjectSettings.globalize_path(
+ "user://migration-recovery"
+ ).path_join(
+ "replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
+ )
+ if not _copy_tree(normalized, recovery):
+ return {"ok": false, "message": "The selected data could not be backed up."}
+ _remove_tree(normalized)
+ var result: Dictionary = migrate_legacy_to(data_root, normalized)
+ if bool(result.get("ok", false)):
+ result["replaced_root_backup"] = recovery
+ return result
+
+
+static func replace_existing_with_active(
+ data_root: PlayerDataRoot,
+ destination: String,
+) -> Dictionary:
+ var normalized: String = destination.simplify_path().trim_suffix("/")
+ if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
+ return {"ok": false, "message": "The selected folder is not a NETfishing data root."}
+ var recovery: String = ProjectSettings.globalize_path(
+ "user://migration-recovery"
+ ).path_join(
+ "replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
+ )
+ if not _copy_tree(normalized, recovery):
+ return {"ok": false, "message": "The selected data could not be backed up."}
+ _remove_tree(normalized)
+ var result: Dictionary = migrate_active_to(data_root, normalized)
+ if bool(result.get("ok", false)):
+ result["replaced_root_backup"] = recovery
+ return result
+
+
+static func _copy_verified_json(source: String, destination: String) -> Dictionary:
+ var bytes: PackedByteArray = PortableFileGuard.read_bytes(source)
+ if bytes.is_empty() and FileAccess.get_open_error() != OK:
+ return {"ok": false}
+ var json: JSON = JSON.new()
+ if (
+ json.parse(bytes.get_string_from_utf8()) != OK
+ or typeof(json.data) != TYPE_DICTIONARY
+ or not _valid_owned_data(source.get_file(), json.data)
+ ):
+ return {"ok": false}
+ if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK:
+ return {"ok": false}
+ if not _write_bytes(destination, bytes):
+ return {"ok": false}
+ var copied: PackedByteArray = PortableFileGuard.read_bytes(destination)
+ return {
+ "ok": (
+ PortableFileGuard.hash_bytes(copied)
+ == PortableFileGuard.hash_bytes(bytes)
+ )
+ }
+
+
+static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
+ if filename == "player_save.json":
+ var version: int = int(data.get("save_version", -1))
+ return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION
+ return (
+ filename not in LEGACY_FILES
+ or int(data.get("format_version", -1)) == 1
+ )
+
+
+static func _copy_bytes(source: String, destination: String) -> bool:
+ return _write_bytes(destination, PortableFileGuard.read_bytes(source))
+
+
+static func _copy_tree(source: String, destination: String) -> bool:
+ if DirAccess.make_dir_recursive_absolute(destination) != OK:
+ return false
+ var access: DirAccess = DirAccess.open(source)
+ if access == null:
+ return false
+ access.list_dir_begin()
+ var name: String = access.get_next()
+ while not name.is_empty():
+ var from: String = source.path_join(name)
+ var to: String = destination.path_join(name)
+ if access.current_is_dir():
+ if not _copy_tree(from, to):
+ access.list_dir_end()
+ return false
+ elif not _copy_bytes(from, to):
+ access.list_dir_end()
+ return false
+ name = access.get_next()
+ access.list_dir_end()
+ return true
+
+
+static func _write_bytes(path: String, bytes: PackedByteArray) -> bool:
+ DirAccess.make_dir_recursive_absolute(path.get_base_dir())
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
+ if file == null:
+ return false
+ file.store_buffer(bytes)
+ file.flush()
+ var ok: bool = file.get_error() == OK
+ file.close()
+ return ok
+
+
+static func _read_json(path: String) -> Dictionary:
+ var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
+ var json: JSON = JSON.new()
+ return (
+ json.data
+ if json.parse(bytes.get_string_from_utf8()) == OK
+ and typeof(json.data) == TYPE_DICTIONARY
+ else {}
+ )
+
+
+static func _directory_empty(path: String) -> bool:
+ var access: DirAccess = DirAccess.open(path)
+ if access == null:
+ return true
+ access.list_dir_begin()
+ var name: String = access.get_next()
+ access.list_dir_end()
+ return name.is_empty()
+
+
+static func _remove_tree(path: String) -> void:
+ var access: DirAccess = DirAccess.open(path)
+ if access == null:
+ return
+ access.list_dir_begin()
+ var name: String = access.get_next()
+ while not name.is_empty():
+ var child: String = path.path_join(name)
+ if access.current_is_dir():
+ _remove_tree(child)
+ else:
+ DirAccess.remove_absolute(child)
+ name = access.get_next()
+ access.list_dir_end()
+ DirAccess.remove_absolute(path)
diff --git a/network/portable_data_migration.gd.uid b/network/portable_data_migration.gd.uid
new file mode 100644
index 0000000..1eb9103
--- /dev/null
+++ b/network/portable_data_migration.gd.uid
@@ -0,0 +1 @@
+uid://bjkaght4qyylm
diff --git a/network/portable_file_guard.gd b/network/portable_file_guard.gd
new file mode 100644
index 0000000..cb99ae1
--- /dev/null
+++ b/network/portable_file_guard.gd
@@ -0,0 +1,148 @@
+class_name PortableFileGuard
+extends RefCounted
+
+const MAX_PORTABLE_FILE_BYTES := 16 * 1024 * 1024
+
+
+static func hash_file(path: String) -> String:
+ if not FileAccess.file_exists(path):
+ return ""
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ if file == null or file.get_length() > MAX_PORTABLE_FILE_BYTES:
+ return ""
+ var bytes: PackedByteArray = file.get_buffer(file.get_length())
+ file.close()
+ return hash_bytes(bytes)
+
+
+static func hash_bytes(bytes: PackedByteArray) -> String:
+ var context: HashingContext = HashingContext.new()
+ var start_error: Error = context.start(HashingContext.HASH_SHA256)
+ if start_error != OK:
+ return ""
+ var update_error: Error = context.update(bytes)
+ if update_error != OK:
+ return ""
+ return context.finish().hex_encode()
+
+
+static func read_bytes(path: String, maximum_bytes: int = MAX_PORTABLE_FILE_BYTES) -> PackedByteArray:
+ if not FileAccess.file_exists(path):
+ return PackedByteArray()
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
+ if file == null or file.get_length() > maximum_bytes:
+ return PackedByteArray()
+ var bytes: PackedByteArray = file.get_buffer(file.get_length())
+ file.close()
+ return bytes
+
+
+static func write_guarded(
+ path: String,
+ bytes: PackedByteArray,
+ expected_hash: String,
+ conflict_directory: String,
+ device_id: String,
+) -> Dictionary:
+ var current_exists: bool = FileAccess.file_exists(path)
+ var current_hash: String = hash_file(path) if current_exists else ""
+ if current_hash != expected_hash:
+ var conflict_path: String = _write_conflict_copy(
+ path, bytes, conflict_directory, device_id
+ )
+ return {
+ "ok": false,
+ "conflict": true,
+ "hash": expected_hash,
+ "conflict_path": conflict_path,
+ "message": (
+ "This file changed on another device.\n"
+ + "Your current data was preserved as a conflict copy."
+ ),
+ }
+ if not _ensure_parent(path):
+ return {"ok": false, "conflict": false, "hash": expected_hash}
+ var temporary: String = path + ".tmp"
+ var backup: String = path + ".backup"
+ var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
+ if file == null:
+ return {"ok": false, "conflict": false, "hash": expected_hash}
+ file.store_buffer(bytes)
+ file.flush()
+ var write_error: Error = file.get_error()
+ file.close()
+ if write_error != OK:
+ _remove(temporary)
+ return {"ok": false, "conflict": false, "hash": expected_hash}
+ _remove(backup)
+ if current_exists and not _rename(path, backup):
+ _remove(temporary)
+ return {"ok": false, "conflict": false, "hash": expected_hash}
+ if not _rename(temporary, path):
+ if current_exists:
+ _rename(backup, path)
+ return {"ok": false, "conflict": false, "hash": expected_hash}
+ _remove(backup)
+ return {
+ "ok": true,
+ "conflict": false,
+ "hash": hash_bytes(bytes),
+ "conflict_path": "",
+ }
+
+
+static func has_syncthing_conflict(directory: String) -> bool:
+ var access: DirAccess = DirAccess.open(directory)
+ if access == null:
+ return false
+ access.list_dir_begin()
+ var name: String = access.get_next()
+ while not name.is_empty():
+ var lower: String = name.to_lower()
+ if "sync-conflict" in lower or ".syncthing." in lower:
+ access.list_dir_end()
+ return true
+ name = access.get_next()
+ access.list_dir_end()
+ return false
+
+
+static func _write_conflict_copy(
+ canonical_path: String,
+ bytes: PackedByteArray,
+ conflict_directory: String,
+ device_id: String,
+) -> String:
+ if DirAccess.make_dir_recursive_absolute(conflict_directory) != OK:
+ return ""
+ var filename: String = canonical_path.get_file()
+ var safe_device: String = device_id.left(16)
+ var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
+ var destination: String = conflict_directory.path_join(
+ "%s.local-%s-%s" % [filename, safe_device, timestamp]
+ )
+ var file: FileAccess = FileAccess.open(destination, FileAccess.WRITE)
+ if file == null:
+ return ""
+ file.store_buffer(bytes)
+ file.flush()
+ var ok: bool = file.get_error() == OK
+ file.close()
+ return destination if ok else ""
+
+
+static func _ensure_parent(path: String) -> bool:
+ var parent: String = path.get_base_dir()
+ return (
+ DirAccess.dir_exists_absolute(parent)
+ or DirAccess.make_dir_recursive_absolute(parent) == OK
+ )
+
+
+static func _rename(from_path: String, to_path: String) -> bool:
+ return DirAccess.rename_absolute(from_path, to_path) == OK
+
+
+static func _remove(path: String) -> void:
+ if FileAccess.file_exists(path):
+ DirAccess.remove_absolute(path)
diff --git a/network/portable_file_guard.gd.uid b/network/portable_file_guard.gd.uid
new file mode 100644
index 0000000..ea9a4b8
--- /dev/null
+++ b/network/portable_file_guard.gd.uid
@@ -0,0 +1 @@
+uid://biw72bdyctluv
diff --git a/network/saved_server_entry.gd b/network/saved_server_entry.gd
new file mode 100644
index 0000000..00052bc
--- /dev/null
+++ b/network/saved_server_entry.gd
@@ -0,0 +1,88 @@
+class_name SavedServerEntry
+extends RefCounted
+
+enum Kind {
+ SAVED,
+ RECENT,
+}
+
+var kind: Kind = Kind.SAVED
+var entry_id: String = ""
+var display_name: String = ""
+var route_kind: String = "DIRECT"
+var host: String = ""
+var normalized_host: String = ""
+var port: int = EndpointParser.DEFAULT_PORT
+var normalized_endpoint: String = ""
+var favorite: bool = false
+var created_at_unix: int = 0
+var updated_at_unix: int = 0
+var last_success_at_unix: int = 0
+var last_result_code: String = ""
+var last_observed_server_name: String = ""
+var last_observed_protocol_version: int = 0
+var last_observed_player_count: int = 0
+var last_observed_max_players: int = 0
+
+
+static func from_dictionary(
+ data: Dictionary,
+ entry_kind: Kind,
+) -> SavedServerEntry:
+ var entry := SavedServerEntry.new()
+ entry.kind = entry_kind
+ entry.entry_id = str(data.get("entry_id", ""))
+ entry.display_name = str(data.get("display_name", ""))
+ entry.route_kind = str(data.get("route_kind", "DIRECT"))
+ entry.host = str(data.get("host", ""))
+ entry.normalized_host = str(data.get("normalized_host", entry.host))
+ entry.port = int(data.get("port", EndpointParser.DEFAULT_PORT))
+ entry.normalized_endpoint = str(data.get("normalized_endpoint", ""))
+ entry.favorite = bool(data.get("favorite", false))
+ entry.created_at_unix = int(data.get("created_at_unix", 0))
+ entry.updated_at_unix = int(data.get("updated_at_unix", 0))
+ entry.last_success_at_unix = int(data.get("last_success_at_unix", 0))
+ entry.last_result_code = str(data.get("last_result_code", ""))
+ entry.last_observed_server_name = str(
+ data.get("last_observed_server_name", "")
+ )
+ entry.last_observed_protocol_version = int(
+ data.get("last_observed_protocol_version", 0)
+ )
+ entry.last_observed_player_count = int(
+ data.get("last_observed_player_count", 0)
+ )
+ entry.last_observed_max_players = int(
+ data.get("last_observed_max_players", 0)
+ )
+ return entry
+
+
+func to_dictionary() -> Dictionary:
+ return {
+ "entry_id": entry_id,
+ "display_name": display_name,
+ "route_kind": route_kind,
+ "host": host,
+ "normalized_host": normalized_host,
+ "port": port,
+ "normalized_endpoint": normalized_endpoint,
+ "favorite": favorite,
+ "created_at_unix": created_at_unix,
+ "updated_at_unix": updated_at_unix,
+ "last_success_at_unix": last_success_at_unix,
+ "last_result_code": last_result_code,
+ "last_observed_server_name": last_observed_server_name,
+ "last_observed_protocol_version": last_observed_protocol_version,
+ "last_observed_player_count": last_observed_player_count,
+ "last_observed_max_players": last_observed_max_players,
+ }
+
+
+func get_endpoint() -> ConnectionEndpoint:
+ var endpoint_text: String = (
+ "[%s]:%d" % [host, port]
+ if host.contains(":")
+ else "%s:%d" % [host, port]
+ )
+ return EndpointParser.parse(endpoint_text)
diff --git a/network/saved_server_entry.gd.uid b/network/saved_server_entry.gd.uid
new file mode 100644
index 0000000..97f6372
--- /dev/null
+++ b/network/saved_server_entry.gd.uid
@@ -0,0 +1 @@
+uid://b3guw5fet7lv0
diff --git a/network/saved_server_store.gd b/network/saved_server_store.gd
new file mode 100644
index 0000000..de48c2c
--- /dev/null
+++ b/network/saved_server_store.gd
@@ -0,0 +1,462 @@
+class_name SavedServerStore
+extends Node
+
+const FORMAT_VERSION: int = 1
+const MAX_SAVED_ENTRIES: int = 100
+const MAX_RECENT_ENTRIES: int = 20
+const MAX_DISPLAY_NAME_LENGTH: int = 80
+
+signal data_changed
+signal recovery_warning_changed(message: String)
+
+var _saved_entries: Array[Dictionary] = []
+var _recent_entries: Array[Dictionary] = []
+var _loaded: bool = false
+var _recovery_warning: String = ""
+var _write_blocked: bool = false
+var _store_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _store_path = path
+ _data_root = data_root
+
+
+func _temp_path() -> String:
+ return _store_path + ".tmp"
+
+
+func _backup_path() -> String:
+ return _store_path + ".backup"
+
+
+func get_saved_entries() -> Array[SavedServerEntry]:
+ _ensure_loaded()
+ var result: Array[SavedServerEntry] = []
+ for data: Dictionary in _saved_entries:
+ result.append(SavedServerEntry.from_dictionary(
+ data, SavedServerEntry.Kind.SAVED
+ ))
+ result.sort_custom(_sort_saved_entries)
+ return result
+
+
+func get_recent_entries() -> Array[SavedServerEntry]:
+ _ensure_loaded()
+ var result: Array[SavedServerEntry] = []
+ for data: Dictionary in _recent_entries:
+ result.append(SavedServerEntry.from_dictionary(
+ data, SavedServerEntry.Kind.RECENT
+ ))
+ result.sort_custom(func(a: SavedServerEntry, b: SavedServerEntry) -> bool:
+ return a.last_success_at_unix > b.last_success_at_unix
+ )
+ return result
+
+
+func list_entries() -> Array[Dictionary]:
+ _ensure_loaded()
+ return _saved_entries.duplicate(true)
+
+
+func get_recovery_warning() -> String:
+ _ensure_loaded()
+ return _recovery_warning
+
+
+func find_saved_by_endpoint(
+ endpoint: ConnectionEndpoint,
+) -> SavedServerEntry:
+ _ensure_loaded()
+ if endpoint == null or not endpoint.is_valid():
+ return null
+ for data: Dictionary in _saved_entries:
+ if data.get("normalized_endpoint", "") == endpoint.normalized_display:
+ return SavedServerEntry.from_dictionary(
+ data, SavedServerEntry.Kind.SAVED
+ )
+ return null
+
+
+func find_by_normalized_endpoint(value: String) -> Dictionary:
+ var endpoint := EndpointParser.parse(value)
+ var entry: SavedServerEntry = find_saved_by_endpoint(endpoint)
+ return entry.to_dictionary() if entry != null else {}
+
+
+func save_or_update_entry(
+ display_name: String,
+ endpoint: ConnectionEndpoint,
+ entry_id: String = "",
+) -> SavedServerEntry:
+ _ensure_loaded()
+ var clean_name: String = display_name.strip_edges()
+ if (
+ endpoint == null
+ or not endpoint.is_valid()
+ or clean_name.is_empty()
+ or clean_name.length() > MAX_DISPLAY_NAME_LENGTH
+ ):
+ return null
+ var now: int = int(Time.get_unix_time_from_system())
+ var target: Dictionary = {}
+ for data: Dictionary in _saved_entries:
+ if not entry_id.is_empty() and data.get("entry_id", "") == entry_id:
+ target = data
+ break
+ for data: Dictionary in _saved_entries:
+ if (
+ data.get("normalized_endpoint", "") == endpoint.normalized_display
+ and data != target
+ ):
+ return null
+ if target.is_empty():
+ if _saved_entries.size() >= MAX_SAVED_ENTRIES:
+ return null
+ target = _make_entry(endpoint, SavedServerEntry.Kind.SAVED)
+ _saved_entries.append(target)
+ target["display_name"] = clean_name
+ target["host"] = endpoint.host
+ target["normalized_host"] = endpoint.host
+ target["port"] = endpoint.port
+ target["normalized_endpoint"] = endpoint.normalized_display
+ target["updated_at_unix"] = now
+ if not _save_atomic():
+ return null
+ data_changed.emit()
+ return SavedServerEntry.from_dictionary(
+ target, SavedServerEntry.Kind.SAVED
+ )
+
+
+func save_entry(display_name: String, endpoint: ConnectionEndpoint) -> bool:
+ return save_or_update_entry(display_name, endpoint) != null
+
+
+func remove_saved_entry(entry_id: String) -> bool:
+ _ensure_loaded()
+ for index: int in range(_saved_entries.size()):
+ if _saved_entries[index].get("entry_id", "") == entry_id:
+ _saved_entries.remove_at(index)
+ if not _save_atomic():
+ return false
+ data_changed.emit()
+ return true
+ return false
+
+
+func remove_entry(entry_id: String) -> bool:
+ return remove_saved_entry(entry_id)
+
+
+func set_favorite(entry_id: String, favorite: bool) -> bool:
+ _ensure_loaded()
+ for data: Dictionary in _saved_entries:
+ if data.get("entry_id", "") == entry_id:
+ data["favorite"] = favorite
+ data["updated_at_unix"] = int(Time.get_unix_time_from_system())
+ if not _save_atomic():
+ return false
+ data_changed.emit()
+ return true
+ return false
+
+
+func record_successful_connection(
+ endpoint: ConnectionEndpoint,
+ observed_max_players: int,
+ server_name: String = "",
+ protocol_version: int = 0,
+ player_count: int = 0,
+) -> bool:
+ _ensure_loaded()
+ if endpoint == null or not endpoint.is_valid():
+ return false
+ var now: int = int(Time.get_unix_time_from_system())
+ for data: Dictionary in _saved_entries:
+ if data.get("normalized_endpoint", "") == endpoint.normalized_display:
+ _apply_result_metadata(
+ data, now, "SUCCESS", server_name, protocol_version,
+ player_count, observed_max_players
+ )
+ var recent: Dictionary = {}
+ for data: Dictionary in _recent_entries:
+ if data.get("normalized_endpoint", "") == endpoint.normalized_display:
+ recent = data
+ break
+ if recent.is_empty():
+ recent = _make_entry(endpoint, SavedServerEntry.Kind.RECENT)
+ _recent_entries.append(recent)
+ recent["display_name"] = (
+ server_name if not server_name.is_empty() else endpoint.normalized_display
+ )
+ _apply_result_metadata(
+ recent, now, "SUCCESS", server_name, protocol_version,
+ player_count, observed_max_players
+ )
+ _recent_entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ return int(a.get("last_success_at_unix", 0)) > int(
+ b.get("last_success_at_unix", 0)
+ )
+ )
+ if _recent_entries.size() > MAX_RECENT_ENTRIES:
+ _recent_entries.resize(MAX_RECENT_ENTRIES)
+ if not _save_atomic():
+ return false
+ data_changed.emit()
+ return true
+
+
+func record_connection_failure(
+ endpoint: ConnectionEndpoint,
+ result_code: String,
+) -> bool:
+ _ensure_loaded()
+ if endpoint == null or not endpoint.is_valid():
+ return false
+ for data: Dictionary in _saved_entries:
+ if data.get("normalized_endpoint", "") == endpoint.normalized_display:
+ data["last_result_code"] = result_code.left(48)
+ data["updated_at_unix"] = int(Time.get_unix_time_from_system())
+ if not _save_atomic():
+ return false
+ data_changed.emit()
+ break
+ return true
+
+
+func remove_recent_entry(entry_id: String) -> bool:
+ _ensure_loaded()
+ for index: int in range(_recent_entries.size()):
+ if _recent_entries[index].get("entry_id", "") == entry_id:
+ _recent_entries.remove_at(index)
+ if not _save_atomic():
+ return false
+ data_changed.emit()
+ return true
+ return false
+
+
+func clear_recent_entries() -> bool:
+ _ensure_loaded()
+ if _recent_entries.is_empty():
+ return true
+ _recent_entries.clear()
+ if not _save_atomic():
+ return false
+ data_changed.emit()
+ return true
+
+
+func _ensure_loaded() -> void:
+ if _loaded:
+ return
+ _loaded = true
+ _recover_interrupted_write()
+ if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
+ return
+ var data: Dictionary = _read_store(_store_path)
+ if data.is_empty() and FileAccess.file_exists(_backup_path()):
+ data = _read_store(_backup_path())
+ if not data.is_empty():
+ _set_warning("Recovered saved servers from the local backup.")
+ if data.is_empty():
+ if FileAccess.file_exists(_store_path):
+ _set_warning(
+ "Saved servers could not be read. Direct connection is still available."
+ )
+ return
+ if data.get("format_version") != FORMAT_VERSION:
+ _write_blocked = true
+ _set_warning(
+ "Saved servers use a newer format. Direct connection is still available."
+ )
+ return
+ _load_collection(data.get("saved_entries", data.get("entries", [])), true)
+ _load_collection(data.get("recent_entries", []), false)
+ _expected_hash = PortableFileGuard.hash_file(_store_path)
+
+
+func _load_collection(raw: Variant, is_saved: bool) -> void:
+ if typeof(raw) != TYPE_ARRAY:
+ _set_warning("Some saved-server data was unavailable.")
+ return
+ var target: Array[Dictionary] = (
+ _saved_entries if is_saved else _recent_entries
+ )
+ var seen: Dictionary[String, bool] = {}
+ for value: Variant in raw:
+ if typeof(value) != TYPE_DICTIONARY:
+ _set_warning("Some malformed server entries were skipped.")
+ continue
+ var validated: Dictionary = _validate_entry(value, is_saved)
+ if validated.is_empty():
+ _set_warning("Some malformed server entries were skipped.")
+ continue
+ var identity: String = validated["normalized_endpoint"]
+ if seen.has(identity):
+ continue
+ seen[identity] = true
+ target.append(validated)
+ if target.size() >= (
+ MAX_SAVED_ENTRIES if is_saved else MAX_RECENT_ENTRIES
+ ):
+ break
+
+
+func _validate_entry(value: Dictionary, is_saved: bool) -> Dictionary:
+ if (
+ typeof(value.get("entry_id")) != TYPE_STRING
+ or str(value.get("entry_id", "")).is_empty()
+ or typeof(value.get("host")) != TYPE_STRING
+ ):
+ return {}
+ var stored_host: String = str(value.get("host", ""))
+ var stored_port: int = int(value.get("port", 0))
+ var endpoint_text: String = (
+ "[%s]:%d" % [stored_host, stored_port]
+ if stored_host.contains(":")
+ else "%s:%d" % [stored_host, stored_port]
+ )
+ var endpoint := EndpointParser.parse(endpoint_text)
+ if not endpoint.is_valid():
+ return {}
+ var display_name: String = str(
+ value.get("display_name", "")
+ ).strip_edges()
+ if is_saved and (
+ display_name.is_empty()
+ or display_name.length() > MAX_DISPLAY_NAME_LENGTH
+ ):
+ return {}
+ var result: Dictionary = _make_entry(
+ endpoint,
+ SavedServerEntry.Kind.SAVED if is_saved else SavedServerEntry.Kind.RECENT
+ )
+ for key: String in result:
+ if value.has(key) and typeof(value[key]) == typeof(result[key]):
+ result[key] = value[key]
+ result["host"] = endpoint.host
+ result["normalized_host"] = endpoint.host
+ result["port"] = endpoint.port
+ result["normalized_endpoint"] = endpoint.normalized_display
+ return result
+
+
+func _make_entry(
+ endpoint: ConnectionEndpoint,
+ kind: SavedServerEntry.Kind,
+) -> Dictionary:
+ var now: int = int(Time.get_unix_time_from_system())
+ return {
+ "entry_id": Crypto.new().generate_random_bytes(16).hex_encode(),
+ "display_name": endpoint.host,
+ "route_kind": "DIRECT",
+ "host": endpoint.host,
+ "normalized_host": endpoint.host,
+ "port": endpoint.port,
+ "normalized_endpoint": endpoint.normalized_display,
+ "favorite": false,
+ "created_at_unix": now,
+ "updated_at_unix": now,
+ "last_success_at_unix": 0,
+ "last_result_code": "",
+ "last_observed_server_name": "",
+ "last_observed_protocol_version": 0,
+ "last_observed_player_count": 0,
+ "last_observed_max_players": 0,
+ "entry_kind": int(kind),
+ }
+
+
+func _apply_result_metadata(
+ data: Dictionary,
+ now: int,
+ result_code: String,
+ server_name: String,
+ protocol_version: int,
+ player_count: int,
+ max_players: int,
+) -> void:
+ data["last_success_at_unix"] = now
+ data["updated_at_unix"] = now
+ data["last_result_code"] = result_code
+ data["last_observed_server_name"] = server_name.left(80)
+ data["last_observed_protocol_version"] = maxi(protocol_version, 0)
+ data["last_observed_player_count"] = maxi(player_count, 0)
+ data["last_observed_max_players"] = maxi(max_players, 0)
+
+
+func _sort_saved_entries(
+ a: SavedServerEntry,
+ b: SavedServerEntry,
+) -> bool:
+ if a.favorite != b.favorite:
+ return a.favorite
+ if a.last_success_at_unix != b.last_success_at_unix:
+ return a.last_success_at_unix > b.last_success_at_unix
+ var name_compare: int = a.display_name.naturalnocasecmp_to(b.display_name)
+ if name_compare != 0:
+ return name_compare < 0
+ return a.entry_id < b.entry_id
+
+
+func _save_atomic() -> bool:
+ if _write_blocked:
+ return false
+ var bytes := JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "saved_entries": _saved_entries,
+ "recent_entries": _recent_entries,
+ }, "\t").to_utf8_buffer()
+ var result := PortableFileGuard.write_guarded(
+ _store_path, bytes, _expected_hash, _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
+
+
+func _read_store(path: String) -> Dictionary:
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ return {}
+ var json := JSON.new()
+ var error: Error = json.parse(file.get_as_text())
+ file.close()
+ return json.data if error == OK and typeof(json.data) == TYPE_DICTIONARY else {}
+
+
+func _recover_interrupted_write() -> void:
+ if FileAccess.file_exists(_store_path):
+ _remove_if_present(_temp_path())
+ return
+ if FileAccess.file_exists(_backup_path()):
+ _rename(_backup_path(), _store_path)
+ _set_warning("Recovered saved servers after an interrupted write.")
+ _remove_if_present(_temp_path())
+
+
+func _set_warning(message: String) -> void:
+ if _recovery_warning.is_empty():
+ _recovery_warning = message
+ recovery_warning_changed.emit(_recovery_warning)
+
+
+func _rename(from_path: String, to_path: String) -> bool:
+ return DirAccess.rename_absolute(
+ ProjectSettings.globalize_path(from_path),
+ ProjectSettings.globalize_path(to_path)
+ ) == OK
+
+
+func _remove_if_present(path: String) -> bool:
+ return (
+ not FileAccess.file_exists(path)
+ or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
+ )
diff --git a/network/saved_server_store.gd.uid b/network/saved_server_store.gd.uid
new file mode 100644
index 0000000..b07aac2
--- /dev/null
+++ b/network/saved_server_store.gd.uid
@@ -0,0 +1 @@
+uid://b7j0er0u1nwy7
diff --git a/network/server_trust_store.gd b/network/server_trust_store.gd
new file mode 100644
index 0000000..fda774b
--- /dev/null
+++ b/network/server_trust_store.gd
@@ -0,0 +1,128 @@
+class_name ServerTrustStore
+extends Node
+
+const FORMAT_VERSION: int = 1
+enum Verification {
+ FIRST_SEEN,
+ MATCH,
+ CHANGED,
+}
+
+var _records: Dictionary = {}
+var _loaded: bool = false
+var _write_blocked: bool = false
+var _store_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _store_path = path
+ _data_root = data_root
+
+
+func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification:
+ _ensure_loaded()
+ if endpoint == null or not endpoint.is_valid():
+ return Verification.CHANGED
+ var record: Dictionary = _records.get(endpoint.normalized_display, {})
+ if record.is_empty():
+ return Verification.FIRST_SEEN
+ return (
+ Verification.MATCH
+ if record.get("fingerprint") == fingerprint
+ else Verification.CHANGED
+ )
+
+
+func get_record(endpoint: ConnectionEndpoint) -> Dictionary:
+ _ensure_loaded()
+ if endpoint == null:
+ return {}
+ return Dictionary(_records.get(endpoint.normalized_display, {})).duplicate(true)
+
+
+func trust(
+ endpoint: ConnectionEndpoint,
+ fingerprint: String,
+ server_name: String = "",
+) -> bool:
+ _ensure_loaded()
+ if (
+ endpoint == null
+ or not endpoint.is_valid()
+ or not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
+ ):
+ return false
+ var now := int(Time.get_unix_time_from_system())
+ var previous: Dictionary = _records.get(endpoint.normalized_display, {})
+ _records[endpoint.normalized_display] = {
+ "route_kind": "direct",
+ "normalized_host": endpoint.host,
+ "port": endpoint.port,
+ "normalized_endpoint": endpoint.normalized_display,
+ "fingerprint": fingerprint,
+ "first_seen_unix": int(previous.get("first_seen_unix", now)),
+ "last_seen_unix": now,
+ "last_observed_server_name": server_name.left(80),
+ "trust_state": "pinned",
+ }
+ return _save()
+
+
+func touch(endpoint: ConnectionEndpoint) -> void:
+ _ensure_loaded()
+ if endpoint == null:
+ return
+ var record: Dictionary = _records.get(endpoint.normalized_display, {})
+ if record.is_empty():
+ return
+ record["last_seen_unix"] = int(Time.get_unix_time_from_system())
+ _save()
+
+
+func _ensure_loaded() -> void:
+ if _loaded:
+ return
+ _loaded = true
+ if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
+ return
+ var file := FileAccess.open(_store_path, FileAccess.READ)
+ if file == null:
+ return
+ var json := JSON.new()
+ if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return
+ var data: Dictionary = json.data
+ if data.get("format_version") != FORMAT_VERSION:
+ _write_blocked = true
+ return
+ if typeof(data.get("records")) != TYPE_ARRAY:
+ return
+ for value: Variant in data["records"]:
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var record: Dictionary = value
+ var endpoint := str(record.get("normalized_endpoint", ""))
+ var fingerprint := str(record.get("fingerprint", ""))
+ if not endpoint.is_empty() and NetworkIdentityCrypto.valid_fingerprint(fingerprint):
+ _records[endpoint] = record.duplicate(true)
+ _expected_hash = PortableFileGuard.hash_file(_store_path)
+
+
+func _save() -> bool:
+ if _write_blocked:
+ return false
+ var bytes := JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "records": _records.values(),
+ }, "\t").to_utf8_buffer()
+ var result := PortableFileGuard.write_guarded(
+ _store_path, bytes, _expected_hash, _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
diff --git a/network/server_trust_store.gd.uid b/network/server_trust_store.gd.uid
new file mode 100644
index 0000000..71ce1df
--- /dev/null
+++ b/network/server_trust_store.gd.uid
@@ -0,0 +1 @@
+uid://cp2kam2btjkb0
diff --git a/player/animalese_voice_profiles.gd b/player/animalese_voice_profiles.gd
new file mode 100644
index 0000000..2ea6cab
--- /dev/null
+++ b/player/animalese_voice_profiles.gd
@@ -0,0 +1,81 @@
+class_name AnimaleseVoiceProfiles
+extends RefCounted
+
+const DEFAULT_ID: String = "natural"
+const DEFAULT_SPEED_ID: String = "normal"
+const DEFAULT_CALL_ID: String = "meow"
+const CALL_AUDIO_DIRECTORY: String = "res://sound/dialogue/calls"
+const OPTIONS: Array[Dictionary] = [
+ {"id": "tiny", "label": "tiny", "pitch": 1.24},
+ {"id": "bright", "label": "bright", "pitch": 1.10},
+ {"id": "natural", "label": "natural", "pitch": 1.0},
+ {"id": "mellow", "label": "mellow", "pitch": 0.89},
+ {"id": "deep", "label": "deep", "pitch": 0.77},
+]
+const SPEED_OPTIONS: Array[Dictionary] = [
+ {"id": "slow", "label": "slow", "characters_per_second": 20.0},
+ {"id": "relaxed", "label": "relaxed", "characters_per_second": 24.0},
+ {"id": "normal", "label": "normal", "characters_per_second": 28.0},
+ {"id": "quick", "label": "quick", "characters_per_second": 34.0},
+ {"id": "rapid", "label": "rapid", "characters_per_second": 40.0},
+]
+const CALL_OPTIONS: Array[Dictionary] = [
+ {"id": "meow", "label": "meow"},
+ {"id": "bark", "label": "bark"},
+]
+
+
+static func is_valid(voice_id: String) -> bool:
+ for option: Dictionary in OPTIONS:
+ if str(option.get("id", "")) == voice_id:
+ return true
+ return false
+
+
+static func sanitized_id(voice_id: String) -> String:
+ return voice_id if is_valid(voice_id) else DEFAULT_ID
+
+
+static func pitch_for(voice_id: String) -> float:
+ var resolved_id := sanitized_id(voice_id)
+ for option: Dictionary in OPTIONS:
+ if str(option.get("id", "")) == resolved_id:
+ return float(option.get("pitch", 1.0))
+ return 1.0
+
+
+static func is_valid_speed(speed_id: String) -> bool:
+ for option: Dictionary in SPEED_OPTIONS:
+ if str(option.get("id", "")) == speed_id:
+ return true
+ return false
+
+
+static func sanitized_speed_id(speed_id: String) -> String:
+ return speed_id if is_valid_speed(speed_id) else DEFAULT_SPEED_ID
+
+
+static func speed_for(speed_id: String) -> float:
+ var resolved_id := sanitized_speed_id(speed_id)
+ for option: Dictionary in SPEED_OPTIONS:
+ if str(option.get("id", "")) == resolved_id:
+ return float(option.get("characters_per_second", 28.0))
+ return 28.0
+
+
+static func is_valid_call(call_id: String) -> bool:
+ for option: Dictionary in CALL_OPTIONS:
+ if str(option.get("id", "")) == call_id:
+ return true
+ return false
+
+
+static func sanitized_call_id(call_id: String) -> String:
+ return call_id if is_valid_call(call_id) else DEFAULT_CALL_ID
+
+
+static func call_audio_path(call_id: String) -> String:
+ return "%s/%s.wav" % [
+ CALL_AUDIO_DIRECTORY,
+ sanitized_call_id(call_id),
+ ]
diff --git a/player/animalese_voice_profiles.gd.uid b/player/animalese_voice_profiles.gd.uid
new file mode 100644
index 0000000..1817007
--- /dev/null
+++ b/player/animalese_voice_profiles.gd.uid
@@ -0,0 +1 @@
+uid://dlut32tq2v66j
diff --git a/player/fishing_rod_attachment.tscn b/player/fishing_rod_attachment.tscn
new file mode 100644
index 0000000..46da419
--- /dev/null
+++ b/player/fishing_rod_attachment.tscn
@@ -0,0 +1,23 @@
+[gd_scene load_steps=3 format=3]
+
+[sub_resource type="CylinderMesh" id="FishingRodMesh"]
+top_radius = 0.025
+bottom_radius = 0.045
+height = 1.2
+
+[sub_resource type="StandardMaterial3D" id="FishingRodMaterial"]
+albedo_color = Color(0.22, 0.12, 0.06, 1)
+roughness = 0.8
+
+[node name="FishingRodAttachment" type="BoneAttachment3D"]
+bone_name = "rod_socket"
+
+[node name="FishingRod" type="Node3D" parent="."]
+
+[node name="RodMesh" type="MeshInstance3D" parent="FishingRod"]
+position = Vector3(0, 0.5, 0)
+mesh = SubResource("FishingRodMesh")
+material_override = SubResource("FishingRodMaterial")
+
+[node name="FishingRodTip" type="Marker3D" parent="FishingRod"]
+position = Vector3(0, 1.1, 0)
diff --git a/player/fur_pattern.gdshader b/player/fur_pattern.gdshader
new file mode 100644
index 0000000..aad013f
--- /dev/null
+++ b/player/fur_pattern.gdshader
@@ -0,0 +1,27 @@
+shader_type spatial;
+render_mode unshaded, cull_back;
+
+// Pattern PNGs are data maps rather than colored artwork. Black selects the
+// base color and the red, green, and blue channels select the three authored
+// accent colors. Alpha is intentionally ignored so artists may leave unused
+// UV space transparent.
+uniform sampler2D pattern_texture : filter_nearest, repeat_disable;
+uniform vec4 base_color : source_color = vec4(1.0);
+uniform vec4 red_zone_color : source_color = vec4(0.9, 0.8, 0.6, 1.0);
+uniform vec4 green_zone_color : source_color = vec4(0.4, 0.2, 0.1, 1.0);
+uniform vec4 blue_zone_color : source_color = vec4(0.7, 0.2, 0.15, 1.0);
+
+void fragment() {
+ vec3 zone_weights = max(texture(pattern_texture, UV).rgb, vec3(0.0));
+ float zone_total = zone_weights.r + zone_weights.g + zone_weights.b;
+ // Filtering between two authored zone colors can produce a sum near one.
+ // Normalize only an overfull sample, retaining smooth black-to-zone edges.
+ zone_weights /= max(zone_total, 1.0);
+ float base_weight = 1.0 - min(zone_total, 1.0);
+ ALBEDO = (
+ base_color.rgb * base_weight
+ + red_zone_color.rgb * zone_weights.r
+ + green_zone_color.rgb * zone_weights.g
+ + blue_zone_color.rgb * zone_weights.b
+ );
+}
diff --git a/player/fur_pattern.gdshader.uid b/player/fur_pattern.gdshader.uid
new file mode 100644
index 0000000..e16692e
--- /dev/null
+++ b/player/fur_pattern.gdshader.uid
@@ -0,0 +1 @@
+uid://eoeegcj00qy8
diff --git a/player/held_item_attachment.tscn b/player/held_item_attachment.tscn
new file mode 100644
index 0000000..66b3b7c
--- /dev/null
+++ b/player/held_item_attachment.tscn
@@ -0,0 +1,44 @@
+[gd_scene format=3]
+
+[node name="HeldItemAttachment" type="BoneAttachment3D"]
+bone_name = "hand.R"
+
+[node name="HeldFishDisplay" type="Node3D" parent="."]
+unique_name_in_owner = true
+position = Vector3(0, 0.08, 0.04)
+top_level = true
+visible = false
+
+[node name="HeldFishSprite" type="Sprite3D" parent="HeldFishDisplay"]
+unique_name_in_owner = true
+pixel_size = 0.012
+shaded = false
+double_sided = true
+texture_filter = 0
+
+[node name="HeldArtKitDisplay" type="Node3D" parent="."]
+unique_name_in_owner = true
+position = Vector3(0, 0.08, 0.04)
+visible = false
+
+[node name="HeldArtKitSprite" type="Sprite3D" parent="HeldArtKitDisplay"]
+unique_name_in_owner = true
+rotation = Vector3(0, 0, 4.71239)
+pixel_size = 0.008
+flip_h = true
+shaded = false
+double_sided = true
+texture_filter = 0
+
+[node name="CatchDisplay" type="Node3D" parent="."]
+unique_name_in_owner = true
+position = Vector3(0, 0.08, 0.04)
+top_level = true
+visible = false
+
+[node name="CatchSprite" type="Sprite3D" parent="CatchDisplay"]
+unique_name_in_owner = true
+pixel_size = 0.012
+shaded = false
+double_sided = true
+texture_filter = 0
diff --git a/player/materials/player_blob_shadow.gdshader b/player/materials/player_blob_shadow.gdshader
new file mode 100644
index 0000000..c1a5089
--- /dev/null
+++ b/player/materials/player_blob_shadow.gdshader
@@ -0,0 +1,13 @@
+shader_type spatial;
+render_mode blend_mix, depth_draw_never, cull_disabled, unshaded;
+
+uniform vec4 shadow_color : source_color = vec4(0.035, 0.105, 0.125, 0.18);
+uniform float inner_softness : hint_range(0.0, 0.8, 0.01) = 0.05;
+
+void fragment() {
+ vec2 centered_uv = (UV - vec2(0.5)) * 2.0;
+ float radial_distance = dot(centered_uv, centered_uv);
+ float falloff = 1.0 - smoothstep(inner_softness, 1.0, radial_distance);
+ ALBEDO = shadow_color.rgb;
+ ALPHA = shadow_color.a * falloff;
+}
diff --git a/player/materials/player_blob_shadow.gdshader.uid b/player/materials/player_blob_shadow.gdshader.uid
new file mode 100644
index 0000000..d7cd853
--- /dev/null
+++ b/player/materials/player_blob_shadow.gdshader.uid
@@ -0,0 +1 @@
+uid://cd16fw0yye48e
diff --git a/player/materials/player_blob_shadow.tres b/player/materials/player_blob_shadow.tres
new file mode 100644
index 0000000..249429f
--- /dev/null
+++ b/player/materials/player_blob_shadow.tres
@@ -0,0 +1,7 @@
+[gd_resource type="ShaderMaterial" load_steps=2 format=3]
+
+[ext_resource type="Shader" path="res://player/materials/player_blob_shadow.gdshader" id="1_shader"]
+
+[resource]
+render_priority = 1
+shader = ExtResource("1_shader")
diff --git a/player/player.gd b/player/player.gd
index 705cb1c..6284873 100644
--- a/player/player.gd
+++ b/player/player.gd
@@ -4,9 +4,11 @@ extends CharacterBody3D
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
+const ItemDataType = preload("res://items/item_data.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
@@ -17,6 +19,260 @@ const PlayerItemEffectsType = preload(
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
+const PlayerArtUnlocksType = preload(
+ "res://progression/player_art_unlocks.gd"
+)
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const FishingRodAttachmentScene = preload(
+ "res://player/fishing_rod_attachment.tscn"
+)
+const HeldItemAttachmentScene = preload(
+ "res://player/held_item_attachment.tscn"
+)
+const SprintDustTrailType = preload("res://player/sprint_dust_trail.gd")
+
+signal retract_visual_finished
+
+const CHARACTER_IDLE_ANIMATION: StringName = &"idle"
+const CHARACTER_IDLE_SHOW_ANIMATION: StringName = &"idle_show"
+const CHARACTER_IDLE_SIT_ANIMATION: StringName = &"idle_sit"
+const CHARACTER_IDLE_SIT_SHOW_ANIMATION: StringName = &"idle_sit_show"
+const CHARACTER_WALKING_ANIMATION: StringName = &"walking"
+const CHARACTER_WALKING_SHOW_ANIMATION: StringName = &"walking_show"
+const CHARACTER_RUNNING_ANIMATION: StringName = &"running"
+const CHARACTER_RUNNING_SHOW_ANIMATION: StringName = &"running_show"
+const CHARACTER_CASTING_ANIMATION: StringName = &"casting"
+const CHARACTER_CASTING_SIT_ANIMATION: StringName = &"casting_sit"
+const CHARACTER_RELEASE_ANIMATION: StringName = &"release"
+const CHARACTER_RELEASE_SIT_ANIMATION: StringName = &"release_sit"
+const CHARACTER_RETRACT_ANIMATION: StringName = &"retract"
+const CHARACTER_RETRACT_SIT_ANIMATION: StringName = &"retract_sit"
+const CHARACTER_POCKET_IDLE_IDLE_ANIMATION: StringName = &"pocket_idle_idle"
+const CHARACTER_POCKET_IDLE_SHOW_ANIMATION: StringName = &"pocket_idle_show"
+const CHARACTER_POCKET_SHOW_SHOW_ANIMATION: StringName = &"pocket_show_show"
+const CHARACTER_POCKET_SHOW_IDLE_ANIMATION: StringName = &"pocket_show_idle"
+const CHARACTER_POCKET_SIT_IDLE_IDLE_ANIMATION: StringName = &"pocket_sit_idle_idle"
+const CHARACTER_POCKET_SIT_IDLE_SHOW_ANIMATION: StringName = &"pocket_sit_idle_show"
+const CHARACTER_POCKET_SIT_SHOW_SHOW_ANIMATION: StringName = &"pocket_sit_show_show"
+const CHARACTER_POCKET_SIT_SHOW_IDLE_ANIMATION: StringName = &"pocket_sit_show_idle"
+const CHARACTER_POCKET_WALKING_IDLE_IDLE_ANIMATION: StringName = &"pocket_walking_idle_idle"
+const CHARACTER_POCKET_WALKING_IDLE_SHOW_ANIMATION: StringName = &"pocket_walking_idle_show"
+const CHARACTER_POCKET_WALKING_SHOW_SHOW_ANIMATION: StringName = &"pocket_walking_show_show"
+const CHARACTER_POCKET_WALKING_SHOW_IDLE_ANIMATION: StringName = &"pocket_walking_show_idle"
+const CHARACTER_FISHING_ANIMATION: StringName = &"fishing"
+const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit"
+const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting"
+const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit"
+# Add future networked emote animation IDs here. The protocol accepts unknown
+# safe IDs so newer clients can extend it, but Player only presents actions
+# explicitly approved by this catalog.
+const NETWORK_ANIMATION_ACTION_IDS: Array[StringName] = []
+
+enum FishingVisualPhase {
+ NONE,
+ CASTING,
+ RELEASE,
+ RETRACT,
+ FISHING,
+}
+
+enum PocketVisualTarget {
+ NONE,
+ IDLE_ITEM,
+ HELD_FISH,
+ ART_KIT,
+ CATCH_SHOWCASE,
+}
+
+enum LocomotionState {
+ IDLE,
+ WALKING,
+ RUNNING,
+}
+const FIGHTING_EYES_ID: String = "alligator_eyes"
+const BLINK_EYES_ID: String = "closed"
+const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5)
+const BLINK_DURATION_SECONDS: float = 0.11
+const CHARACTER_CALL_MOUTH_ID: String = "open_ah"
+const CHARACTER_CALL_MOUTH_DURATION_SECONDS: float = 0.16
+const BASE_REEL_SPEED: float = 0.16
+const LANDING_DUST_MIN_FALL_SPEED: float = 2.5
+# The target Android handheld exposes its physical right trigger through
+# Godot's left-trigger axis. Keep the role named here so the platform mapping
+# remains isolated from camera behavior.
+const CONTROLLER_ZOOM_TRIGGER_AXIS: JoyAxis = JOY_AXIS_TRIGGER_LEFT
+
+var appearance_snapshot: Dictionary = (
+ CharacterCustomizationCatalog.default_snapshot()
+)
+var animalese_voice_id: String = "natural"
+var active_bait_id: StringName = StringName()
+var active_lure_id: StringName = StringName()
+signal active_bait_changed(item_id: StringName)
+signal active_lure_changed(item_id: StringName)
+
+
+func equip_bait(item: ItemDataType) -> bool:
+ if item == null or not item.is_bait() or bag == null:
+ return false
+ if not bag.owns_item(item.item_id):
+ return false
+ active_bait_id = item.item_id
+ active_bait_changed.emit(active_bait_id)
+ return true
+
+
+func unequip_bait() -> void:
+ if active_bait_id.is_empty():
+ return
+ active_bait_id = StringName()
+ active_bait_changed.emit(active_bait_id)
+
+
+func equip_lure(item: ItemDataType) -> bool:
+ if item == null or not item.is_lure() or bag == null:
+ return false
+ if not bag.owns_item(item.item_id):
+ return false
+ active_lure_id = item.item_id
+ active_lure_changed.emit(active_lure_id)
+ return true
+
+
+func unequip_lure() -> void:
+ if active_lure_id.is_empty():
+ return
+ active_lure_id = StringName()
+ active_lure_changed.emit(active_lure_id)
+
+
+func apply_appearance_snapshot(snapshot: Dictionary) -> void:
+ if CharacterCustomizationCatalog.validate_snapshot(snapshot):
+ appearance_snapshot = snapshot.duplicate(true)
+ _apply_presented_appearance()
+
+
+func get_character_visual_scale() -> float:
+ return CharacterCustomizationCatalog.character_scale(
+ appearance_snapshot.get(
+ CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
+ CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
+ )
+ )
+
+
+func set_fighting_visual(active: bool) -> void:
+ if _fighting_visual_active == active:
+ return
+ _fighting_visual_active = active
+ if active:
+ _blink_visual_active = false
+ _schedule_next_blink()
+ _apply_presented_appearance()
+ _character_animation_name = &""
+ _update_character_animation()
+
+
+func play_character_call_visual() -> void:
+ _character_call_visual_generation += 1
+ var generation: int = _character_call_visual_generation
+ _character_call_mouth_active = true
+ _apply_presented_appearance()
+ await get_tree().create_timer(
+ CHARACTER_CALL_MOUTH_DURATION_SECONDS
+ ).timeout
+ if generation != _character_call_visual_generation:
+ return
+ _character_call_mouth_active = false
+ _apply_presented_appearance()
+
+
+func set_fishing_visual(active: bool) -> void:
+ if (
+ active
+ and _fishing_visual_phase == FishingVisualPhase.RELEASE
+ and _character_animation_name in [
+ CHARACTER_RELEASE_ANIMATION,
+ CHARACTER_RELEASE_SIT_ANIMATION,
+ ]
+ and _character_animation_player != null
+ and _character_animation_player.is_playing()
+ ):
+ _fishing_after_release_pending = true
+ return
+ _set_fishing_visual_phase(
+ FishingVisualPhase.FISHING if active else FishingVisualPhase.NONE
+ )
+
+
+func set_casting_visual() -> void:
+ _set_fishing_visual_phase(FishingVisualPhase.CASTING)
+
+
+func set_release_visual() -> void:
+ _set_fishing_visual_phase(FishingVisualPhase.RELEASE)
+
+
+func set_retract_visual() -> void:
+ _retract_animation_completed = false
+ _set_fishing_visual_phase(FishingVisualPhase.RETRACT)
+ if (
+ _character_animation_player == null
+ or _character_animation_name not in [
+ CHARACTER_RETRACT_ANIMATION,
+ CHARACTER_RETRACT_SIT_ANIMATION,
+ ]
+ or not _character_animation_player.is_playing()
+ ):
+ _complete_retract_animation()
+
+
+func _set_fishing_visual_phase(phase: FishingVisualPhase) -> void:
+ if phase != FishingVisualPhase.NONE and _pocket_visual_active:
+ _complete_pocket_visual()
+ if _fishing_visual_phase == phase:
+ return
+ if (
+ _fishing_visual_phase == FishingVisualPhase.RETRACT
+ and phase != FishingVisualPhase.RETRACT
+ ):
+ _complete_retract_animation()
+ if phase != FishingVisualPhase.RELEASE:
+ _fishing_after_release_pending = false
+ _fishing_visual_phase = phase
+ _fishing_visual_active = phase != FishingVisualPhase.NONE
+ _character_animation_name = &""
+ _update_character_animation()
+
+
+func _apply_presented_appearance() -> void:
+ if not is_node_ready() or _visuals == null:
+ return
+ var presented_appearance := appearance_snapshot
+ if _blink_visual_active and not _fighting_visual_active:
+ presented_appearance = appearance_snapshot.duplicate(true)
+ presented_appearance["eyes"] = BLINK_EYES_ID
+ if _fighting_visual_active:
+ presented_appearance = appearance_snapshot.duplicate(true)
+ presented_appearance["eyes"] = FIGHTING_EYES_ID
+ if _character_call_mouth_active:
+ if presented_appearance == appearance_snapshot:
+ presented_appearance = appearance_snapshot.duplicate(true)
+ presented_appearance["mouth"] = CHARACTER_CALL_MOUTH_ID
+ PlayerVisualPresenter.apply_appearance(_visuals, presented_appearance)
+
+
+func apply_animalese_voice_id(voice_id: String) -> void:
+ animalese_voice_id = voice_id
+
+
+func get_animalese_voice_id() -> String:
+ return animalese_voice_id
class ShowcaseCameraSnapshot:
extends RefCounted
@@ -31,13 +287,13 @@ class ShowcaseCameraSnapshot:
@export_category("Movement")
-@export var walk_speed: float = 5.0
-@export var sprint_speed: float = 8.0
-@export var sneak_speed: float = 2.0
-@export var slow_walk_speed: float = 3.25
+@export var walk_speed: float = 2.25
+@export var sprint_speed: float = 4.5
+@export var sneak_speed: float = 1.125
+@export var slow_walk_speed: float = 2.9
@export_range(0.1, 20.0, 0.1) var jump_velocity: float = 4.6
@export_range(0.1, 5.0, 0.05) var upward_gravity_multiplier: float = 1.35
-@export_range(0.1, 5.0, 0.05) var fall_gravity_multiplier: float = 2.1
+@export_range(0.1, 5.0, 0.05) var fall_gravity_multiplier: float = 2.35
@export_range(0.1, 3.0, 0.05) var body_center_height: float = 0.9
@export var body_rotation_speed: float = 12.0
@@ -45,7 +301,7 @@ class ShowcaseCameraSnapshot:
@export var local_control_enabled: bool = true
@export_category("Fishing Stats")
-@export_range(0.01, 2.0, 0.01) var reel_speed: float = 0.32
+@export_range(0.01, 2.0, 0.01) var reel_speed: float = BASE_REEL_SPEED
@export_range(1, 100, 1) var click_power: int = 1
@export_category("Showcase")
@@ -69,12 +325,23 @@ class ShowcaseCameraSnapshot:
@export var maximum_zoom: float = 8.0
@export var zoom_step: float = 0.75
@export var zoom_smoothing: float = 12.0
+@export_range(0.1, 12.0, 0.1) var controller_zoom_speed: float = 4.0
+@export_range(0.0, 1.0, 0.01) var controller_trigger_threshold: float = 0.55
+@export_range(1.0, 40.0, 0.5) var free_camera_speed: float = 10.0
+@export_range(1.0, 10.0, 0.5) var free_camera_sprint_multiplier: float = 3.0
@onready var _visuals: Node3D = %Visuals
+@onready var _character_rig: Node3D = get_node_or_null(
+ "Visuals/CharacterRig/CharacterRig"
+) as Node3D
+@onready var _character_animation_player: AnimationPlayer = (
+ get_node_or_null("Visuals/CharacterRig/AnimationPlayer") as AnimationPlayer
+)
@onready var _camera_yaw: Node3D = %CameraYaw
@onready var _camera_pitch: Node3D = %CameraPitch
@onready var _spring_arm: SpringArm3D = %SpringArm3D
@onready var _camera: Camera3D = %Camera3D
+@onready var _player_collision_shape: CollisionShape3D = $CollisionShape3D
@onready var inventory: FishInventoryType = %Inventory
@onready var collection_log: CollectionLogType = %CollectionLog
@onready var wallet: PlayerWalletType = %Wallet
@@ -84,19 +351,37 @@ class ShowcaseCameraSnapshot:
@onready var fishing_upgrades: PlayerFishingUpgradesType = %FishingUpgrades
@onready var item_effects: PlayerItemEffectsType = %ItemEffects
@onready var cooler_capacity: PlayerCoolerCapacityType = %CoolerCapacity
+@onready var art_unlocks: PlayerArtUnlocksType = %ArtUnlocks
+@onready var experience: PlayerExperienceType = %Experience
@onready var _cast_origin: Marker3D = %CastOrigin
-@onready var _fishing_rod: Node3D = %FishingRod
-@onready var _fishing_rod_tip: Marker3D = %FishingRodTip
-@onready var _catch_display: Node3D = %CatchDisplay
-@onready var _catch_sprite: Sprite3D = %CatchSprite
+var _catch_display: Node3D
+var _catch_sprite: Sprite3D
+var _held_item_attachment: BoneAttachment3D
+var _held_fish_display: Node3D
+var _held_fish_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04)
+var _held_fish_sprite: Sprite3D
+var _held_art_kit_display: Node3D
+var _held_art_kit_sprite: Sprite3D
+var _catch_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04)
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity"))
var _camera_dragging: bool = false
var _camera_input_enabled: bool = true
+var _camera_drag_prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
+var _free_camera_active: bool = false
+var _free_camera_body: CharacterBody3D
+var _free_camera: Camera3D
+var _free_camera_yaw: float = 0.0
+var _free_camera_pitch: float = 0.0
var _movement_enabled: bool = true
+var _local_input_suppressors: Dictionary[StringName, bool] = {}
var _water_recovery_active: bool = false
+var _remote_recovery_presentation_active: bool = false
+var _remote_recovery_visual_origin: Vector3
+var _remote_recovery_elapsed: float = 0.0
var _target_zoom: float = 5.0
var _showcase_rod_visibility: bool = true
+var _remote_presentation_visible := true
var _showcase_rod_state_stored: bool = false
var _showcase_visual_rotation: Vector3
var _showcase_visual_rotation_stored: bool = false
@@ -105,17 +390,256 @@ var _showcase_restore_tween: Tween
var _showcase_camera_tween: Tween
var _showcase_camera_snapshot: ShowcaseCameraSnapshot
var _showcase_restore_generation: int = 0
+var _network_peer_id: int = 0
+var _network_authoritative_simulation: bool = false
+var _network_interpolation_enabled: bool = false
+var _network_axis: Vector2 = Vector2.ZERO
+var _network_camera_yaw: float = 0.0
+var _network_jump_pending: bool = false
+var _network_sprint: bool = false
+var _network_sneak: bool = false
+var _network_slow_walk: bool = false
+var _last_network_input_sequence: int = 0
+var _network_target_position: Vector3
+var _network_target_velocity: Vector3
+var _network_target_visual_yaw: float = 0.0
+var _network_target_grounded: bool = false
+var _network_target_locomotion_state: LocomotionState = LocomotionState.IDLE
+var _network_target_animation_action_id: StringName = &""
+var _network_target_animation_action_sequence: int = 0
+var _network_target_animation_action_elapsed: float = 0.0
+var _network_snapshot_ready: bool = false
+var _network_snapshot_age: float = 0.0
+var _animation_action_id: StringName = &""
+var _animation_action_sequence: int = 0
+var _animation_action_elapsed: float = 0.0
+var _presented_animation_action_id: StringName = &""
+var _presented_animation_action_sequence: int = -1
+var _character_animation_name: StringName = &""
+var _sitting: bool = false
+var _sit_after_landing: bool = false
+var _sitting_intent_pending: bool = false
+var _sitting_intent_sequence: int = -1
+var _held_fish_visible: bool = false
+var _pending_held_fish_texture: Texture2D
+var _pending_held_fish_scale: Vector3 = Vector3.ONE
+var _held_art_kit_visible: bool = false
+var _showcase_animation_active: bool = false
+var _pocket_visual_active: bool = false
+var _pocket_visual_target: PocketVisualTarget = PocketVisualTarget.NONE
+var _pocket_visual_animation: StringName = &""
+var _pocket_visual_finished_callback: Callable
+var _pocket_visual_callback_runs_on_interrupt: bool = true
+var _pocket_visual_midpoint_callback: Callable
+var _pocket_visual_midpoint_called: bool = false
+var _pocket_visual_generation: int = 0
+var _fighting_visual_active: bool = false
+var _blink_visual_active: bool = false
+var _blink_seconds_remaining: float = 0.0
+var _blink_rng := RandomNumberGenerator.new()
+var _character_call_mouth_active: bool = false
+var _character_call_visual_generation: int = 0
+var _fishing_visual_active: bool = false
+var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE
+var _fishing_after_release_pending: bool = false
+var _retract_animation_completed: bool = true
+var _fishing_rod: Node3D
+var _fishing_rod_tip: Marker3D
+var _active_item_is_rod: bool = false
+var _controller_mapping_manager: ControllerMappingManagerType
+var _sprint_dust_landing_ready: bool = false
+var _sprint_dust_airborne: bool = false
+var _sprint_dust_fall_speed: float = 0.0
+
+@onready var _sprint_dust: SprintDustTrailType = %SprintDust
func _ready() -> void:
+ _blink_rng.randomize()
+ _schedule_next_blink()
+ if (
+ _character_animation_player != null
+ and not _character_animation_player.animation_finished.is_connected(
+ _on_character_animation_finished
+ )
+ ):
+ _character_animation_player.animation_finished.connect(
+ _on_character_animation_finished
+ )
+ if not bag.contents_changed.is_connected(_on_bag_contents_changed):
+ bag.contents_changed.connect(_on_bag_contents_changed)
+ _apply_presented_appearance()
+ _initialize_fishing_rod()
_target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom)
_spring_arm.spring_length = _target_zoom
_camera.current = local_control_enabled
+ _initialize_held_item_attachment()
+
+
+func _on_bag_contents_changed() -> void:
+ if (
+ not active_bait_id.is_empty()
+ and bag.get_quantity(active_bait_id) <= 0
+ ):
+ unequip_bait()
+ if (
+ not active_lure_id.is_empty()
+ and not bag.owns_item(active_lure_id)
+ ):
+ unequip_lure()
+
+
+func set_controller_mapping_manager(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+
+
+func begin_animation_action(
+ action_id: StringName,
+ elapsed_seconds: float = 0.0,
+) -> bool:
+ if (
+ action_id.is_empty()
+ or not supports_network_animation_action(action_id)
+ or _character_animation_player == null
+ or not _character_animation_player.has_animation(action_id)
+ ):
+ return false
+ var next_sequence: int = (
+ 1
+ if _animation_action_sequence
+ >= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
+ else _animation_action_sequence + 1
+ )
+ var action_state: Dictionary = (
+ NetworkPlayerAnimationProtocol.make_action_state(
+ action_id, next_sequence, elapsed_seconds
+ )
+ )
+ if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
+ return false
+ _apply_animation_action_state(action_state)
+ return true
+
+
+func end_animation_action() -> void:
+ if _animation_action_id.is_empty():
+ return
+ var next_sequence: int = (
+ 1
+ if _animation_action_sequence
+ >= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
+ else _animation_action_sequence + 1
+ )
+ _apply_animation_action_state(
+ NetworkPlayerAnimationProtocol.make_action_state(
+ &"", next_sequence, 0.0
+ )
+ )
+
+
+func _apply_animation_action_state(action_state: Dictionary) -> void:
+ if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
+ return
+ var action_id := StringName(str(action_state["id"]))
+ if (
+ not action_id.is_empty()
+ and not supports_network_animation_action(action_id)
+ ):
+ action_id = &""
+ var action_sequence: int = int(action_state["sequence"])
+ var action_changed: bool = (
+ action_id != _animation_action_id
+ or action_sequence != _animation_action_sequence
+ )
+ _animation_action_id = action_id
+ _animation_action_sequence = action_sequence
+ _animation_action_elapsed = float(action_state["elapsed"])
+ if action_changed:
+ _character_animation_name = &""
+ _update_character_animation()
+
+
+func _initialize_fishing_rod() -> void:
+ var skeleton := get_node_or_null(
+ "Visuals/CharacterRig/CharacterRig/Skeleton3D"
+ ) as Skeleton3D
+ if skeleton == null:
+ push_error("Player character skeleton is unavailable.")
+ return
+ var attachment := FishingRodAttachmentScene.instantiate() as BoneAttachment3D
+ if attachment == null:
+ push_error("Fishing rod attachment could not be instantiated.")
+ return
+ skeleton.add_child(attachment)
+ _fishing_rod = attachment.get_node("FishingRod") as Node3D
+ _fishing_rod_tip = attachment.get_node(
+ "FishingRod/FishingRodTip"
+ ) as Marker3D
+
+
+func _initialize_held_item_attachment() -> void:
+ var skeleton := get_node_or_null(
+ "Visuals/CharacterRig/CharacterRig/Skeleton3D"
+ ) as Skeleton3D
+ if skeleton == null:
+ push_error("Player character skeleton is unavailable for held items.")
+ return
+ var attachment := HeldItemAttachmentScene.instantiate() as BoneAttachment3D
+ if attachment == null:
+ push_error("Held item attachment could not be instantiated.")
+ return
+ skeleton.add_child(attachment)
+ _held_item_attachment = attachment
+ _held_fish_display = attachment.get_node("HeldFishDisplay") as Node3D
+ _held_fish_attachment_offset = _held_fish_display.position
+ _held_fish_sprite = attachment.get_node(
+ "HeldFishDisplay/HeldFishSprite"
+ ) as Sprite3D
+ _held_art_kit_display = attachment.get_node("HeldArtKitDisplay") as Node3D
+ _held_art_kit_sprite = attachment.get_node(
+ "HeldArtKitDisplay/HeldArtKitSprite"
+ ) as Sprite3D
+ _catch_display = attachment.get_node("CatchDisplay") as Node3D
+ _catch_attachment_offset = _catch_display.position
+ _catch_sprite = attachment.get_node("CatchDisplay/CatchSprite") as Sprite3D
func _physics_process(delta: float) -> void:
+ if _network_interpolation_enabled:
+ _update_network_interpolation(delta)
+ return
+ if local_control_enabled and _free_camera_active:
+ _update_free_camera_physics()
+ velocity.x = 0.0
+ velocity.z = 0.0
+ if not is_on_floor():
+ velocity.y -= _gravity * fall_gravity_multiplier * delta
+ move_and_slide()
+ _network_jump_pending = false
+ return
+ var jump_requested: bool = (
+ _is_movement_input_enabled()
+ and not _free_camera_active
+ and (
+ (local_control_enabled and Input.is_action_just_pressed("jump"))
+ or (_network_authoritative_simulation and _network_jump_pending)
+ )
+ )
+ if _sit_after_landing and is_on_floor():
+ _sit_after_landing = false
+ _set_sitting(true, local_control_enabled)
+ if _sitting:
+ if jump_requested:
+ _set_sitting(false, local_control_enabled)
+ else:
+ velocity = Vector3.ZERO
+ _network_jump_pending = false
+ return
if _water_recovery_active:
velocity = Vector3.ZERO
+ _network_jump_pending = false
return
if not is_on_floor():
@@ -125,34 +649,41 @@ func _physics_process(delta: float) -> void:
else fall_gravity_multiplier
)
velocity.y -= _gravity * gravity_multiplier * delta
- elif (
- local_control_enabled
- and _movement_enabled
- and Input.is_action_just_pressed("jump")
- ):
+ elif jump_requested:
velocity.y = jump_velocity
+ _network_jump_pending = false
- if not local_control_enabled or not _movement_enabled:
+ if not _is_movement_input_enabled():
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
return
- var input_vector: Vector2 = Input.get_vector(
- "move_left",
- "move_right",
- "move_forward",
- "move_backward"
- )
- var camera_basis: Basis = _camera_yaw.global_basis
+ var input_vector: Vector2
+ var camera_basis: Basis
+ if local_control_enabled:
+ input_vector = Input.get_vector(
+ "move_left",
+ "move_right",
+ "move_forward",
+ "move_backward"
+ )
+ camera_basis = _camera_yaw.global_basis
+ elif _network_authoritative_simulation:
+ input_vector = _network_axis
+ camera_basis = Basis(Vector3.UP, _network_camera_yaw)
+ else:
+ velocity.x = 0.0
+ velocity.z = 0.0
+ return
var move_direction: Vector3 = camera_basis.x * input_vector.x + camera_basis.z * input_vector.y
move_direction.y = 0.0
var input_strength: float = minf(input_vector.length(), 1.0)
move_direction = move_direction.normalized()
- var speed: float = (
- _get_current_speed() * item_effects.get_movement_multiplier()
- )
+ var speed: float = _get_network_aware_speed()
+ if item_effects != null:
+ speed *= item_effects.get_movement_multiplier()
velocity.x = move_direction.x * speed * input_strength
velocity.z = move_direction.z * speed * input_strength
@@ -168,32 +699,69 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void:
+ if not _animation_action_id.is_empty():
+ _animation_action_elapsed = minf(
+ _animation_action_elapsed + delta,
+ NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
+ )
+ _update_blink(delta)
+ _update_character_animation()
+ _update_sprint_dust(delta)
+ # The hand bone supplies the attachment position, but its animated wrist
+ # rotation should not turn the flat fish/catch artwork edge-on. Keep each
+ # display aligned with the character's facing while it follows the hand.
+ if _character_rig != null and _held_item_attachment != null:
+ var facing_rotation: Vector3 = _character_rig.global_rotation
+ for display: Node3D in [_held_fish_display, _catch_display]:
+ if display != null:
+ var attachment_offset: Vector3 = (
+ _held_fish_attachment_offset
+ if display == _held_fish_display
+ else _catch_attachment_offset
+ )
+ display.global_position = (
+ _held_item_attachment.global_transform * attachment_offset
+ )
+ display.global_rotation = facing_rotation
+ if _remote_recovery_presentation_active:
+ _remote_recovery_elapsed += delta
+ _visuals.position = (
+ _remote_recovery_visual_origin
+ + Vector3.UP
+ * sin(_remote_recovery_elapsed * 2.0 * TAU)
+ * 0.08
+ )
if not local_control_enabled:
return
+ if _free_camera_active:
+ return
- if (
- _camera_input_enabled
- and _camera_dragging
- and not Input.is_action_pressed("camera_drag")
- ):
- _camera_dragging = false
+ if _camera_dragging and not Input.is_action_pressed("camera_drag"):
+ _set_camera_dragging(false)
- if _camera_input_enabled:
- var stick: Vector2 = Vector2(
- Input.get_joy_axis(0, JOY_AXIS_RIGHT_X),
- Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y)
- )
+ if _is_camera_input_enabled():
+ var stick: Vector2 = _get_controller_camera_stick()
if stick.length() > controller_camera_deadzone:
- var adjusted_strength: float = (
- (stick.length() - controller_camera_deadzone)
- / (1.0 - controller_camera_deadzone)
- )
- _rotate_camera(
- stick.normalized()
- * adjusted_strength
- * controller_camera_speed
- * delta
- )
+ if (
+ _get_controller_zoom_strength()
+ >= controller_trigger_threshold
+ ):
+ var vertical_zoom_input: float = _apply_axis_deadzone(stick.y)
+ _set_target_zoom(
+ _target_zoom
+ + vertical_zoom_input * controller_zoom_speed * delta
+ )
+ else:
+ var adjusted_strength: float = (
+ (stick.length() - controller_camera_deadzone)
+ / (1.0 - controller_camera_deadzone)
+ )
+ _rotate_camera(
+ stick.normalized()
+ * adjusted_strength
+ * controller_camera_speed
+ * delta
+ )
var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta)
_spring_arm.spring_length = lerpf(
@@ -203,32 +771,435 @@ func _process(delta: float) -> void:
)
+func _update_blink(delta: float) -> void:
+ if _fighting_visual_active:
+ return
+ _blink_seconds_remaining -= delta
+ if _blink_seconds_remaining > 0.0:
+ return
+ _blink_visual_active = not _blink_visual_active
+ if _blink_visual_active:
+ _blink_seconds_remaining = BLINK_DURATION_SECONDS
+ else:
+ _schedule_next_blink()
+ _apply_presented_appearance()
+
+
+func _schedule_next_blink() -> void:
+ _blink_seconds_remaining = _blink_rng.randf_range(
+ BLINK_INTERVAL_SECONDS.x,
+ BLINK_INTERVAL_SECONDS.y,
+ )
+
+
+func _update_sprint_dust(delta: float) -> void:
+ if _sprint_dust == null:
+ return
+ var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z)
+ var grounded: bool = _get_presented_grounded()
+ if _water_recovery_active:
+ _sprint_dust_airborne = false
+ _sprint_dust_fall_speed = 0.0
+ elif not _sprint_dust_landing_ready:
+ # Establish a real ground contact before arming landing effects. This
+ # prevents the player's initial placement from looking like a fall.
+ _sprint_dust_landing_ready = grounded
+ elif not grounded:
+ _sprint_dust_airborne = true
+ _sprint_dust_fall_speed = maxf(
+ _sprint_dust_fall_speed,
+ maxf(-velocity.y, 0.0),
+ )
+ elif _sprint_dust_airborne:
+ if _sprint_dust_fall_speed >= LANDING_DUST_MIN_FALL_SPEED:
+ var landing_facing: Vector3 = horizontal_velocity.normalized()
+ if landing_facing.is_zero_approx() and _visuals != null:
+ landing_facing = -_visuals.global_basis.z
+ _sprint_dust.emit_landing_burst(
+ global_position,
+ landing_facing,
+ )
+ _sprint_dust_airborne = false
+ _sprint_dust_fall_speed = 0.0
+ var should_emit: bool = (
+ grounded
+ and not _sitting
+ and not _water_recovery_active
+ and _get_presented_locomotion_state() == LocomotionState.RUNNING
+ )
+ _sprint_dust.update_trail(
+ delta,
+ global_position,
+ horizontal_velocity,
+ should_emit,
+ )
+
+
+func _get_controller_camera_stick() -> Vector2:
+ if _controller_mapping_manager != null:
+ return Vector2(
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X
+ ),
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_Y
+ ),
+ )
+ return Vector2(
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_X),
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y),
+ )
+
+
+func _get_controller_zoom_strength() -> float:
+ if _controller_mapping_manager != null:
+ return _controller_mapping_manager.get_role_strength(
+ ControllerMappingManagerType.ROLE_CAMERA_ZOOM
+ )
+ return Input.get_joy_axis(0, CONTROLLER_ZOOM_TRIGGER_AXIS)
+
+
+func _update_character_animation() -> void:
+ if _character_animation_player == null:
+ return
+ var locomotion_state: LocomotionState = (
+ _get_presented_locomotion_state()
+ )
+ var is_walking: bool = locomotion_state != LocomotionState.IDLE
+ var is_running: bool = locomotion_state == LocomotionState.RUNNING
+ var animation_action: Dictionary = _get_presented_animation_action()
+ var animation_action_id := StringName(str(animation_action.get("id", "")))
+ var animation_action_available: bool = (
+ not animation_action_id.is_empty()
+ and supports_network_animation_action(animation_action_id)
+ and _character_animation_player.has_animation(animation_action_id)
+ )
+ var held_show_item_visible: bool = (
+ _held_fish_visible or _held_art_kit_visible
+ )
+ var requested_animation: Array[StringName] = []
+ if _pocket_visual_active:
+ requested_animation = [_pocket_visual_animation]
+ elif _showcase_animation_active:
+ if _sitting:
+ requested_animation = [
+ CHARACTER_IDLE_SIT_SHOW_ANIMATION,
+ &"idle_sit_show_loop",
+ CHARACTER_IDLE_SIT_ANIMATION,
+ &"idle_sit_loop",
+ &"idle_loop_sit",
+ ]
+ else:
+ requested_animation = [
+ CHARACTER_IDLE_SHOW_ANIMATION,
+ &"idle_show_loop",
+ &"show",
+ &"show_loop",
+ ]
+ elif _fighting_visual_active:
+ if _sitting:
+ requested_animation = [
+ CHARACTER_FIGHTING_SIT_ANIMATION,
+ CHARACTER_IDLE_SIT_ANIMATION,
+ &"idle_sit_loop",
+ ]
+ else:
+ requested_animation = [
+ CHARACTER_FIGHTING_ANIMATION,
+ CHARACTER_IDLE_ANIMATION,
+ ]
+ elif _fishing_visual_active:
+ if _sitting:
+ match _fishing_visual_phase:
+ FishingVisualPhase.CASTING:
+ requested_animation = [
+ CHARACTER_CASTING_SIT_ANIMATION,
+ CHARACTER_FISHING_SIT_ANIMATION,
+ CHARACTER_IDLE_SIT_ANIMATION,
+ ]
+ FishingVisualPhase.RELEASE:
+ requested_animation = [
+ CHARACTER_RELEASE_SIT_ANIMATION,
+ CHARACTER_FISHING_SIT_ANIMATION,
+ CHARACTER_IDLE_SIT_ANIMATION,
+ ]
+ FishingVisualPhase.RETRACT:
+ requested_animation = [
+ CHARACTER_RETRACT_SIT_ANIMATION,
+ CHARACTER_RETRACT_ANIMATION,
+ CHARACTER_FISHING_SIT_ANIMATION,
+ CHARACTER_IDLE_SIT_ANIMATION,
+ ]
+ _:
+ requested_animation = [
+ CHARACTER_FISHING_SIT_ANIMATION,
+ CHARACTER_IDLE_SIT_ANIMATION,
+ &"idle_sit_loop",
+ ]
+ else:
+ match _fishing_visual_phase:
+ FishingVisualPhase.CASTING:
+ requested_animation = [
+ CHARACTER_CASTING_ANIMATION,
+ CHARACTER_FISHING_ANIMATION,
+ CHARACTER_IDLE_ANIMATION,
+ ]
+ FishingVisualPhase.RELEASE:
+ requested_animation = [
+ CHARACTER_RELEASE_ANIMATION,
+ CHARACTER_FISHING_ANIMATION,
+ CHARACTER_IDLE_ANIMATION,
+ ]
+ FishingVisualPhase.RETRACT:
+ requested_animation = [
+ CHARACTER_RETRACT_ANIMATION,
+ CHARACTER_FISHING_ANIMATION,
+ CHARACTER_IDLE_ANIMATION,
+ ]
+ _:
+ requested_animation = [
+ CHARACTER_FISHING_ANIMATION,
+ CHARACTER_IDLE_ANIMATION,
+ ]
+ elif animation_action_available:
+ requested_animation = [animation_action_id]
+ elif _sitting:
+ if held_show_item_visible:
+ requested_animation = [
+ CHARACTER_IDLE_SIT_SHOW_ANIMATION,
+ &"idle_sit_show_loop",
+ CHARACTER_IDLE_SIT_ANIMATION,
+ &"idle_sit_loop",
+ &"idle_loop_sit",
+ ]
+ else:
+ requested_animation = [
+ CHARACTER_IDLE_SIT_ANIMATION,
+ &"idle_sit_loop",
+ &"sitting",
+ &"idle_loop_sit",
+ ]
+ elif is_running and held_show_item_visible:
+ requested_animation = [
+ CHARACTER_RUNNING_SHOW_ANIMATION,
+ &"running_show_loop",
+ CHARACTER_WALKING_SHOW_ANIMATION,
+ &"walking_show_loop",
+ ]
+ elif is_running:
+ requested_animation = [
+ CHARACTER_RUNNING_ANIMATION,
+ &"running_loop",
+ CHARACTER_WALKING_ANIMATION,
+ &"walking_loop",
+ ]
+ elif is_walking and held_show_item_visible:
+ requested_animation = [
+ CHARACTER_WALKING_SHOW_ANIMATION,
+ &"walking_show_loop",
+ ]
+ elif held_show_item_visible:
+ requested_animation = [
+ CHARACTER_IDLE_SHOW_ANIMATION,
+ &"idle_show_loop",
+ &"show",
+ &"show_loop",
+ ]
+ elif is_walking:
+ requested_animation = [
+ CHARACTER_WALKING_ANIMATION,
+ &"walking_loop",
+ ]
+ else:
+ requested_animation = [CHARACTER_IDLE_ANIMATION, &"idle_loop"]
+ var next_animation: StringName = &""
+ for candidate: StringName in requested_animation:
+ if _character_animation_player.has_animation(candidate):
+ next_animation = candidate
+ break
+ if next_animation.is_empty():
+ return
+ var action_selected: bool = (
+ animation_action_available and next_animation == animation_action_id
+ )
+ var action_sequence: int = int(animation_action.get("sequence", 0))
+ var action_changed: bool = (
+ action_selected
+ and (
+ animation_action_id != _presented_animation_action_id
+ or action_sequence != _presented_animation_action_sequence
+ )
+ )
+ if not action_selected:
+ _presented_animation_action_id = &""
+ _presented_animation_action_sequence = -1
+ if _character_animation_name == next_animation and not action_changed:
+ return
+ _character_animation_player.play(next_animation)
+ _character_animation_name = next_animation
+ if action_selected:
+ _presented_animation_action_id = animation_action_id
+ _presented_animation_action_sequence = action_sequence
+ var elapsed: float = float(animation_action.get("elapsed", 0.0))
+ var animation: Animation = _character_animation_player.get_animation(
+ next_animation
+ )
+ if elapsed > 0.0 and animation != null and animation.length > 0.0:
+ _character_animation_player.seek(
+ fposmod(elapsed, animation.length), true
+ )
+
+
+func _on_character_animation_finished(animation_name: StringName) -> void:
+ if (
+ local_control_enabled
+ and not _animation_action_id.is_empty()
+ and animation_name == _animation_action_id
+ ):
+ end_animation_action()
+ return
+ if animation_name in [
+ CHARACTER_POCKET_IDLE_IDLE_ANIMATION,
+ CHARACTER_POCKET_IDLE_SHOW_ANIMATION,
+ CHARACTER_POCKET_SHOW_SHOW_ANIMATION,
+ CHARACTER_POCKET_SHOW_IDLE_ANIMATION,
+ CHARACTER_POCKET_SIT_IDLE_IDLE_ANIMATION,
+ CHARACTER_POCKET_SIT_IDLE_SHOW_ANIMATION,
+ CHARACTER_POCKET_SIT_SHOW_SHOW_ANIMATION,
+ CHARACTER_POCKET_SIT_SHOW_IDLE_ANIMATION,
+ CHARACTER_POCKET_WALKING_IDLE_IDLE_ANIMATION,
+ CHARACTER_POCKET_WALKING_IDLE_SHOW_ANIMATION,
+ CHARACTER_POCKET_WALKING_SHOW_SHOW_ANIMATION,
+ CHARACTER_POCKET_WALKING_SHOW_IDLE_ANIMATION,
+ ]:
+ if _pocket_visual_active:
+ _complete_pocket_visual()
+ return
+ if animation_name in [
+ CHARACTER_RETRACT_ANIMATION,
+ CHARACTER_RETRACT_SIT_ANIMATION,
+ ]:
+ _complete_retract_animation()
+ return
+ if (
+ not _fishing_after_release_pending
+ or animation_name not in [
+ CHARACTER_RELEASE_ANIMATION,
+ CHARACTER_RELEASE_SIT_ANIMATION,
+ ]
+ ):
+ return
+ _fishing_after_release_pending = false
+ _set_fishing_visual_phase(FishingVisualPhase.FISHING)
+
+
+func is_retract_visual_complete() -> bool:
+ return _retract_animation_completed
+
+
+func _complete_retract_animation() -> void:
+ if _retract_animation_completed:
+ return
+ _retract_animation_completed = true
+ retract_visual_finished.emit()
+
+
+func toggle_sitting() -> void:
+ if _sit_after_landing:
+ _sit_after_landing = false
+ return
+ var should_sit: bool = not _sitting
+ if should_sit:
+ if (
+ _character_animation_player == null
+ or not _has_any_character_animation([
+ CHARACTER_IDLE_SIT_ANIMATION,
+ &"idle_sit_loop",
+ &"sitting",
+ &"idle_loop_sit",
+ ])
+ ):
+ return
+ if not is_on_floor():
+ _sit_after_landing = true
+ return
+ _set_sitting(should_sit, local_control_enabled)
+
+
+func _has_any_character_animation(candidates: Array[StringName]) -> bool:
+ if _character_animation_player == null:
+ return false
+ for candidate: StringName in candidates:
+ if _character_animation_player.has_animation(candidate):
+ return true
+ return false
+
+
+func _set_sitting(
+ should_sit: bool,
+ is_local_intent: bool = false,
+) -> void:
+ if _sitting == should_sit:
+ return
+ _sitting = should_sit
+ if is_local_intent:
+ _sitting_intent_pending = true
+ _sitting_intent_sequence = -1
+ if _sitting:
+ velocity = Vector3.ZERO
+ _character_animation_name = &""
+ _update_character_animation()
+
+
+func is_sitting() -> bool:
+ return _sitting
+
+
func _unhandled_input(event: InputEvent) -> void:
- if not local_control_enabled or not _camera_input_enabled:
+ if not local_control_enabled or not _is_camera_input_enabled():
+ return
+ if event.is_action_pressed("toggle_free_camera"):
+ toggle_free_camera()
+ get_viewport().set_input_as_handled()
return
if event.is_action("camera_drag"):
- _camera_dragging = event.is_pressed()
+ _set_camera_dragging(event.is_pressed())
get_viewport().set_input_as_handled()
return
if event is InputEventMouseMotion and _camera_dragging:
- _rotate_camera(event.relative * mouse_sensitivity)
+ if _free_camera_active:
+ _rotate_free_camera(event.relative * mouse_sensitivity)
+ else:
+ _rotate_camera(event.relative * mouse_sensitivity)
get_viewport().set_input_as_handled()
return
+ if _free_camera_active:
+ return
- if (
+ var mouse_zoom_in: bool = (
event is InputEventMouseButton
and event.shift_pressed
and event.is_action_pressed("camera_zoom_in")
- ):
- _set_target_zoom(_target_zoom - zoom_step)
- get_viewport().set_input_as_handled()
- elif (
+ )
+ var controller_zoom_in: bool = (
+ event is InputEventJoypadButton
+ and event.is_action_pressed("camera_zoom_in")
+ )
+ var mouse_zoom_out: bool = (
event is InputEventMouseButton
and event.shift_pressed
and event.is_action_pressed("camera_zoom_out")
- ):
+ )
+ var controller_zoom_out: bool = (
+ event is InputEventJoypadButton
+ and event.is_action_pressed("camera_zoom_out")
+ )
+ if mouse_zoom_in or controller_zoom_in:
+ _set_target_zoom(_target_zoom - zoom_step)
+ get_viewport().set_input_as_handled()
+ elif mouse_zoom_out or controller_zoom_out:
_set_target_zoom(_target_zoom + zoom_step)
get_viewport().set_input_as_handled()
@@ -243,6 +1214,29 @@ func _get_current_speed() -> float:
return walk_speed
+func _set_camera_dragging(active: bool) -> void:
+ if _camera_dragging == active:
+ return
+ _camera_dragging = active
+ if active:
+ _camera_drag_prior_mouse_mode = Input.mouse_mode
+ Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
+ elif Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
+ Input.mouse_mode = _camera_drag_prior_mouse_mode
+
+
+func _get_network_aware_speed() -> float:
+ if local_control_enabled:
+ return _get_current_speed()
+ if _network_sneak:
+ return sneak_speed
+ if _network_slow_walk:
+ return slow_walk_speed
+ if _network_sprint:
+ return sprint_speed
+ return walk_speed
+
+
func _rotate_camera(delta_rotation: Vector2) -> void:
_camera_yaw.rotation.y -= delta_rotation.x
var vertical_direction: float = -1.0 if invert_camera_y else 1.0
@@ -253,16 +1247,507 @@ func _rotate_camera(delta_rotation: Vector2) -> void:
)
+func _set_free_camera_active(active: bool) -> void:
+ if _free_camera_active == active:
+ return
+ if active:
+ var camera_transform: Transform3D = _camera.global_transform
+ var duplicated_camera := _camera.duplicate() as Camera3D
+ if duplicated_camera == null:
+ return
+ var camera_body := CharacterBody3D.new()
+ camera_body.name = "FreeCameraBody"
+ camera_body.collision_layer = 0
+ camera_body.collision_mask = collision_mask
+ camera_body.motion_mode = CharacterBody3D.MOTION_MODE_FLOATING
+ add_child(camera_body)
+ camera_body.top_level = true
+ camera_body.global_position = camera_transform.origin
+ var camera_collision := CollisionShape3D.new()
+ camera_collision.name = "FreeCameraCollision"
+ camera_collision.shape = _player_collision_shape.shape
+ camera_body.add_child(camera_collision)
+ duplicated_camera.name = "FreeCamera"
+ duplicated_camera.unique_name_in_owner = false
+ camera_body.add_child(duplicated_camera)
+ duplicated_camera.transform = Transform3D(
+ camera_transform.basis,
+ Vector3.ZERO,
+ )
+ _free_camera_body = camera_body
+ _free_camera = duplicated_camera
+ var camera_rotation: Vector3 = _free_camera.global_rotation
+ _free_camera_yaw = camera_rotation.y
+ _free_camera_pitch = camera_rotation.x
+ _camera.current = false
+ _free_camera.current = true
+ _free_camera_active = true
+ return
+ _free_camera_active = false
+ _set_camera_dragging(false)
+ if _free_camera != null:
+ _free_camera.current = false
+ _free_camera = null
+ if _free_camera_body != null:
+ _free_camera_body.queue_free()
+ _free_camera_body = null
+ _camera.current = local_control_enabled
+
+
+func toggle_free_camera() -> void:
+ if not local_control_enabled:
+ return
+ _set_free_camera_active(not _free_camera_active)
+
+
+func is_free_camera_active() -> bool:
+ return _free_camera_active
+
+
+func _update_free_camera_physics() -> void:
+ if _free_camera == null or _free_camera_body == null:
+ _set_free_camera_active(false)
+ return
+ var input_vector: Vector2 = Input.get_vector(
+ "move_left",
+ "move_right",
+ "move_forward",
+ "move_backward",
+ )
+ var vertical_input: float = (
+ Input.get_action_strength("jump")
+ - Input.get_action_strength("sneak")
+ )
+ var yaw_basis := Basis(Vector3.UP, _free_camera_yaw)
+ var movement: Vector3 = (
+ yaw_basis.x * input_vector.x
+ + yaw_basis.z * input_vector.y
+ + Vector3.UP * vertical_input
+ )
+ if movement.length_squared() > 1.0:
+ movement = movement.normalized()
+ var movement_speed: float = free_camera_speed
+ if Input.is_action_pressed("sprint"):
+ movement_speed *= free_camera_sprint_multiplier
+ _free_camera_body.velocity = movement * movement_speed
+ _free_camera_body.move_and_slide()
+
+
+func _rotate_free_camera(delta_rotation: Vector2) -> void:
+ if _free_camera == null:
+ return
+ _free_camera_yaw -= delta_rotation.x
+ var vertical_direction: float = -1.0 if invert_camera_y else 1.0
+ _free_camera_pitch = clampf(
+ _free_camera_pitch - delta_rotation.y * vertical_direction,
+ deg_to_rad(-90.0),
+ deg_to_rad(90.0),
+ )
+ _free_camera.global_rotation = Vector3(
+ _free_camera_pitch,
+ _free_camera_yaw,
+ 0.0,
+ )
+
+
+func get_active_gameplay_camera() -> Camera3D:
+ if _free_camera_active and _free_camera != null:
+ return _free_camera
+ return _camera
+
+
func _set_target_zoom(value: float) -> void:
_target_zoom = clampf(value, minimum_zoom, maximum_zoom)
+func _apply_axis_deadzone(value: float) -> float:
+ var magnitude: float = absf(value)
+ if magnitude <= controller_camera_deadzone:
+ return 0.0
+ return (
+ signf(value)
+ * (magnitude - controller_camera_deadzone)
+ / (1.0 - controller_camera_deadzone)
+ )
+
+
func set_local_control(enabled: bool) -> void:
+ if not enabled and _free_camera_active:
+ _set_free_camera_active(false)
local_control_enabled = enabled
if is_node_ready():
_camera.current = enabled
if not enabled:
- _camera_dragging = false
+ _set_camera_dragging(false)
+
+
+func set_network_peer_id(peer_id: int) -> void:
+ _network_peer_id = peer_id
+
+
+func get_network_peer_id() -> int:
+ return _network_peer_id
+
+
+func configure_network_remote(authoritative_simulation: bool) -> void:
+ set_local_control(false)
+ _network_authoritative_simulation = authoritative_simulation
+ _network_interpolation_enabled = not authoritative_simulation
+ _network_snapshot_ready = false
+ _network_snapshot_age = 0.0
+ _network_target_grounded = false
+ _network_target_locomotion_state = LocomotionState.IDLE
+ _network_target_animation_action_id = &""
+ _network_target_animation_action_sequence = 0
+ _network_target_animation_action_elapsed = 0.0
+ _camera.current = false
+
+
+func capture_network_input(sequence: int) -> Dictionary:
+ if _sitting_intent_pending and _sitting_intent_sequence < 0:
+ _sitting_intent_sequence = sequence
+ if (
+ not _is_movement_input_enabled()
+ or _water_recovery_active
+ or _free_camera_active
+ ):
+ return {
+ "sequence": sequence,
+ "axis": [0.0, 0.0],
+ "camera_yaw": _camera_yaw.global_rotation.y,
+ "jump": false,
+ "sprint": false,
+ "sneak": false,
+ "slow_walk": false,
+ "sitting": _sitting,
+ "casting": (
+ _fishing_visual_phase == FishingVisualPhase.CASTING
+ ),
+ "animation_action": _make_animation_action_state(),
+ }
+ var axis: Vector2 = Input.get_vector(
+ "move_left",
+ "move_right",
+ "move_forward",
+ "move_backward"
+ )
+ return {
+ "sequence": sequence,
+ "axis": [axis.x, axis.y],
+ "camera_yaw": _camera_yaw.global_rotation.y,
+ "jump": Input.is_action_just_pressed("jump"),
+ "sprint": Input.is_action_pressed("sprint"),
+ "sneak": Input.is_action_pressed("sneak"),
+ "slow_walk": Input.is_action_pressed("slow_walk"),
+ "sitting": _sitting,
+ "casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
+ "animation_action": _make_animation_action_state(),
+ }
+
+
+func apply_authoritative_network_input(data: Dictionary) -> void:
+ var sequence: int = int(data.get("sequence", 0))
+ if sequence <= _last_network_input_sequence:
+ return
+ var axis: Array = data.get("axis", [])
+ if axis.size() != 2:
+ return
+ _last_network_input_sequence = sequence
+ _network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0)
+ _network_camera_yaw = float(data.get("camera_yaw", 0.0))
+ _network_jump_pending = bool(data.get("jump", false))
+ _network_sprint = bool(data.get("sprint", false))
+ _network_sneak = bool(data.get("sneak", false))
+ _network_slow_walk = bool(data.get("slow_walk", false))
+ _apply_animation_action_state(data.get("animation_action", {}))
+ _apply_network_casting(bool(data.get("casting", false)))
+ var sitting_requested: bool = bool(data.get("sitting", false))
+ if sitting_requested and not is_on_floor():
+ _sit_after_landing = true
+ _set_sitting(false)
+ else:
+ _sit_after_landing = false
+ _set_sitting(sitting_requested)
+
+
+func make_network_snapshot(peer_id: int) -> Dictionary:
+ return {
+ "peer_id": peer_id,
+ "acknowledged_input": _last_network_input_sequence,
+ "position": [global_position.x, global_position.y, global_position.z],
+ "velocity": [velocity.x, velocity.y, velocity.z],
+ "visual_yaw": _visuals.rotation.y,
+ "animation_state": NetworkPlayerAnimationProtocol.make_state(
+ _get_authoritative_locomotion_id(),
+ is_on_floor(),
+ _animation_action_id,
+ _animation_action_sequence,
+ _animation_action_elapsed,
+ ),
+ "sitting": _sitting,
+ "casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
+ }
+
+
+func push_network_snapshot(snapshot: Dictionary) -> void:
+ var parsed: Dictionary = _parse_network_snapshot(snapshot)
+ if parsed.is_empty():
+ return
+ _network_target_position = parsed["position"]
+ _network_target_velocity = parsed["velocity"]
+ _network_target_visual_yaw = parsed["visual_yaw"]
+ _apply_network_target_animation_state(parsed["animation_state"])
+ _network_snapshot_age = 0.0
+ _apply_network_casting(bool(parsed["casting"]))
+ _set_sitting(bool(parsed["sitting"]))
+ if not _network_snapshot_ready:
+ global_position = _network_target_position
+ velocity = _network_target_velocity
+ _visuals.rotation.y = _network_target_visual_yaw
+ _network_snapshot_ready = true
+
+
+func apply_local_prediction_correction(snapshot: Dictionary) -> void:
+ var parsed: Dictionary = _parse_network_snapshot(snapshot)
+ if parsed.is_empty():
+ return
+ var acknowledged_input: int = parsed["acknowledged_input"]
+ var sitting_intent_acknowledged: bool = (
+ _sitting_intent_pending
+ and _sitting_intent_sequence >= 0
+ and acknowledged_input >= _sitting_intent_sequence
+ )
+ if sitting_intent_acknowledged:
+ _sitting_intent_pending = false
+ _sitting_intent_sequence = -1
+ if not _sitting_intent_pending:
+ _set_sitting(bool(parsed["sitting"]))
+ var authoritative_position: Vector3 = parsed["position"]
+ var error_distance: float = global_position.distance_to(
+ authoritative_position
+ )
+ if error_distance > 2.0:
+ global_position = authoritative_position
+ elif error_distance > 0.05:
+ global_position = global_position.lerp(authoritative_position, 0.18)
+
+
+func apply_network_teleport(snapshot: Dictionary) -> void:
+ var parsed: Dictionary = _parse_network_snapshot(snapshot)
+ if parsed.is_empty():
+ return
+ global_position = parsed["position"]
+ velocity = parsed["velocity"]
+ _visuals.rotation.y = parsed["visual_yaw"]
+ _apply_network_casting(bool(parsed["casting"]))
+ _set_sitting(bool(parsed["sitting"]))
+ _network_target_position = global_position
+ _network_target_velocity = velocity
+ _network_target_visual_yaw = _visuals.rotation.y
+ _apply_network_target_animation_state(parsed["animation_state"])
+ _network_snapshot_age = 0.0
+ _network_snapshot_ready = true
+
+
+func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
+ var snapshot_position: Variant = snapshot.get("position")
+ var network_velocity: Variant = snapshot.get("velocity")
+ if (
+ typeof(snapshot_position) != TYPE_ARRAY
+ or typeof(network_velocity) != TYPE_ARRAY
+ or snapshot_position.size() != 3
+ or network_velocity.size() != 3
+ ):
+ return {}
+ var parsed_position := Vector3(
+ float(snapshot_position[0]),
+ float(snapshot_position[1]),
+ float(snapshot_position[2])
+ )
+ var parsed_velocity := Vector3(
+ float(network_velocity[0]),
+ float(network_velocity[1]),
+ float(network_velocity[2])
+ )
+ var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0))
+ var animation_state_value: Variant = snapshot.get("animation_state")
+ if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value):
+ return {}
+ var animation_state: Dictionary = (
+ animation_state_value as Dictionary
+ ).duplicate(true)
+ if (
+ snapshot.has("acknowledged_input")
+ and (
+ typeof(snapshot.get("acknowledged_input")) != TYPE_INT
+ or int(snapshot.get("acknowledged_input")) < 0
+ )
+ ):
+ return {}
+ var acknowledged_input: int = int(
+ snapshot.get("acknowledged_input", 0)
+ )
+ if snapshot.has("sitting") and typeof(snapshot.get("sitting")) != TYPE_BOOL:
+ return {}
+ var sitting: bool = bool(snapshot.get("sitting", false))
+ if snapshot.has("casting") and typeof(snapshot.get("casting")) != TYPE_BOOL:
+ return {}
+ var casting: bool = bool(snapshot.get("casting", false))
+ if (
+ not parsed_position.is_finite()
+ or not parsed_velocity.is_finite()
+ or not is_finite(visual_yaw)
+ ):
+ return {}
+ return {
+ "position": parsed_position,
+ "velocity": parsed_velocity,
+ "visual_yaw": visual_yaw,
+ "animation_state": animation_state,
+ "acknowledged_input": acknowledged_input,
+ "sitting": sitting,
+ "casting": casting,
+ }
+
+
+func _make_animation_action_state() -> Dictionary:
+ return NetworkPlayerAnimationProtocol.make_action_state(
+ _animation_action_id,
+ _animation_action_sequence,
+ _animation_action_elapsed,
+ )
+
+
+func _apply_network_target_animation_state(state: Dictionary) -> void:
+ if not NetworkPlayerAnimationProtocol.validate_state(state):
+ return
+ _network_target_grounded = bool(state["grounded"])
+ _network_target_locomotion_state = _locomotion_state_from_id(
+ StringName(str(state["locomotion_id"]))
+ )
+ var action: Dictionary = state["action"]
+ _network_target_animation_action_id = StringName(str(action["id"]))
+ _network_target_animation_action_sequence = int(action["sequence"])
+ _network_target_animation_action_elapsed = float(action["elapsed"])
+
+
+func _get_authoritative_locomotion_state() -> LocomotionState:
+ if (
+ _network_authoritative_simulation
+ and _is_movement_input_enabled()
+ and not _water_recovery_active
+ and not _sitting
+ and _network_axis.length_squared() > 0.0025
+ ):
+ return (
+ LocomotionState.RUNNING
+ if _network_sprint
+ else LocomotionState.WALKING
+ )
+ return _locomotion_state_from_velocity()
+
+
+func _get_authoritative_locomotion_id() -> StringName:
+ return _locomotion_id_from_state(_get_authoritative_locomotion_state())
+
+
+func _get_presented_locomotion_state() -> LocomotionState:
+ if _network_interpolation_enabled and _network_snapshot_ready:
+ return _network_target_locomotion_state
+ return _locomotion_state_from_velocity()
+
+
+func _get_presented_animation_action() -> Dictionary:
+ if _network_interpolation_enabled and _network_snapshot_ready:
+ return NetworkPlayerAnimationProtocol.make_action_state(
+ _network_target_animation_action_id,
+ _network_target_animation_action_sequence,
+ minf(
+ _network_target_animation_action_elapsed + _network_snapshot_age,
+ NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
+ ),
+ )
+ return _make_animation_action_state()
+
+
+func _locomotion_id_from_state(state: LocomotionState) -> StringName:
+ match state:
+ LocomotionState.WALKING:
+ return NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING
+ LocomotionState.RUNNING:
+ return NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING
+ _:
+ return NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE
+
+
+func _locomotion_state_from_id(state_id: StringName) -> LocomotionState:
+ match state_id:
+ NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING:
+ return LocomotionState.WALKING
+ NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING:
+ return LocomotionState.RUNNING
+ _:
+ return LocomotionState.IDLE
+
+
+static func supports_network_animation_action(action_id: StringName) -> bool:
+ return action_id in NETWORK_ANIMATION_ACTION_IDS
+
+
+func _locomotion_state_from_velocity() -> LocomotionState:
+ var horizontal_speed_squared: float = (
+ velocity.x * velocity.x + velocity.z * velocity.z
+ )
+ if horizontal_speed_squared <= 0.0025:
+ return LocomotionState.IDLE
+ var fastest_non_sprint_speed := maxf(
+ walk_speed,
+ maxf(sneak_speed, slow_walk_speed)
+ )
+ var running_threshold := fastest_non_sprint_speed + 0.5
+ if horizontal_speed_squared > running_threshold * running_threshold:
+ return LocomotionState.RUNNING
+ return LocomotionState.WALKING
+
+
+func _get_presented_grounded() -> bool:
+ if _network_interpolation_enabled and _network_snapshot_ready:
+ return _network_target_grounded
+ return is_on_floor()
+
+
+func _apply_network_casting(casting: bool) -> void:
+ if casting:
+ if _fishing_visual_phase == FishingVisualPhase.NONE:
+ set_casting_visual()
+ elif _fishing_visual_phase == FishingVisualPhase.CASTING:
+ set_fishing_visual(false)
+
+
+func _update_network_interpolation(delta: float) -> void:
+ if not _network_snapshot_ready:
+ return
+ _network_snapshot_age = minf(_network_snapshot_age + delta, 0.1)
+ var predicted_position: Vector3 = (
+ _network_target_position
+ + _network_target_velocity * _network_snapshot_age
+ )
+ var projected_position: Vector3 = (
+ global_position + _network_target_velocity * delta
+ )
+ if projected_position.distance_to(predicted_position) > 2.0:
+ global_position = predicted_position
+ else:
+ global_position = projected_position.lerp(
+ predicted_position,
+ 1.0 - exp(-10.0 * delta)
+ )
+ velocity = _network_target_velocity
+ _visuals.rotation.y = lerp_angle(
+ _visuals.rotation.y,
+ _network_target_visual_yaw,
+ 1.0 - exp(-14.0 * delta)
+ )
func is_local_control_enabled() -> bool:
@@ -280,10 +1765,37 @@ func is_movement_enabled() -> bool:
return _movement_enabled
+func set_local_input_suppressed(owner: StringName, suppressed: bool) -> void:
+ if owner.is_empty():
+ return
+ if suppressed:
+ _local_input_suppressors[owner] = true
+ velocity.x = 0.0
+ velocity.z = 0.0
+ _set_camera_dragging(false)
+ else:
+ _local_input_suppressors.erase(owner)
+
+
+func _is_movement_input_enabled() -> bool:
+ return _movement_enabled and _local_input_suppressors.is_empty()
+
+
+func _is_camera_input_enabled() -> bool:
+ return _camera_input_enabled and _local_input_suppressors.is_empty()
+
+
func set_camera_input_enabled(enabled: bool) -> void:
_camera_input_enabled = enabled
if not enabled:
- _camera_dragging = false
+ _set_camera_dragging(false)
+
+
+func set_camera_active(active: bool) -> void:
+ if _free_camera_active and _free_camera != null:
+ _free_camera.current = active
+ else:
+ _camera.current = active
func apply_camera_settings(
@@ -309,6 +1821,21 @@ func set_water_recovery_active(active: bool) -> void:
velocity = Vector3.ZERO
+func is_water_recovery_active() -> bool:
+ return _water_recovery_active
+
+
+func set_remote_recovery_presentation(active: bool) -> void:
+ if local_control_enabled or active == _remote_recovery_presentation_active:
+ return
+ _remote_recovery_presentation_active = active
+ _remote_recovery_elapsed = 0.0
+ if active:
+ _remote_recovery_visual_origin = _visuals.position
+ else:
+ _visuals.position = _remote_recovery_visual_origin
+
+
func prepare_for_water_recovery() -> void:
_restore_gameplay_presentation_for_recovery()
@@ -331,10 +1858,42 @@ func get_body_center_position() -> Vector3:
return global_position + Vector3.UP * body_center_height
+func get_chat_anchor_position() -> Vector3:
+ var highest_y := -INF
+ for node: Node in _visuals.find_children("*", "MeshInstance3D", true, false):
+ var mesh_instance := node as MeshInstance3D
+ if mesh_instance == null or not mesh_instance.is_visible_in_tree():
+ continue
+ var bounds := mesh_instance.get_aabb()
+ for endpoint_index: int in range(8):
+ var world_point := (
+ mesh_instance.global_transform * bounds.get_endpoint(endpoint_index)
+ )
+ highest_y = maxf(highest_y, world_point.y)
+ if not is_finite(highest_y):
+ highest_y = get_body_center_position().y + body_center_height
+ return Vector3(global_position.x, highest_y + 0.22, global_position.z)
+
+
func get_body_center_height() -> float:
return body_center_height
+func get_gameplay_camera() -> Camera3D:
+ return _camera
+
+
+func set_remote_presentation_visible(value: bool) -> void:
+ if local_control_enabled:
+ return
+ _remote_presentation_visible = value
+ _visuals.visible = value
+
+
+func is_remote_presentation_visible() -> bool:
+ return _remote_presentation_visible
+
+
func get_fishing_rod_tip() -> Marker3D:
return _fishing_rod_tip
@@ -343,11 +1902,314 @@ func get_fishing_rod() -> Node3D:
return _fishing_rod
-func set_active_item_is_rod(active_is_rod: bool) -> void:
+func set_active_item_is_rod(
+ active_is_rod: bool,
+ animate_transition: bool = false,
+) -> void:
+ _active_item_is_rod = active_is_rod
if _showcase_rod_state_stored:
_showcase_rod_visibility = active_is_rod
- else:
+ return
+ var visibility_changed: bool = _fishing_rod.visible != active_is_rod
+ if not animate_transition or not visibility_changed:
_fishing_rod.visible = active_is_rod
+ return
+ if _has_held_show_item() or _showcase_animation_active:
+ if not active_is_rod:
+ _fishing_rod.visible = false
+ return
+ if active_is_rod:
+ _fishing_rod.visible = false
+ _begin_pocket_visual(
+ PocketVisualTarget.IDLE_ITEM,
+ false,
+ false,
+ _apply_active_rod_visibility,
+ )
+
+
+func _apply_active_rod_visibility() -> void:
+ if _showcase_rod_state_stored:
+ _showcase_rod_visibility = _active_item_is_rod
+ return
+ _fishing_rod.visible = _active_item_is_rod
+
+
+func set_active_art_kit(icon: Texture2D, should_show: bool) -> void:
+ var new_visible: bool = should_show and icon != null
+ if not new_visible:
+ if _held_art_kit_visible:
+ _begin_pocket_visual(
+ PocketVisualTarget.ART_KIT,
+ true,
+ _held_fish_visible,
+ _clear_active_art_kit,
+ true,
+ _apply_pending_held_fish,
+ )
+ else:
+ _clear_active_art_kit()
+ return
+ var previous_show_pose: bool = _has_held_show_item()
+ var item_changed: bool = (
+ not _held_art_kit_visible
+ or _held_art_kit_sprite.texture != icon
+ )
+ _held_art_kit_visible = true
+ _held_art_kit_sprite.texture = icon
+ _held_art_kit_sprite.visible = true
+ if not previous_show_pose:
+ _held_art_kit_display.visible = false
+ _fishing_rod.visible = false
+ if item_changed:
+ _begin_pocket_visual(
+ PocketVisualTarget.ART_KIT,
+ previous_show_pose,
+ true,
+ Callable(),
+ false,
+ _apply_active_art_kit_visual,
+ )
+ else:
+ _apply_active_art_kit_visual()
+ _update_character_animation()
+
+
+func set_held_fish(
+ fish: FishDataType,
+ display_scale: float,
+ should_show: bool,
+ animate_put_away: bool = false,
+) -> void:
+ if not should_show or fish == null or fish.display_texture == null:
+ if animate_put_away and _held_fish_visible:
+ _begin_pocket_visual(
+ PocketVisualTarget.HELD_FISH,
+ true,
+ _held_art_kit_visible,
+ _clear_held_fish,
+ )
+ else:
+ if _pocket_visual_target == PocketVisualTarget.HELD_FISH:
+ _cancel_pocket_visual()
+ _clear_held_fish()
+ return
+ var previous_show_pose: bool = _has_held_show_item()
+ var item_changed: bool = (
+ not _held_fish_visible
+ or _held_fish_sprite.texture != fish.display_texture
+ )
+ _held_fish_visible = true
+ _pending_held_fish_texture = fish.display_texture
+ _pending_held_fish_scale = (
+ Vector3.ONE
+ * maxf(display_scale, 0.01)
+ * catch_presentation_base_scale
+ )
+ if not previous_show_pose:
+ _held_fish_display.visible = false
+ _fishing_rod.visible = false
+ if item_changed:
+ _begin_pocket_visual(
+ PocketVisualTarget.HELD_FISH,
+ previous_show_pose,
+ true,
+ Callable(),
+ false,
+ _apply_pending_held_fish,
+ )
+ else:
+ _apply_pending_held_fish()
+ _update_character_animation()
+
+
+func _has_held_show_item() -> bool:
+ return _held_fish_visible or _held_art_kit_visible
+
+
+func _clear_held_fish() -> void:
+ var was_visible: bool = _held_fish_visible
+ _held_fish_visible = false
+ _pending_held_fish_texture = null
+ _pending_held_fish_scale = Vector3.ONE
+ _held_fish_display.visible = false
+ _held_fish_display.scale = Vector3.ONE
+ _held_fish_sprite.texture = null
+ if was_visible:
+ _apply_active_rod_visibility()
+ _update_character_animation()
+
+
+func _apply_pending_held_fish() -> void:
+ if not _held_fish_visible or _pending_held_fish_texture == null:
+ return
+ _held_fish_sprite.texture = _pending_held_fish_texture
+ _held_fish_display.scale = _pending_held_fish_scale
+ _held_fish_display.visible = true
+
+
+func _clear_active_art_kit() -> void:
+ var was_visible: bool = _held_art_kit_visible
+ _held_art_kit_visible = false
+ _held_art_kit_display.visible = false
+ _held_art_kit_sprite.texture = null
+ _held_art_kit_sprite.visible = false
+ if was_visible:
+ _apply_active_rod_visibility()
+ if _held_fish_visible:
+ _apply_pending_held_fish()
+ _update_character_animation()
+
+
+func _apply_active_art_kit_visual() -> void:
+ if not _held_art_kit_visible or _held_art_kit_sprite.texture == null:
+ return
+ _held_art_kit_display.visible = true
+
+
+func _begin_pocket_visual(
+ target: PocketVisualTarget,
+ starts_in_show_pose: bool,
+ ends_in_show_pose: bool,
+ finished_callback: Callable,
+ callback_runs_on_interrupt: bool = true,
+ midpoint_callback: Callable = Callable(),
+) -> void:
+ var animation_name: StringName
+ if _sitting:
+ if starts_in_show_pose:
+ animation_name = (
+ CHARACTER_POCKET_SIT_SHOW_SHOW_ANIMATION
+ if ends_in_show_pose
+ else CHARACTER_POCKET_SIT_SHOW_IDLE_ANIMATION
+ )
+ else:
+ animation_name = (
+ CHARACTER_POCKET_SIT_IDLE_SHOW_ANIMATION
+ if ends_in_show_pose
+ else CHARACTER_POCKET_SIT_IDLE_IDLE_ANIMATION
+ )
+ elif _is_moving_for_pocket_visual():
+ if starts_in_show_pose:
+ animation_name = (
+ CHARACTER_POCKET_WALKING_SHOW_SHOW_ANIMATION
+ if ends_in_show_pose
+ else CHARACTER_POCKET_WALKING_SHOW_IDLE_ANIMATION
+ )
+ else:
+ animation_name = (
+ CHARACTER_POCKET_WALKING_IDLE_SHOW_ANIMATION
+ if ends_in_show_pose
+ else CHARACTER_POCKET_WALKING_IDLE_IDLE_ANIMATION
+ )
+ elif starts_in_show_pose:
+ animation_name = (
+ CHARACTER_POCKET_SHOW_SHOW_ANIMATION
+ if ends_in_show_pose
+ else CHARACTER_POCKET_SHOW_IDLE_ANIMATION
+ )
+ else:
+ animation_name = (
+ CHARACTER_POCKET_IDLE_SHOW_ANIMATION
+ if ends_in_show_pose
+ else CHARACTER_POCKET_IDLE_IDLE_ANIMATION
+ )
+ if _pocket_visual_active:
+ if (
+ _pocket_visual_target == target
+ and _pocket_visual_animation == animation_name
+ ):
+ return
+ if _pocket_visual_callback_runs_on_interrupt:
+ _complete_pocket_visual()
+ else:
+ _cancel_pocket_visual()
+ if (
+ animation_name.is_empty()
+ or _character_animation_player == null
+ or not _character_animation_player.has_animation(animation_name)
+ ):
+ if midpoint_callback.is_valid():
+ midpoint_callback.call()
+ if finished_callback.is_valid():
+ finished_callback.call()
+ _update_character_animation()
+ return
+ _pocket_visual_active = true
+ _pocket_visual_target = target
+ _pocket_visual_animation = animation_name
+ _pocket_visual_finished_callback = finished_callback
+ _pocket_visual_callback_runs_on_interrupt = callback_runs_on_interrupt
+ _pocket_visual_midpoint_callback = midpoint_callback
+ _pocket_visual_midpoint_called = false
+ _pocket_visual_generation += 1
+ var midpoint_generation: int = _pocket_visual_generation
+ _update_character_animation()
+ _schedule_pocket_visual_midpoint(midpoint_generation, animation_name)
+
+
+func _is_moving_for_pocket_visual() -> bool:
+ var horizontal_velocity := Vector2(velocity.x, velocity.z)
+ return horizontal_velocity.length_squared() > 0.0025
+
+
+func _schedule_pocket_visual_midpoint(
+ generation: int,
+ animation_name: StringName,
+) -> void:
+ if not _pocket_visual_midpoint_callback.is_valid():
+ return
+ var animation: Animation = _character_animation_player.get_animation(
+ animation_name
+ )
+ var midpoint_seconds: float = maxf(animation.length * 0.5, 0.0)
+ if midpoint_seconds > 0.0:
+ await get_tree().create_timer(midpoint_seconds, false).timeout
+ if (
+ generation != _pocket_visual_generation
+ or not _pocket_visual_active
+ or _pocket_visual_animation != animation_name
+ ):
+ return
+ _apply_pocket_visual_midpoint()
+
+
+func _apply_pocket_visual_midpoint() -> void:
+ if _pocket_visual_midpoint_called:
+ return
+ _pocket_visual_midpoint_called = true
+ var midpoint_callback: Callable = _pocket_visual_midpoint_callback
+ _pocket_visual_midpoint_callback = Callable()
+ if midpoint_callback.is_valid():
+ midpoint_callback.call()
+
+
+func _complete_pocket_visual() -> void:
+ if not _pocket_visual_active:
+ return
+ var finished_callback: Callable = _pocket_visual_finished_callback
+ _pocket_visual_active = false
+ _pocket_visual_target = PocketVisualTarget.NONE
+ _pocket_visual_animation = &""
+ _pocket_visual_finished_callback = Callable()
+ _pocket_visual_callback_runs_on_interrupt = true
+ _pocket_visual_midpoint_callback = Callable()
+ _pocket_visual_midpoint_called = false
+ _pocket_visual_generation += 1
+ if finished_callback.is_valid():
+ finished_callback.call()
+ _update_character_animation()
+
+
+func _cancel_pocket_visual() -> void:
+ _pocket_visual_active = false
+ _pocket_visual_target = PocketVisualTarget.NONE
+ _pocket_visual_animation = &""
+ _pocket_visual_finished_callback = Callable()
+ _pocket_visual_callback_runs_on_interrupt = true
+ _pocket_visual_midpoint_callback = Callable()
+ _pocket_visual_midpoint_called = false
+ _pocket_visual_generation += 1
func get_cast_origin_position() -> Vector3:
@@ -357,6 +2219,10 @@ func get_cast_origin_position() -> Vector3:
func begin_catch_showcase(fish_catch: FishCatchType) -> void:
if fish_catch == null or not fish_catch.is_valid():
return
+ if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE:
+ _cancel_pocket_visual()
+ _showcase_animation_active = true
+ set_fishing_visual(false)
_kill_showcase_camera_tween()
_kill_showcase_restore_tween()
_capture_showcase_camera_snapshot()
@@ -376,12 +2242,49 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void:
* catch_presentation_base_scale
)
_catch_display.visible = _catch_sprite.texture != null
+ _update_character_animation()
+
+
+func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void:
+ if local_control_enabled or fish_catch == null or not fish_catch.is_valid():
+ return
+ end_catch_showcase(Callable(), true)
+ _showcase_animation_active = true
+ set_fishing_visual(false)
+ _showcase_rod_visibility = _fishing_rod.visible
+ _showcase_rod_state_stored = true
+ _fishing_rod.visible = false
+ _catch_sprite.texture = fish_catch.fish.display_texture
+ _catch_display.scale = (
+ Vector3.ONE
+ * fish_catch.display_scale
+ * catch_presentation_base_scale
+ )
+ _catch_display.visible = _catch_sprite.texture != null
+ _update_character_animation()
func end_catch_showcase(
restored_callback: Callable = Callable(),
immediate: bool = false,
+ pocket_completed: bool = false,
) -> void:
+ if immediate and _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE:
+ _cancel_pocket_visual()
+ elif (
+ not pocket_completed
+ and _showcase_animation_active
+ and _catch_display.visible
+ ):
+ _begin_pocket_visual(
+ PocketVisualTarget.CATCH_SHOWCASE,
+ true,
+ false,
+ end_catch_showcase.bind(restored_callback, false, true),
+ )
+ return
+ _showcase_animation_active = false
+ _update_character_animation()
_kill_showcase_turn_tween()
_kill_showcase_camera_tween()
_kill_showcase_restore_tween()
@@ -552,7 +2455,10 @@ func _complete_showcase_restore(
_camera_input_enabled = (
_showcase_camera_snapshot.camera_input_enabled
)
- _camera_dragging = _showcase_camera_snapshot.camera_dragging
+ _set_camera_dragging(
+ _showcase_camera_snapshot.camera_dragging
+ and Input.is_action_pressed("camera_drag")
+ )
_showcase_camera_snapshot = null
if restored_callback.is_valid():
restored_callback.call()
@@ -570,7 +2476,7 @@ func _capture_showcase_camera_snapshot() -> void:
_showcase_camera_snapshot.camera_input_enabled = _camera_input_enabled
_showcase_camera_snapshot.camera_dragging = _camera_dragging
_camera_input_enabled = false
- _camera_dragging = false
+ _set_camera_dragging(false)
func _begin_showcase_camera_transition() -> Vector3:
diff --git a/player/player.tscn b/player/player.tscn
index 977040d..43ab900 100644
--- a/player/player.tscn
+++ b/player/player.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=18 format=3]
+[gd_scene load_steps=22 format=3]
[ext_resource type="Script" path="res://player/player.gd" id="1_script"]
[ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"]
@@ -10,32 +10,31 @@
[ext_resource type="Script" path="res://progression/player_fishing_upgrades.gd" id="8_upgrades"]
[ext_resource type="Script" path="res://progression/player_item_effects.gd" id="9_effects"]
[ext_resource type="Script" path="res://progression/player_cooler_capacity.gd" id="10_capacity"]
+[ext_resource type="Script" path="res://player/player_ground_shadow.gd" id="11_ground_shadow"]
+[ext_resource type="Material" path="res://player/materials/player_blob_shadow.tres" id="12_blob_shadow"]
+[ext_resource type="PackedScene" path="res://art/exported/characters/base/netfishing_base_character.glb" id="13_character"]
+[ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"]
+[ext_resource type="Script" path="res://progression/player_experience.gd" id="15_experience"]
+[ext_resource type="Script" path="res://player/sprint_dust_trail.gd" id="16_sprint_dust"]
[sub_resource type="CapsuleShape3D" id="PlayerShape"]
radius = 0.45
height = 1.8
-[sub_resource type="CapsuleMesh" id="PlayerMesh"]
-radius = 0.45
-height = 1.8
+[sub_resource type="QuadMesh" id="GroundShadowMesh"]
+material = ExtResource("12_blob_shadow")
+size = Vector2(1.15, 0.72)
-[sub_resource type="StandardMaterial3D" id="PlayerMaterial"]
-albedo_color = Color(0.95, 0.48, 0.18, 1)
+[sub_resource type="Shader" id="SprintDustShader"]
+code = "shader_type spatial;\nrender_mode blend_mix, depth_draw_never, cull_disabled, unshaded, shadows_disabled;\n\nuniform vec3 dust_color : source_color = vec3(0.61, 0.57, 0.53);\nvarying flat float puff_phase;\nvarying flat float puff_alpha;\n\nvoid vertex() {\n\tpuff_phase = INSTANCE_CUSTOM.x;\n\tpuff_alpha = INSTANCE_CUSTOM.w;\n\tVERTEX.xy *= INSTANCE_CUSTOM.yz;\n\tfloat puff_rotation = puff_phase * 6.28318530718;\n\tfloat rotation_sine = sin(puff_rotation);\n\tfloat rotation_cosine = cos(puff_rotation);\n\tVERTEX.xy = mat2(\n\t\tvec2(rotation_cosine, -rotation_sine),\n\t\tvec2(rotation_sine, rotation_cosine)\n\t) * VERTEX.xy;\n\tMODELVIEW_MATRIX = VIEW_MATRIX * mat4(\n\t\tINV_VIEW_MATRIX[0],\n\t\tINV_VIEW_MATRIX[1],\n\t\tINV_VIEW_MATRIX[2],\n\t\tMODEL_MATRIX[3]\n\t);\n}\n\nvoid fragment() {\n\tvec2 centered_uv = UV * 2.0 - 1.0;\n\tfloat angle = atan(centered_uv.y, centered_uv.x);\n\tfloat edge_wobble =\n\t\tsin(angle * 5.0 + puff_phase * 8.0) * 0.07\n\t\t+ sin(angle * 3.0 - puff_phase * 5.0) * 0.025;\n\tfloat radius = length(centered_uv);\n\tif (radius > 0.82 + edge_wobble) {\n\t\tdiscard;\n\t}\n\tALBEDO = dust_color;\n\tALPHA = puff_alpha;\n}\n"
-[sub_resource type="BoxMesh" id="FacingMarkerMesh"]
-size = Vector3(0.24, 0.2, 0.4)
+[sub_resource type="ShaderMaterial" id="SprintDustMaterial"]
+render_priority = 1
+shader = SubResource("SprintDustShader")
-[sub_resource type="StandardMaterial3D" id="FacingMarkerMaterial"]
-albedo_color = Color(0.12, 0.18, 0.28, 1)
-
-[sub_resource type="CylinderMesh" id="FishingRodMesh"]
-top_radius = 0.025
-bottom_radius = 0.045
-height = 1.2
-
-[sub_resource type="StandardMaterial3D" id="FishingRodMaterial"]
-albedo_color = Color(0.22, 0.12, 0.06, 1)
-roughness = 0.8
+[sub_resource type="QuadMesh" id="SprintDustMesh"]
+material = SubResource("SprintDustMaterial")
+size = Vector2(1, 1)
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
@@ -49,29 +48,36 @@ shape = SubResource("PlayerShape")
[node name="Visuals" type="Node3D" parent="."]
unique_name_in_owner = true
-[node name="MeshInstance3D" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.9, 0)
-mesh = SubResource("PlayerMesh")
-material_override = SubResource("PlayerMaterial")
+[node name="GroundShadow" type="Node3D" parent="."]
+script = ExtResource("11_ground_shadow")
-[node name="FacingMarker" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 1.0, -0.55)
-mesh = SubResource("FacingMarkerMesh")
-material_override = SubResource("FacingMarkerMaterial")
-
-[node name="FishingRod" type="Node3D" parent="Visuals"]
+[node name="GroundProbe" type="RayCast3D" parent="GroundShadow"]
unique_name_in_owner = true
-position = Vector3(0.42, 1.05, -0.2)
-rotation = Vector3(-0.436332, 0, 0)
+position = Vector3(0, 0.45, 0)
+target_position = Vector3(0, -4.5, 0)
+collision_mask = 9
+collide_with_areas = true
-[node name="RodMesh" type="MeshInstance3D" parent="Visuals/FishingRod"]
-position = Vector3(0, 0.6, 0)
-mesh = SubResource("FishingRodMesh")
-material_override = SubResource("FishingRodMaterial")
-
-[node name="FishingRodTip" type="Marker3D" parent="Visuals/FishingRod"]
+[node name="GroundShadowPlacement" type="Node3D" parent="GroundShadow"]
unique_name_in_owner = true
-position = Vector3(0, 1.2, 0)
+
+[node name="GroundShadowVisual" type="MeshInstance3D" parent="GroundShadow/GroundShadowPlacement"]
+unique_name_in_owner = true
+rotation_degrees = Vector3(-90, 0, 0)
+cast_shadow = 0
+mesh = SubResource("GroundShadowMesh")
+
+[node name="SprintDust" type="MultiMeshInstance3D" parent="."]
+unique_name_in_owner = true
+cast_shadow = 0
+script = ExtResource("16_sprint_dust")
+puff_mesh = SubResource("SprintDustMesh")
+
+[node name="CharacterRig" parent="Visuals" instance=ExtResource("13_character")]
+rotation_degrees = Vector3(0, 180, 0)
+
+[node name="AnimationPlayer" parent="Visuals/CharacterRig"]
+autoplay = "idle"
[node name="CastOrigin" type="Marker3D" parent="."]
unique_name_in_owner = true
@@ -113,21 +119,13 @@ script = ExtResource("9_effects")
unique_name_in_owner = true
script = ExtResource("10_capacity")
-[node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"]
+[node name="ArtUnlocks" type="Node" parent="."]
unique_name_in_owner = true
-position = Vector3(0, 1.45, -1.15)
+script = ExtResource("14_art_unlocks")
-[node name="CatchDisplay" type="Node3D" parent="Visuals/CatchDisplayAnchor"]
+[node name="Experience" type="Node" parent="."]
unique_name_in_owner = true
-visible = false
-
-[node name="CatchSprite" type="Sprite3D" parent="Visuals/CatchDisplayAnchor/CatchDisplay"]
-unique_name_in_owner = true
-pixel_size = 0.012
-billboard = 1
-shaded = false
-double_sided = true
-texture_filter = 0
+script = ExtResource("15_experience")
[node name="CameraYaw" type="Node3D" parent="."]
unique_name_in_owner = true
diff --git a/player/player_ground_shadow.gd b/player/player_ground_shadow.gd
new file mode 100644
index 0000000..0efa948
--- /dev/null
+++ b/player/player_ground_shadow.gd
@@ -0,0 +1,66 @@
+class_name PlayerGroundShadow
+extends Node3D
+
+@export_range(0.0, 0.1, 0.001) var surface_offset: float = 0.018
+@export_range(0.0, 0.5, 0.01) var full_opacity_height: float = 0.12
+@export_range(0.5, 4.0, 0.05) var fade_end_height: float = 2.0
+@export_range(1.0, 20.0, 0.5) var fade_speed: float = 10.0
+@export_range(0.25, 1.0, 0.01) var airborne_scale: float = 0.72
+
+@onready var _player := get_parent() as Player
+@onready var _ground_probe: RayCast3D = %GroundProbe
+@onready var _placement: Node3D = %GroundShadowPlacement
+@onready var _visual: MeshInstance3D = %GroundShadowVisual
+
+var _opacity: float = 0.0
+var _visual_scale: float = 1.0
+
+
+func _ready() -> void:
+ _visual.visible = false
+
+
+func _physics_process(delta: float) -> void:
+ _ground_probe.force_raycast_update()
+ var target_opacity := 0.0
+ var target_scale := airborne_scale
+ var can_place := (
+ _player != null
+ and _player.is_remote_presentation_visible()
+ and not _player.is_water_recovery_active()
+ and _ground_probe.is_colliding()
+ and not (_ground_probe.get_collider() is PlayerWaterTrigger)
+ )
+ if can_place:
+ var hit_position: Vector3 = _ground_probe.get_collision_point()
+ var hit_normal: Vector3 = _ground_probe.get_collision_normal().normalized()
+ var height := maxf(global_position.y - hit_position.y, 0.0)
+ var height_fade := 1.0 - smoothstep(
+ full_opacity_height,
+ fade_end_height,
+ height,
+ )
+ target_opacity = height_fade
+ target_scale = lerpf(airborne_scale, 1.0, height_fade)
+ _place_on_surface(hit_position, hit_normal)
+ _opacity = move_toward(_opacity, target_opacity, fade_speed * delta)
+ _visual_scale = move_toward(
+ _visual_scale,
+ target_scale,
+ fade_speed * 0.35 * delta,
+ )
+ _placement.scale = Vector3.ONE * _visual_scale
+ _visual.visible = _opacity > 0.001
+ _visual.transparency = 1.0 - _opacity
+
+
+func _place_on_surface(surface_position: Vector3, normal: Vector3) -> void:
+ var tangent := Vector3.RIGHT - normal * Vector3.RIGHT.dot(normal)
+ if tangent.length_squared() < 0.001:
+ tangent = Vector3.FORWARD - normal * Vector3.FORWARD.dot(normal)
+ tangent = tangent.normalized()
+ var bitangent := tangent.cross(normal).normalized()
+ _placement.global_transform = Transform3D(
+ Basis(tangent, normal, bitangent),
+ surface_position + normal * surface_offset,
+ )
diff --git a/player/player_ground_shadow.gd.uid b/player/player_ground_shadow.gd.uid
new file mode 100644
index 0000000..1dd7974
--- /dev/null
+++ b/player/player_ground_shadow.gd.uid
@@ -0,0 +1 @@
+uid://i8vkdcyckug3
diff --git a/player/player_visual_presenter.gd b/player/player_visual_presenter.gd
new file mode 100644
index 0000000..7a1ae1c
--- /dev/null
+++ b/player/player_visual_presenter.gd
@@ -0,0 +1,318 @@
+class_name PlayerVisualPresenter
+extends RefCounted
+
+const PlayerScene: PackedScene = preload("res://player/player.tscn")
+const FurPatternShader: Shader = preload("res://player/fur_pattern.gdshader")
+const RUNTIME_MATERIAL_META: StringName = &"netfishing_runtime_material"
+const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = (
+ &"netfishing_runtime_fur_pattern_material"
+)
+const EAR_MESH_NAMES: Array[String] = [
+ "ears_antlers_round",
+ "ears_bear",
+ "ears_bunny",
+ "ears_pointy_long",
+ "ears_pointy_short",
+ "ears_pointy_wide",
+]
+const TAIL_MESH_NAMES: Array[String] = [
+ "tails_bear",
+ "tails_bunny",
+ "tails_cat",
+ "tails_fox",
+ "tails_gator",
+ "tails_pointy",
+]
+
+static var _feature_uv_aspects: Dictionary = {}
+
+
+static func feature_uv_aspect(feature_name: String) -> float:
+ if _feature_uv_aspects.has(feature_name):
+ return float(_feature_uv_aspects[feature_name])
+ var visuals := instantiate_visuals()
+ var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
+ var aspects: Array[float] = []
+ for head_name: String in ["head_round", "head_pointy"]:
+ var mesh_instance := skeleton.get_node_or_null(
+ "%s_%s" % [head_name, feature_name]
+ ) as MeshInstance3D
+ if mesh_instance == null or mesh_instance.mesh == null:
+ continue
+ var arrays := mesh_instance.mesh.surface_get_arrays(0)
+ var uv: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
+ if uv.is_empty():
+ continue
+ var min_uv := Vector2(INF, INF)
+ var max_uv := Vector2(-INF, -INF)
+ for value: Vector2 in uv:
+ min_uv = min_uv.min(value)
+ max_uv = max_uv.max(value)
+ var size := max_uv - min_uv
+ if size.y > 0.001:
+ aspects.append(size.x / size.y)
+ visuals.free()
+ var result := 1.0
+ if not aspects.is_empty():
+ result = 0.0
+ for aspect: float in aspects:
+ result += aspect
+ result /= aspects.size()
+ result = clampf(result, 0.35, 2.5)
+ _feature_uv_aspects[feature_name] = result
+ return result
+
+
+static func instantiate_visuals() -> Node3D:
+ var source: Player = PlayerScene.instantiate()
+ var visuals := source.get_node("Visuals") as Node3D
+ var source_rod_attachment: Node = visuals.find_child(
+ "FishingRodAttachment", true, false
+ )
+ var source_rod_parent: Node = null
+ var source_rod_index: int = -1
+ if source_rod_attachment != null:
+ source_rod_parent = source_rod_attachment.get_parent()
+ source_rod_index = source_rod_attachment.get_index()
+ source_rod_attachment.owner = null
+ source_rod_parent.remove_child(source_rod_attachment)
+ var presentation := visuals.duplicate(
+ Node.DUPLICATE_SIGNALS
+ | Node.DUPLICATE_GROUPS
+ | Node.DUPLICATE_SCRIPTS
+ | Node.DUPLICATE_USE_INSTANTIATION
+ ) as Node3D
+ if source_rod_attachment != null and source_rod_parent != null:
+ source_rod_parent.add_child(source_rod_attachment)
+ source_rod_parent.move_child(source_rod_attachment, source_rod_index)
+ source.free()
+ presentation.name = "PlayerVisuals"
+ return presentation
+
+
+static func apply_appearance(
+ visuals: Node3D,
+ snapshot: Dictionary,
+) -> void:
+ if not CharacterCustomizationCatalog.validate_snapshot(snapshot):
+ return
+ # Gameplay and preview deliberately share this presentation seam. Keep all
+ # mesh selection here so profile previews and network avatars stay identical.
+ var character_scale: float = CharacterCustomizationCatalog.character_scale(
+ snapshot.get(
+ CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
+ CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
+ )
+ )
+ visuals.scale = Vector3.ONE * character_scale
+ var skeleton: Skeleton3D = visuals.find_child("Skeleton3D", true, false) as Skeleton3D
+ if skeleton == null:
+ return
+
+ var species_id: String = CharacterCustomizationCatalog.canonical_option_id(
+ "species", str(snapshot.get("species", "round"))
+ )
+ var head_id: String = "head_pointy" if species_id == "pointy" else "head_round"
+ var head_ids: Array[String] = ["head_pointy", "head_round"]
+ for head_name: String in head_ids:
+ var head: Node3D = skeleton.get_node_or_null(head_name) as Node3D
+ if head != null:
+ head.visible = head_name == head_id
+ for feature: String in ["eyes", "mouth", "nose"]:
+ var decal: Node3D = skeleton.get_node_or_null(
+ "%s_%s" % [head_name, feature]
+ ) as Node3D
+ if decal != null:
+ var feature_id := CharacterCustomizationCatalog.canonical_option_id(
+ feature, str(snapshot.get(feature, "none"))
+ )
+ decal.visible = (
+ head_name == head_id and feature_id != "none"
+ )
+
+ var selected_ears: String = CharacterCustomizationCatalog.canonical_option_id(
+ "ears", str(snapshot.get("ears", "none"))
+ )
+ for ear_name: String in EAR_MESH_NAMES:
+ var ears: Node3D = skeleton.get_node_or_null(ear_name) as Node3D
+ if ears != null:
+ ears.visible = selected_ears == ear_name.trim_prefix("ears_")
+ var selected_tail: String = CharacterCustomizationCatalog.canonical_option_id(
+ "tail", str(snapshot.get("tail", "none"))
+ )
+ for tail_name: String in TAIL_MESH_NAMES:
+ var tail: Node3D = skeleton.get_node_or_null(tail_name) as Node3D
+ if tail != null:
+ tail.visible = selected_tail == tail_name.trim_prefix("tails_")
+
+ var fur_color: Color = CharacterCustomizationCatalog.option_color(
+ "fur_pattern", str(snapshot.get("fur_pattern", "white"))
+ )
+ for fur_mesh_name: String in [
+ "body_main",
+ "body_arms",
+ "head_pointy",
+ "head_round",
+ ]:
+ _set_fur_mesh(skeleton, fur_mesh_name, snapshot, fur_color)
+ for ear_name: String in EAR_MESH_NAMES:
+ _set_fur_mesh(skeleton, ear_name, snapshot, fur_color)
+ for tail_name: String in TAIL_MESH_NAMES:
+ _set_fur_mesh(skeleton, tail_name, snapshot, fur_color)
+
+ var eye_id: String = CharacterCustomizationCatalog.canonical_option_id(
+ "eyes", str(snapshot.get("eyes", "simple_shine"))
+ )
+ var mouth_id: String = CharacterCustomizationCatalog.canonical_option_id(
+ "mouth", str(snapshot.get("mouth", "three"))
+ )
+ var nose_id: String = CharacterCustomizationCatalog.canonical_option_id(
+ "nose", str(snapshot.get("nose", "dog_round"))
+ )
+ _set_feature_texture(
+ skeleton,
+ "eyes",
+ CharacterCustomizationCatalog.texture_for("eyes", eye_id),
+ )
+ _set_feature_texture(
+ skeleton,
+ "mouth",
+ CharacterCustomizationCatalog.texture_for("mouth", mouth_id),
+ )
+ _set_feature_texture(
+ skeleton,
+ "nose",
+ CharacterCustomizationCatalog.texture_for("nose", nose_id),
+ )
+
+
+static func _set_feature_texture(
+ skeleton: Skeleton3D,
+ feature_name: String,
+ texture: Texture2D,
+) -> void:
+ for head_name: String in ["head_pointy", "head_round"]:
+ var mesh_instance: MeshInstance3D = skeleton.get_node_or_null(
+ "%s_%s" % [head_name, feature_name]
+ ) as MeshInstance3D
+ if texture == null:
+ if mesh_instance != null:
+ mesh_instance.visible = false
+ continue
+ var material: StandardMaterial3D = _runtime_material(mesh_instance)
+ if material != null:
+ # Feature plates are transparent geometry. The exported base model may
+ # have no preview texture, so enforce the runtime alpha mode when a
+ # drop-in PNG is assigned.
+ material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ material.albedo_color = Color.WHITE
+ material.albedo_texture = texture
+
+
+static func _set_fur_mesh(
+ skeleton: Skeleton3D,
+ mesh_name: String,
+ snapshot: Dictionary,
+ base_color: Color,
+) -> void:
+ var mesh_instance := skeleton.get_node_or_null(mesh_name) as MeshInstance3D
+ if mesh_instance == null:
+ return
+ var style_id := CharacterCustomizationCatalog.canonical_option_id(
+ CharacterCustomizationCatalog.FUR_STYLE_ID,
+ str(snapshot.get(
+ CharacterCustomizationCatalog.FUR_STYLE_ID,
+ CharacterCustomizationCatalog.DEFAULT_FUR_STYLE,
+ )),
+ )
+ var pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture(
+ style_id,
+ mesh_name,
+ )
+ if pattern_texture == null:
+ var solid_material := _runtime_material(mesh_instance)
+ if solid_material != null:
+ solid_material.albedo_color = base_color
+ mesh_instance.material_override = solid_material
+ return
+
+ var pattern_material := _runtime_fur_pattern_material(mesh_instance)
+ pattern_material.set_shader_parameter("pattern_texture", pattern_texture)
+ pattern_material.set_shader_parameter("base_color", base_color)
+ pattern_material.set_shader_parameter(
+ "red_zone_color",
+ _fur_snapshot_color(snapshot, "fur_color_2", "cream"),
+ )
+ pattern_material.set_shader_parameter(
+ "green_zone_color",
+ _fur_snapshot_color(snapshot, "fur_color_3", "brown"),
+ )
+ pattern_material.set_shader_parameter(
+ "blue_zone_color",
+ _fur_snapshot_color(snapshot, "fur_color_4", "red"),
+ )
+ mesh_instance.material_override = pattern_material
+
+
+static func _fur_snapshot_color(
+ snapshot: Dictionary,
+ category_id: String,
+ default_id: String,
+) -> Color:
+ return CharacterCustomizationCatalog.option_color(
+ category_id,
+ str(snapshot.get(category_id, default_id)),
+ )
+
+
+static func _runtime_material(
+ mesh_instance: MeshInstance3D,
+) -> StandardMaterial3D:
+ if mesh_instance == null or mesh_instance.mesh == null:
+ return null
+ if mesh_instance.has_meta(RUNTIME_MATERIAL_META):
+ var runtime := mesh_instance.get_meta(
+ RUNTIME_MATERIAL_META
+ ) as StandardMaterial3D
+ _configure_matte_material(runtime)
+ return runtime
+ var source: StandardMaterial3D = mesh_instance.mesh.surface_get_material(
+ 0
+ ) as StandardMaterial3D
+ if source == null:
+ return null
+ var material: StandardMaterial3D = source.duplicate() as StandardMaterial3D
+ _configure_matte_material(material)
+ mesh_instance.material_override = material
+ mesh_instance.set_meta(RUNTIME_MATERIAL_META, material)
+ return material
+
+
+static func _runtime_fur_pattern_material(
+ mesh_instance: MeshInstance3D,
+) -> ShaderMaterial:
+ if mesh_instance.has_meta(RUNTIME_FUR_PATTERN_MATERIAL_META):
+ return mesh_instance.get_meta(
+ RUNTIME_FUR_PATTERN_MATERIAL_META
+ ) as ShaderMaterial
+ var material := ShaderMaterial.new()
+ material.shader = FurPatternShader
+ mesh_instance.set_meta(RUNTIME_FUR_PATTERN_MATERIAL_META, material)
+ return material
+
+
+static func _configure_matte_material(material: StandardMaterial3D) -> void:
+ if material == null:
+ return
+ material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
+ # Character artwork is deliberately graphic and flat. Unshaded rendering
+ # keeps the authored fur and facial colors invariant across world weather,
+ # time of day, and the isolated Profile preview.
+ material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ material.metallic = 0.0
+ material.metallic_specular = 0.0
+ material.roughness = 1.0
+ material.diffuse_mode = BaseMaterial3D.DIFFUSE_LAMBERT
+ material.anisotropy_enabled = false
+ material.clearcoat_enabled = false
+ material.rim_enabled = false
diff --git a/player/player_visual_presenter.gd.uid b/player/player_visual_presenter.gd.uid
new file mode 100644
index 0000000..f67f8da
--- /dev/null
+++ b/player/player_visual_presenter.gd.uid
@@ -0,0 +1 @@
+uid://b4w23asm85rtu
diff --git a/player/sprint_dust_trail.gd b/player/sprint_dust_trail.gd
new file mode 100644
index 0000000..124b6ae
--- /dev/null
+++ b/player/sprint_dust_trail.gd
@@ -0,0 +1,249 @@
+class_name SprintDustTrail
+extends MultiMeshInstance3D
+
+const PUFF_COUNT: int = 12
+const PUFF_SPACING: float = 0.46
+const PUFF_LIFETIME: float = 0.62
+const PUFF_GROW_TIME: float = 0.16
+const PUFF_FADE_TIME: float = 0.22
+const PUFF_BACK_OFFSET: float = 0.14
+const PUFF_SIDE_OFFSET: float = 0.11
+const PUFF_HEIGHT: float = 0.08
+const PUFF_MIN_SCALE: float = 0.22
+
+@export var puff_mesh: Mesh
+
+var _puff_positions: Array[Vector3] = []
+var _puff_ages := PackedFloat32Array()
+var _puff_widths := PackedFloat32Array()
+var _puff_heights := PackedFloat32Array()
+var _puff_phases := PackedFloat32Array()
+var _next_puff: int = 0
+var _spawn_serial: int = 0
+var _foot_side: float = -1.0
+var _tracking_run: bool = false
+var _previous_source_position: Vector3
+var _distance_since_puff: float = 0.0
+
+
+func _ready() -> void:
+ top_level = true
+ global_transform = Transform3D.IDENTITY
+ _initialize_pool()
+
+
+func update_trail(
+ delta: float,
+ source_position: Vector3,
+ movement_velocity: Vector3,
+ is_running: bool,
+) -> void:
+ _update_existing_puffs(delta)
+ custom_aabb = AABB(
+ source_position - Vector3(5.0, 2.0, 5.0),
+ Vector3(10.0, 5.0, 10.0),
+ )
+ if not is_running:
+ _tracking_run = false
+ _distance_since_puff = 0.0
+ return
+
+ var flat_source := Vector3(
+ source_position.x,
+ source_position.y,
+ source_position.z,
+ )
+ if not _tracking_run:
+ _tracking_run = true
+ _previous_source_position = flat_source
+ _distance_since_puff = 0.0
+ return
+
+ var segment := flat_source - _previous_source_position
+ segment.y = 0.0
+ var remaining_distance: float = segment.length()
+ if remaining_distance <= 0.0001:
+ _previous_source_position = flat_source
+ return
+
+ var segment_direction: Vector3 = segment / remaining_distance
+ var movement_direction := Vector3(
+ movement_velocity.x,
+ 0.0,
+ movement_velocity.z,
+ ).normalized()
+ if movement_direction.is_zero_approx():
+ movement_direction = segment_direction
+ var cursor: Vector3 = _previous_source_position
+ var distance_to_next: float = PUFF_SPACING - _distance_since_puff
+ while remaining_distance >= distance_to_next:
+ cursor += segment_direction * distance_to_next
+ _spawn_puff(cursor, movement_direction)
+ remaining_distance -= distance_to_next
+ _distance_since_puff = 0.0
+ distance_to_next = PUFF_SPACING
+ _distance_since_puff += remaining_distance
+ _previous_source_position = flat_source
+
+
+func clear_trail() -> void:
+ _tracking_run = false
+ _distance_since_puff = 0.0
+ for index: int in PUFF_COUNT:
+ _puff_ages[index] = PUFF_LIFETIME
+ _write_inactive_puff(index)
+
+
+func emit_landing_burst(
+ source_position: Vector3,
+ facing_direction: Vector3,
+) -> void:
+ var direction := Vector3(
+ facing_direction.x,
+ 0.0,
+ facing_direction.z,
+ ).normalized()
+ if direction.is_zero_approx():
+ direction = Vector3.FORWARD
+ var first_direction: Vector3 = -direction
+ for puff_index: int in 5:
+ var spread_direction := first_direction.rotated(
+ Vector3.UP,
+ TAU * float(puff_index) / 5.0,
+ )
+ var radius: float = lerpf(
+ 0.38,
+ 0.46,
+ _stable_variation(_spawn_serial + puff_index + 81),
+ )
+ _spawn_puff_at(
+ source_position
+ + spread_direction * radius
+ + Vector3.UP * PUFF_HEIGHT,
+ 0.95,
+ )
+
+
+func get_active_puff_count() -> int:
+ var active_count: int = 0
+ for age: float in _puff_ages:
+ if age < PUFF_LIFETIME:
+ active_count += 1
+ return active_count
+
+
+func get_first_active_puff_position() -> Vector3:
+ for index: int in PUFF_COUNT:
+ if _puff_ages[index] < PUFF_LIFETIME:
+ return _puff_positions[index]
+ return Vector3.INF
+
+
+func _initialize_pool() -> void:
+ var pool := MultiMesh.new()
+ pool.transform_format = MultiMesh.TRANSFORM_3D
+ pool.use_custom_data = true
+ pool.instance_count = PUFF_COUNT
+ pool.mesh = puff_mesh
+ multimesh = pool
+ _puff_positions.resize(PUFF_COUNT)
+ _puff_ages.resize(PUFF_COUNT)
+ _puff_widths.resize(PUFF_COUNT)
+ _puff_heights.resize(PUFF_COUNT)
+ _puff_phases.resize(PUFF_COUNT)
+ clear_trail()
+
+
+func _spawn_puff(source_position: Vector3, direction: Vector3) -> void:
+ var lateral := Vector3(-direction.z, 0.0, direction.x)
+ var position := (
+ source_position
+ - direction * PUFF_BACK_OFFSET
+ + lateral * PUFF_SIDE_OFFSET * _foot_side
+ + Vector3.UP * PUFF_HEIGHT
+ )
+ _spawn_puff_at(position)
+ _foot_side *= -1.0
+
+
+func _spawn_puff_at(
+ position: Vector3,
+ size_multiplier: float = 1.0,
+) -> void:
+ var variation: float = _stable_variation(_spawn_serial)
+ var second_variation: float = _stable_variation(_spawn_serial + 37)
+ var index: int = _next_puff
+ _puff_positions[index] = position
+ _puff_ages[index] = 0.0
+ _puff_widths[index] = (
+ lerpf(0.34, 0.43, variation) * size_multiplier
+ )
+ _puff_heights[index] = (
+ lerpf(0.23, 0.30, second_variation) * size_multiplier
+ )
+ _puff_phases[index] = variation
+ _write_puff(index, PUFF_MIN_SCALE, 1.0)
+ _next_puff = (_next_puff + 1) % PUFF_COUNT
+ _spawn_serial += 1
+
+
+func _update_existing_puffs(delta: float) -> void:
+ if multimesh == null:
+ return
+ for index: int in PUFF_COUNT:
+ var age: float = _puff_ages[index]
+ if age >= PUFF_LIFETIME:
+ continue
+ age += delta
+ _puff_ages[index] = age
+ if age >= PUFF_LIFETIME:
+ _write_inactive_puff(index)
+ continue
+ var visual_scale: float = 1.0
+ if age < PUFF_GROW_TIME:
+ var grow_weight: float = smoothstep(
+ 0.0,
+ 1.0,
+ age / PUFF_GROW_TIME,
+ )
+ visual_scale = lerpf(PUFF_MIN_SCALE, 1.0, grow_weight)
+ var visual_alpha: float = 1.0
+ if age > PUFF_LIFETIME - PUFF_FADE_TIME:
+ var fade_weight: float = smoothstep(
+ 0.0,
+ 1.0,
+ (age - (PUFF_LIFETIME - PUFF_FADE_TIME))
+ / PUFF_FADE_TIME,
+ )
+ visual_alpha = 1.0 - fade_weight
+ _write_puff(index, visual_scale, visual_alpha)
+
+
+func _write_puff(
+ index: int,
+ visual_scale: float,
+ visual_alpha: float,
+) -> void:
+ var puff_transform := Transform3D.IDENTITY
+ puff_transform.origin = _puff_positions[index]
+ multimesh.set_instance_transform(index, puff_transform)
+ multimesh.set_instance_custom_data(
+ index,
+ Color(
+ _puff_phases[index],
+ _puff_widths[index] * visual_scale,
+ _puff_heights[index] * visual_scale,
+ visual_alpha,
+ ),
+ )
+
+
+func _write_inactive_puff(index: int) -> void:
+ var inactive_transform := Transform3D.IDENTITY
+ inactive_transform.origin = _puff_positions[index]
+ multimesh.set_instance_transform(index, inactive_transform)
+ multimesh.set_instance_custom_data(index, Color(0.0, 0.0, 0.0, 0.0))
+
+
+func _stable_variation(serial: int) -> float:
+ return fposmod(sin(float(serial + 1) * 12.9898) * 43758.5453, 1.0)
diff --git a/player/sprint_dust_trail.gd.uid b/player/sprint_dust_trail.gd.uid
new file mode 100644
index 0000000..c40239c
--- /dev/null
+++ b/player/sprint_dust_trail.gd.uid
@@ -0,0 +1 @@
+uid://o2gi5oqunsp8
diff --git a/progression/character_customization_catalog.gd b/progression/character_customization_catalog.gd
new file mode 100644
index 0000000..609f593
--- /dev/null
+++ b/progression/character_customization_catalog.gd
@@ -0,0 +1,543 @@
+class_name CharacterCustomizationCatalog
+extends RefCounted
+
+const CATEGORY_IDS: PackedStringArray = [
+ "species",
+ "scale",
+ "fur_pattern",
+ "ears",
+ "eyes",
+ "nose",
+ "mouth",
+ "tail",
+]
+
+# CATEGORY_IDS controls the visible Profile navigation. The additional fur
+# fields are edited together on the existing fur page, but remain explicit in
+# saves and signed multiplayer appearance snapshots.
+const SNAPSHOT_IDS: PackedStringArray = [
+ "species",
+ "scale",
+ "fur_pattern",
+ "fur_style",
+ "fur_color_2",
+ "fur_color_3",
+ "fur_color_4",
+ "ears",
+ "eyes",
+ "nose",
+ "mouth",
+ "tail",
+]
+
+const CATEGORY_LABELS: Dictionary = {
+ "species": "head",
+ "scale": "size",
+ "fur_pattern": "fur",
+ "ears": "ears",
+ "eyes": "eyes",
+ "nose": "nose",
+ "mouth": "mouth",
+ "tail": "tail",
+}
+
+const SCALE_CATEGORY_ID: String = "scale"
+const MIN_CHARACTER_SCALE: float = 0.5
+const MAX_CHARACTER_SCALE: float = 1.5
+const DEFAULT_CHARACTER_SCALE: float = 1.0
+const CHARACTER_SCALE_STEP: float = 0.05
+const FUR_STYLE_ID: String = "fur_style"
+const FUR_COLOR_IDS: PackedStringArray = [
+ "fur_pattern",
+ "fur_color_2",
+ "fur_color_3",
+ "fur_color_4",
+]
+const FUR_COLOR_LABELS: Dictionary = {
+ "fur_pattern": "primary",
+ "fur_color_2": "secondary",
+ "fur_color_3": "accent",
+ "fur_color_4": "detail",
+}
+const DEFAULT_FUR_STYLE: String = "solid"
+
+const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"]
+const FEATURE_ASSET_ROOTS: Dictionary = {
+ "eyes": "res://art/exported/characters/faces/eyes",
+ "nose": "res://art/exported/characters/faces/noses",
+ "mouth": "res://art/exported/characters/faces/mouth",
+}
+const FEATURE_LABEL_OVERRIDES: Dictionary = {
+ "simple_shine": "simple shine",
+ "simple_shine_eyebrows": "simple shine + eyebrows",
+ "dog_round": "round",
+ "three": "three",
+}
+const FUR_COLOR_OPTIONS: Array = [
+ {"id": "white", "label": "white", "color": Color("f2f0e8")},
+ {"id": "ivory", "label": "ivory", "color": Color("f0e2c2")},
+ {"id": "cream", "label": "cream", "color": Color("e6d39b")},
+ {"id": "sand", "label": "sand", "color": Color("c6a66f")},
+ {"id": "tan", "label": "tan", "color": Color("ad7c55")},
+ {"id": "taupe", "label": "taupe", "color": Color("89756a")},
+ {"id": "silver", "label": "silver", "color": Color("b8c3c1")},
+ {"id": "gray", "label": "gray", "color": Color("819398")},
+ {"id": "slate", "label": "slate", "color": Color("58686c")},
+ {"id": "charcoal", "label": "charcoal", "color": Color("34444a")},
+ {"id": "black", "label": "black", "color": Color("192126")},
+ {"id": "dark_brown", "label": "dark brown", "color": Color("432a25")},
+ {"id": "chocolate", "label": "chocolate", "color": Color("603b2c")},
+ {"id": "brown", "label": "brown", "color": Color("7b4a32")},
+ {"id": "chestnut", "label": "chestnut", "color": Color("98513d")},
+ {"id": "ginger", "label": "ginger", "color": Color("d28742")},
+ {"id": "orange", "label": "orange", "color": Color("c86c36")},
+ {"id": "red", "label": "red", "color": Color("a9433f")},
+ {"id": "pink", "label": "pink", "color": Color("d8899e")},
+ {"id": "yellow", "label": "yellow", "color": Color("d8c545")},
+ {"id": "gold", "label": "gold", "color": Color("c29a3d")},
+ {"id": "sage", "label": "sage", "color": Color("8fa16b")},
+ {"id": "green", "label": "green", "color": Color("6f913c")},
+ {"id": "forest", "label": "forest", "color": Color("3f6945")},
+ {"id": "aqua", "label": "aqua", "color": Color("69aeb0")},
+ {"id": "teal", "label": "teal", "color": Color("3a8790")},
+ {"id": "blue", "label": "blue", "color": Color("4d76a8")},
+ {"id": "navy", "label": "navy", "color": Color("344b78")},
+ {"id": "lavender", "label": "lavender", "color": Color("9a7eaa")},
+ {"id": "purple", "label": "purple", "color": Color("76558f")},
+]
+const OPTIONS: Dictionary = {
+ "species": [
+ {"id": "round", "label": "round"},
+ {"id": "pointy", "label": "pointy"},
+ ],
+ # `fur_pattern` remains the primary color key for compatibility with
+ # existing appearance saves. The actual authored pattern has its own field.
+ "fur_pattern": FUR_COLOR_OPTIONS,
+ "fur_color_2": FUR_COLOR_OPTIONS,
+ "fur_color_3": FUR_COLOR_OPTIONS,
+ "fur_color_4": FUR_COLOR_OPTIONS,
+ "fur_style": [
+ {"id": "solid", "label": "solid"},
+ {
+ "id": "spots_bengal",
+ "label": "bengal spots",
+ "textures": {
+ "body_main": (
+ "res://art/exported/characters/patterns/"
+ + "bengal/body_main_bengal.png"
+ ),
+ "body_arms": (
+ "res://art/exported/characters/patterns/"
+ + "bengal/body_arms_bengal.png"
+ ),
+ "head_round": (
+ "res://art/exported/characters/patterns/"
+ + "bengal/head_round_bengal.png"
+ ),
+ },
+ },
+ {
+ "id": "fox",
+ "label": "fox",
+ "textures": {
+ "body_main": (
+ "res://art/exported/characters/patterns/"
+ + "fox/body_main_fox.png"
+ ),
+ "body_arms": (
+ "res://art/exported/characters/patterns/"
+ + "fox/body_arms_fox.png"
+ ),
+ "head_pointy": (
+ "res://art/exported/characters/patterns/"
+ + "fox/head_pointy_fox.png"
+ ),
+ "head_round": (
+ "res://art/exported/characters/patterns/"
+ + "fox/head_round_fox.png"
+ ),
+ "tails_fox": (
+ "res://art/exported/characters/patterns/"
+ + "fox/tails_fox_fox.png"
+ ),
+ },
+ },
+ ],
+ "ears": [
+ {"id": "none", "label": "none"},
+ {"id": "antlers_round", "label": "antlers"},
+ {"id": "bear", "label": "bear"},
+ {"id": "bunny", "label": "bunny"},
+ {"id": "pointy_long", "label": "long"},
+ {"id": "pointy_short", "label": "short"},
+ {"id": "pointy_wide", "label": "wide"},
+ ],
+ "eyes": [
+ {"id": "simple_shine", "label": "simple shine"},
+ {
+ "id": "simple_shine_eyebrows",
+ "label": "simple shine + eyebrows",
+ },
+ ],
+ "nose": [{"id": "dog_round", "label": "round"}],
+ "mouth": [{"id": "three", "label": "three"}],
+ "tail": [
+ {"id": "none", "label": "none"},
+ {"id": "bear", "label": "bear"},
+ {"id": "bunny", "label": "bunny"},
+ {"id": "cat", "label": "cat"},
+ {"id": "fox", "label": "fox"},
+ {"id": "gator", "label": "gator"},
+ {"id": "pointy", "label": "pointy"},
+ ],
+}
+
+const LEGACY_OPTION_ALIASES: Dictionary = {
+ "species": {"default": "round"},
+ "fur_pattern": {"solid": "white"},
+ "fur_style": {"bengal": "spots_bengal"},
+ "ears": {"default": "none"},
+ "tail": {"default": "none"},
+ "eyes": {"default": "simple_shine"},
+ "nose": {"default": "dog_round"},
+ "mouth": {"default": "three"},
+}
+
+static var _feature_assets_ready: bool = false
+static var _feature_options: Dictionary = {}
+static var _feature_textures: Dictionary = {}
+static var _fur_pattern_textures: Dictionary = {}
+
+
+static func default_snapshot() -> Dictionary:
+ _ensure_feature_assets()
+ return {
+ "species": "round",
+ "scale": DEFAULT_CHARACTER_SCALE,
+ "fur_pattern": "white",
+ "fur_style": DEFAULT_FUR_STYLE,
+ "fur_color_2": "cream",
+ "fur_color_3": "brown",
+ "fur_color_4": "red",
+ "ears": "none",
+ "eyes": _default_feature_option("eyes", "simple_shine"),
+ "nose": _default_feature_option("nose", "dog_round"),
+ "mouth": _default_feature_option("mouth", "three"),
+ "tail": "none",
+ }
+
+
+static func _default_feature_option(
+ category_id: String,
+ legacy_id: String,
+) -> String:
+ var options: Array = _feature_options.get(category_id, []) as Array
+ for option: Dictionary in options:
+ if str(option.get("id", "")) == legacy_id:
+ return legacy_id
+ for option: Dictionary in options:
+ var option_id := str(option.get("id", ""))
+ if option_id != "none":
+ return option_id
+ return "none"
+
+
+static func options_for(category_id: String) -> Array:
+ if category_id in FEATURE_CATEGORIES:
+ _ensure_feature_assets()
+ return _feature_options.get(category_id, []) as Array
+ return OPTIONS.get(category_id, [])
+
+
+static func feature_option_groups(category_id: String) -> Array:
+ if category_id not in FEATURE_CATEGORIES:
+ return []
+ var options: Array = options_for(category_id)
+ var option_ids: Array[String] = []
+ for option: Dictionary in options:
+ option_ids.append(str(option.get("id", "")))
+ var grouped_options: Dictionary = {}
+ var group_order: Array[String] = []
+ for option: Dictionary in options:
+ var option_id := str(option.get("id", ""))
+ var group_id := _feature_variant_root(option_id, option_ids)
+ if not grouped_options.has(group_id):
+ grouped_options[group_id] = []
+ group_order.append(group_id)
+ var group: Array = grouped_options[group_id] as Array
+ group.append(option)
+ grouped_options[group_id] = group
+ var result: Array = []
+ for group_id: String in group_order:
+ result.append({
+ "id": group_id,
+ "options": grouped_options[group_id],
+ })
+ return result
+
+
+static func _feature_variant_root(
+ option_id: String,
+ option_ids: Array[String],
+) -> String:
+ if option_id == "none":
+ return option_id
+ # Prefer the shortest authored base option. This keeps nested names such as
+ # `beady_extra_shine` in the `beady` drawer rather than creating drawers
+ # inside drawers.
+ var authored_root := ""
+ for candidate_id: String in option_ids:
+ if candidate_id == "none":
+ continue
+ if (
+ option_id != candidate_id
+ and not option_id.begins_with(candidate_id + "_")
+ ):
+ continue
+ var has_descendant := false
+ for descendant_id: String in option_ids:
+ if descendant_id.begins_with(candidate_id + "_"):
+ has_descendant = true
+ break
+ if (
+ has_descendant
+ and (
+ authored_root.is_empty()
+ or candidate_id.length() < authored_root.length()
+ )
+ ):
+ authored_root = candidate_id
+ if not authored_root.is_empty():
+ return authored_root
+
+ # Some families have no unqualified base asset. Group those siblings by
+ # their leading name segment, making the sorted first asset representative.
+ var separator_index := option_id.find("_")
+ if separator_index <= 0:
+ return option_id
+ var leading_segment := option_id.substr(0, separator_index)
+ var sibling_count := 0
+ for candidate_id: String in option_ids:
+ if candidate_id.begins_with(leading_segment + "_"):
+ sibling_count += 1
+ if sibling_count >= 2:
+ return leading_segment
+ return option_id
+
+
+static func texture_for(category_id: String, option_id: String) -> Texture2D:
+ if category_id not in FEATURE_CATEGORIES:
+ return null
+ _ensure_feature_assets()
+ var canonical_id := canonical_option_id(category_id, option_id)
+ var textures: Dictionary = _feature_textures.get(category_id, {}) as Dictionary
+ return textures.get(canonical_id) as Texture2D
+
+
+static func refresh_feature_assets() -> void:
+ _feature_assets_ready = false
+ _feature_options.clear()
+ _feature_textures.clear()
+ _ensure_feature_assets()
+
+
+static func _ensure_feature_assets() -> void:
+ if _feature_assets_ready:
+ return
+ _feature_assets_ready = true
+ for category_id: String in FEATURE_CATEGORIES:
+ var options: Array = [{"id": "none", "label": "none"}]
+ var textures: Dictionary = {}
+ var root_path := str(FEATURE_ASSET_ROOTS[category_id])
+ var directory := DirAccess.open(root_path)
+ if directory != null:
+ var files := directory.get_files()
+ files.sort()
+ var normalized_paths: Dictionary = {}
+ for file_name: String in files:
+ var normalized_file_name := _canonical_feature_filename(file_name)
+ if (
+ normalized_file_name.is_empty()
+ or normalized_paths.has(normalized_file_name)
+ ):
+ continue
+ normalized_paths[normalized_file_name] = true
+ var option_id := _feature_id_from_filename(
+ category_id, normalized_file_name
+ )
+ if option_id.is_empty() or textures.has(option_id):
+ continue
+ var resource_path := root_path.path_join(normalized_file_name)
+ var texture := ResourceLoader.load(resource_path) as Texture2D
+ if texture == null:
+ push_warning(
+ "Ignoring facial feature without a loadable texture: "
+ + resource_path
+ )
+ continue
+ textures[option_id] = texture
+ options.append({
+ "id": option_id,
+ "label": _feature_label(option_id),
+ })
+
+ _feature_options[category_id] = options
+ _feature_textures[category_id] = textures
+
+
+static func _canonical_feature_filename(file_name: String) -> String:
+ var lower_name := file_name.to_lower()
+ for sidecar_suffix: String in [".import", ".remap"]:
+ if lower_name.ends_with(".png" + sidecar_suffix):
+ return file_name.substr(
+ 0, file_name.length() - sidecar_suffix.length()
+ )
+ if lower_name.ends_with(".png"):
+ return file_name
+ return ""
+
+
+static func _feature_id_from_filename(
+ category_id: String,
+ file_name: String,
+) -> String:
+ var option_id := file_name.get_basename().to_lower()
+ var prefix := category_id + "_"
+ if option_id.begins_with(prefix):
+ option_id = option_id.trim_prefix(prefix)
+ option_id = option_id.replace("-", "_")
+ option_id = option_id.replace(" ", "_")
+ option_id = option_id.replace(".", "_")
+ while option_id.contains("__"):
+ option_id = option_id.replace("__", "_")
+ while option_id.begins_with("_"):
+ option_id = option_id.substr(1)
+ while option_id.ends_with("_"):
+ option_id = option_id.substr(0, option_id.length() - 1)
+ return option_id
+
+
+static func _feature_label(option_id: String) -> String:
+ if FEATURE_LABEL_OVERRIDES.has(option_id):
+ return str(FEATURE_LABEL_OVERRIDES[option_id])
+ return option_id.replace("_", " ")
+
+
+static func category_label(category_id: String) -> String:
+ return str(CATEGORY_LABELS.get(category_id, category_id))
+
+
+static func is_valid_option(category_id: String, option_id: String) -> bool:
+ option_id = canonical_option_id(category_id, option_id)
+ for option: Dictionary in options_for(category_id):
+ if str(option.get("id", "")) == option_id:
+ return true
+ return false
+
+
+static func canonical_option_id(category_id: String, option_id: String) -> String:
+ var aliases: Dictionary = LEGACY_OPTION_ALIASES.get(category_id, {})
+ return str(aliases.get(option_id, option_id))
+
+
+static func option_color(category_id: String, option_id: String) -> Color:
+ var canonical_id: String = canonical_option_id(category_id, option_id)
+ for option: Dictionary in options_for(category_id):
+ if str(option.get("id", "")) == canonical_id:
+ return option.get("color", Color.WHITE) as Color
+ return Color.WHITE
+
+
+static func fur_color_label(category_id: String) -> String:
+ return str(FUR_COLOR_LABELS.get(category_id, category_id))
+
+
+static func fur_pattern_texture(
+ option_id: String,
+ component_id: String = "body_main",
+) -> Texture2D:
+ var canonical_id := canonical_option_id(FUR_STYLE_ID, option_id)
+ if canonical_id == DEFAULT_FUR_STYLE:
+ return null
+ var cache_id := "%s:%s" % [canonical_id, component_id]
+ if _fur_pattern_textures.has(cache_id):
+ return _fur_pattern_textures[cache_id] as Texture2D
+ for option: Dictionary in options_for(FUR_STYLE_ID):
+ if str(option.get("id", "")) != canonical_id:
+ continue
+ var textures := option.get("textures", {}) as Dictionary
+ var resource_path := str(textures.get(component_id, ""))
+ if resource_path.is_empty():
+ return null
+ var texture := ResourceLoader.load(resource_path) as Texture2D
+ if texture != null:
+ _fur_pattern_textures[cache_id] = texture
+ return texture
+ return null
+
+
+static func validate_snapshot(value: Variant) -> bool:
+ if typeof(value) != TYPE_DICTIONARY:
+ return false
+ var snapshot: Dictionary = value
+ if snapshot.size() != SNAPSHOT_IDS.size():
+ return false
+ for category_id: String in SNAPSHOT_IDS:
+ if category_id == SCALE_CATEGORY_ID:
+ if not is_valid_character_scale(snapshot.get(category_id)):
+ return false
+ continue
+ if (
+ typeof(snapshot.get(category_id)) != TYPE_STRING
+ or not is_valid_option(category_id, str(snapshot[category_id]))
+ ):
+ return false
+ return true
+
+
+static func sanitized_snapshot(value: Variant) -> Dictionary:
+ var result := default_snapshot()
+ if typeof(value) != TYPE_DICTIONARY:
+ return result
+ var snapshot: Dictionary = value
+ for category_id: String in SNAPSHOT_IDS:
+ if category_id == SCALE_CATEGORY_ID:
+ result[category_id] = character_scale(
+ snapshot.get(category_id, DEFAULT_CHARACTER_SCALE)
+ )
+ continue
+ var option_id: String = canonical_option_id(
+ category_id, str(snapshot.get(category_id, ""))
+ )
+ if is_valid_option(category_id, option_id):
+ result[category_id] = option_id
+ return result
+
+
+static func character_scale(value: Variant) -> float:
+ if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
+ return DEFAULT_CHARACTER_SCALE
+ var number: float = float(value)
+ if not is_finite(number):
+ return DEFAULT_CHARACTER_SCALE
+ return clampf(
+ snappedf(number, CHARACTER_SCALE_STEP),
+ MIN_CHARACTER_SCALE,
+ MAX_CHARACTER_SCALE,
+ )
+
+
+static func is_valid_character_scale(value: Variant) -> bool:
+ if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
+ return false
+ var number: float = float(value)
+ return (
+ is_finite(number)
+ and number >= MIN_CHARACTER_SCALE
+ and number <= MAX_CHARACTER_SCALE
+ and is_equal_approx(number, character_scale(number))
+ )
+
+
+static func character_scale_percent(value: Variant) -> int:
+ return roundi(character_scale(value) * 100.0)
diff --git a/progression/character_customization_catalog.gd.uid b/progression/character_customization_catalog.gd.uid
new file mode 100644
index 0000000..e2f3303
--- /dev/null
+++ b/progression/character_customization_catalog.gd.uid
@@ -0,0 +1 @@
+uid://c08tu8ntuqig3
diff --git a/progression/player_appearance_store.gd b/progression/player_appearance_store.gd
new file mode 100644
index 0000000..7ac8767
--- /dev/null
+++ b/progression/player_appearance_store.gd
@@ -0,0 +1,124 @@
+class_name PlayerAppearanceStore
+extends Node
+
+const FORMAT_VERSION: int = 1
+var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot()
+var _loaded: bool = false
+var _future_version: bool = false
+var _profile_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _profile_path = path
+ _data_root = data_root
+
+
+func _temp_path() -> String:
+ return _profile_path + ".tmp"
+
+
+func _backup_path() -> String:
+ return _profile_path + ".backup"
+
+
+func load_preferences() -> bool:
+ if _profile_path.is_empty():
+ return false
+ _recover_interrupted_write()
+ if not FileAccess.file_exists(_profile_path):
+ _snapshot = CharacterCustomizationCatalog.default_snapshot()
+ _loaded = true
+ return true
+ var file := FileAccess.open(_profile_path, FileAccess.READ)
+ if file == null:
+ return false
+ var json := JSON.new()
+ var error := json.parse(file.get_as_text())
+ file.close()
+ if error != OK or typeof(json.data) != TYPE_DICTIONARY:
+ return _recover_backup()
+ var data: Dictionary = json.data
+ if typeof(data.get("format_version")) not in [TYPE_INT, TYPE_FLOAT]:
+ return _recover_backup()
+ var version := int(data["format_version"])
+ if version > FORMAT_VERSION:
+ _future_version = true
+ _loaded = false
+ return false
+ if version != FORMAT_VERSION or typeof(data.get("appearance")) != TYPE_DICTIONARY:
+ return _recover_backup()
+ _snapshot = CharacterCustomizationCatalog.sanitized_snapshot(data["appearance"])
+ _loaded = true
+ _expected_hash = PortableFileGuard.hash_file(_profile_path)
+ return true
+
+
+func get_snapshot() -> Dictionary:
+ return _snapshot.duplicate(true)
+
+
+func save_snapshot(value: Dictionary) -> bool:
+ if _future_version or not CharacterCustomizationCatalog.validate_snapshot(value):
+ return false
+ var previous := _snapshot.duplicate(true)
+ _snapshot = value.duplicate(true)
+ if _save_atomic():
+ _loaded = true
+ return true
+ _snapshot = previous
+ return false
+
+
+func is_loaded() -> bool:
+ return _loaded
+
+
+func _save_atomic() -> bool:
+ var bytes := JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "appearance": _snapshot,
+ }, "\t").to_utf8_buffer()
+ var result := PortableFileGuard.write_guarded(
+ _profile_path, bytes, _expected_hash, _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
+ if bool(result.get("ok", false)):
+ _expected_hash = str(result["hash"])
+ return bool(result.get("ok", false))
+
+
+func _recover_interrupted_write() -> void:
+ if FileAccess.file_exists(_profile_path):
+ _remove(_temp_path())
+ return
+ if FileAccess.file_exists(_backup_path()):
+ _rename(_backup_path(), _profile_path)
+ _remove(_temp_path())
+
+
+func _recover_backup() -> bool:
+ if not FileAccess.file_exists(_backup_path()):
+ return false
+ var primary_path := _profile_path
+ var backup_path := _backup_path()
+ var corrupt_path := "%s.corrupt.%d.json" % [_profile_path.get_basename(), int(Time.get_unix_time_from_system())]
+ DirAccess.rename_absolute(primary_path, corrupt_path)
+ if DirAccess.rename_absolute(backup_path, primary_path) != OK:
+ return false
+ return load_preferences()
+
+
+func _rename(from_path: String, to_path: String) -> bool:
+ return DirAccess.rename_absolute(
+ ProjectSettings.globalize_path(from_path),
+ ProjectSettings.globalize_path(to_path),
+ ) == OK
+
+
+func _remove(path: String) -> void:
+ if FileAccess.file_exists(path):
+ DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
diff --git a/progression/player_appearance_store.gd.uid b/progression/player_appearance_store.gd.uid
new file mode 100644
index 0000000..35c2e85
--- /dev/null
+++ b/progression/player_appearance_store.gd.uid
@@ -0,0 +1 @@
+uid://ci07gm3e7r8p6
diff --git a/progression/player_art_unlocks.gd b/progression/player_art_unlocks.gd
new file mode 100644
index 0000000..c6a6e57
--- /dev/null
+++ b/progression/player_art_unlocks.gd
@@ -0,0 +1,173 @@
+class_name PlayerArtUnlocks
+extends Node
+
+const PlayerWalletType = preload("res://economy/player_wallet.gd")
+
+signal unlocks_changed(unlock_mask: int)
+
+const BASE_BRUSH_SIZE: int = 1
+const BASE_GRID_SIZE: int = 16
+const BRUSH_SIZES: Array[int] = [1, 2, 3, 4]
+const GRID_SIZES: Array[int] = [16, 32, 64, 128]
+
+# Stable product IDs are both the save and shop transaction boundary.
+const PRODUCT_BITS: Dictionary[StringName, int] = {
+ &"marker_ocean_teal": 0,
+ &"marker_coral": 1,
+ &"marker_sunny": 2,
+ &"marker_leaf": 3,
+ &"marker_blue": 4,
+ &"marker_violet": 5,
+ &"marker_charcoal": 6,
+ &"brush_2x": 7,
+ &"brush_3x": 8,
+ &"brush_4x": 9,
+ &"grid_32x": 10,
+ &"grid_64x": 11,
+ &"grid_128x": 12,
+}
+const COLOR_PRODUCTS: Dictionary[StringName, StringName] = {
+ &"marker_ocean_teal": &"ocean_teal",
+ &"marker_coral": &"coral",
+ &"marker_sunny": &"sunny",
+ &"marker_leaf": &"leaf",
+ &"marker_blue": &"blue",
+ &"marker_violet": &"violet",
+ &"marker_charcoal": &"charcoal",
+}
+const BRUSH_PRODUCTS: Dictionary[StringName, int] = {
+ &"brush_2x": 2,
+ &"brush_3x": 3,
+ &"brush_4x": 4,
+}
+const GRID_PRODUCTS: Dictionary[StringName, int] = {
+ &"grid_32x": 32,
+ &"grid_64x": 64,
+ &"grid_128x": 128,
+}
+const ALL_UNLOCK_MASK: int = (1 << 13) - 1
+
+var _unlock_mask: int = 0
+
+
+func get_unlock_mask() -> int:
+ return _unlock_mask
+
+
+func owns_product(product_id: StringName) -> bool:
+ var bit: int = get_product_bit(product_id)
+ return bit >= 0 and (_unlock_mask & (1 << bit)) != 0
+
+
+func unlock_product(product_id: StringName) -> bool:
+ var bit: int = get_product_bit(product_id)
+ if bit < 0 or owns_product(product_id):
+ return false
+ _unlock_mask |= 1 << bit
+ unlocks_changed.emit(_unlock_mask)
+ return true
+
+
+func purchase_product(
+ product_id: StringName,
+ wallet: PlayerWalletType,
+ cost: int,
+) -> bool:
+ if (
+ wallet == null
+ or cost < 0
+ or owns_product(product_id)
+ or get_product_bit(product_id) < 0
+ or not wallet.can_afford(cost)
+ ):
+ return false
+ if not wallet.debit(cost):
+ return false
+ if unlock_product(product_id):
+ return true
+ if not wallet.credit(cost):
+ push_error("Art upgrade purchase rollback failed.")
+ return false
+
+
+func restore_mask(value: int) -> bool:
+ if value < 0 or (value & ~ALL_UNLOCK_MASK) != 0:
+ return false
+ var changed: bool = _unlock_mask != value
+ _unlock_mask = value
+ if changed:
+ unlocks_changed.emit(_unlock_mask)
+ return true
+
+
+func reset_to_defaults() -> void:
+ restore_mask(0)
+
+
+func to_save_data() -> Dictionary:
+ return {"unlock_mask": _unlock_mask}
+
+
+func get_unlocked_color_ids() -> Array[StringName]:
+ var result: Array[StringName] = [SurfaceDrawingPalette.DEFAULT_COLOR_ID]
+ for product_id: StringName in COLOR_PRODUCTS:
+ if owns_product(product_id):
+ result.append(StringName(str(COLOR_PRODUCTS[product_id])))
+ return result
+
+
+func get_unlocked_brush_sizes() -> Array[int]:
+ var result: Array[int] = [BASE_BRUSH_SIZE]
+ for product_id: StringName in BRUSH_PRODUCTS:
+ if owns_product(product_id):
+ result.append(int(BRUSH_PRODUCTS[product_id]))
+ result.sort()
+ return result
+
+
+func get_unlocked_grid_sizes() -> Array[int]:
+ var result: Array[int] = [BASE_GRID_SIZE]
+ for product_id: StringName in GRID_PRODUCTS:
+ if owns_product(product_id):
+ result.append(int(GRID_PRODUCTS[product_id]))
+ result.sort()
+ return result
+
+
+func is_color_unlocked(color_id: StringName) -> bool:
+ return color_id in get_unlocked_color_ids()
+
+
+func is_brush_size_unlocked(brush_size: int) -> bool:
+ return brush_size in get_unlocked_brush_sizes()
+
+
+func is_grid_size_unlocked(grid_size: int) -> bool:
+ return grid_size in get_unlocked_grid_sizes()
+
+
+static func get_product_bit(product_id: StringName) -> int:
+ return int(PRODUCT_BITS.get(product_id, -1))
+
+
+static func is_product_id(product_id: StringName) -> bool:
+ return PRODUCT_BITS.has(product_id)
+
+
+static func resulting_mask(current_mask: int, product_id: StringName) -> int:
+ var bit: int = get_product_bit(product_id)
+ if bit < 0:
+ return current_mask
+ return current_mask | (1 << bit)
+
+
+static func color_id_for_product(product_id: StringName) -> StringName:
+ return StringName(str(COLOR_PRODUCTS.get(product_id, "")))
+
+
+static func brush_size_for_product(product_id: StringName) -> int:
+ return int(BRUSH_PRODUCTS.get(product_id, -1))
+
+
+static func grid_size_for_product(product_id: StringName) -> int:
+ return int(GRID_PRODUCTS.get(product_id, -1))
diff --git a/progression/player_art_unlocks.gd.uid b/progression/player_art_unlocks.gd.uid
new file mode 100644
index 0000000..e0d2f0e
--- /dev/null
+++ b/progression/player_art_unlocks.gd.uid
@@ -0,0 +1 @@
+uid://cixpwmn33k2x2
diff --git a/progression/player_asset_reservation_service.gd b/progression/player_asset_reservation_service.gd
new file mode 100644
index 0000000..f66e672
--- /dev/null
+++ b/progression/player_asset_reservation_service.gd
@@ -0,0 +1,251 @@
+class_name PlayerAssetReservationService
+extends Node
+
+enum AttachmentType { NONE, FISH_COIN, FISH, CONSUMABLE }
+
+signal reservations_changed
+
+var _wallet: PlayerWallet
+var _inventory: FishInventory
+var _bag: PlayerBag
+var _catalog: ItemCatalog
+var _reservations: Dictionary[String, Dictionary] = {}
+var _mail_transfer_authorizations: Dictionary[String, Dictionary] = {}
+
+
+func setup(
+ wallet: PlayerWallet,
+ inventory: FishInventory,
+ bag: PlayerBag,
+ catalog: ItemCatalog,
+) -> void:
+ _wallet = wallet
+ _inventory = inventory
+ _bag = bag
+ _catalog = catalog
+
+
+func reserve(reservation_id: String, attachment: Dictionary) -> bool:
+ if (
+ reservation_id.is_empty()
+ or reservation_id.length() > 96
+ or _reservations.has(reservation_id)
+ or not validate_attachment(attachment)
+ ):
+ return false
+ var kind: int = attachment["type"]
+ match kind:
+ AttachmentType.NONE:
+ return true
+ AttachmentType.FISH_COIN:
+ if int(attachment["amount"]) > get_available_fish_coin():
+ return false
+ AttachmentType.FISH:
+ var catch_id := StringName(str(attachment["catch_id"]))
+ var fish_catch := _inventory.get_catch_by_id(catch_id)
+ if (
+ fish_catch == null
+ or fish_catch.is_favorited
+ or is_fish_reserved(catch_id)
+ ):
+ return false
+ AttachmentType.CONSUMABLE:
+ var item_id := StringName(str(attachment["item_id"]))
+ if (
+ int(attachment["quantity"])
+ > get_available_item_quantity(item_id)
+ ):
+ return false
+ _reservations[reservation_id] = attachment.duplicate(true)
+ reservations_changed.emit()
+ return true
+
+
+func release(reservation_id: String) -> bool:
+ if not _reservations.erase(reservation_id):
+ return false
+ _mail_transfer_authorizations.erase(reservation_id)
+ reservations_changed.emit()
+ return true
+
+
+func release_all() -> void:
+ if _reservations.is_empty():
+ return
+ _reservations.clear()
+ _mail_transfer_authorizations.clear()
+ reservations_changed.emit()
+
+
+func authorize_mail_transfer(
+ reservation_id: String,
+ catch_id: StringName,
+ transfer_id: String,
+) -> bool:
+ var attachment := get_reservation(reservation_id)
+ if (
+ transfer_id.is_empty()
+ or transfer_id.length() > 96
+ or attachment.is_empty()
+ or int(attachment.get("type", 0)) != AttachmentType.FISH
+ or StringName(str(attachment.get("catch_id", ""))) != catch_id
+ ):
+ return false
+ _mail_transfer_authorizations[reservation_id] = {
+ "catch_id": catch_id,
+ "transfer_id": transfer_id,
+ }
+ return true
+
+
+func authorize_mail_fish_removal(
+ reservation_id: String,
+ catch_id: StringName,
+ transfer_id: String,
+) -> bool:
+ var authorization: Dictionary = _mail_transfer_authorizations.get(
+ reservation_id, {}
+ )
+ return (
+ not authorization.is_empty()
+ and StringName(authorization.get("catch_id", StringName())) == catch_id
+ and str(authorization.get("transfer_id", "")) == transfer_id
+ )
+
+
+func has_reservation(reservation_id: String) -> bool:
+ return _reservations.has(reservation_id)
+
+
+func get_reservation(reservation_id: String) -> Dictionary:
+ return _reservations.get(reservation_id, {}).duplicate(true)
+
+
+func get_available_fish_coin() -> int:
+ return maxi(_wallet.get_balance() - get_reserved_fish_coin(), 0)
+
+
+func get_reserved_fish_coin() -> int:
+ var total := 0
+ for attachment: Dictionary in _reservations.values():
+ if int(attachment.get("type", 0)) == AttachmentType.FISH_COIN:
+ total += int(attachment.get("amount", 0))
+ return total
+
+
+func get_available_item_quantity(item_id: StringName) -> int:
+ return maxi(_bag.get_quantity(item_id) - get_reserved_item_quantity(item_id), 0)
+
+
+func get_reserved_item_quantity(item_id: StringName) -> int:
+ var total := 0
+ for attachment: Dictionary in _reservations.values():
+ if (
+ int(attachment.get("type", 0)) == AttachmentType.CONSUMABLE
+ and StringName(str(attachment.get("item_id", ""))) == item_id
+ ):
+ total += int(attachment.get("quantity", 0))
+ return total
+
+
+func is_fish_reserved(catch_id: StringName) -> bool:
+ for attachment: Dictionary in _reservations.values():
+ if (
+ int(attachment.get("type", 0)) == AttachmentType.FISH
+ and StringName(str(attachment.get("catch_id", ""))) == catch_id
+ ):
+ return true
+ return false
+
+
+func can_commit(reservation_id: String) -> bool:
+ var attachment := get_reservation(reservation_id)
+ if attachment.is_empty():
+ return false
+ match int(attachment["type"]):
+ AttachmentType.FISH_COIN:
+ return _wallet.can_afford(int(attachment["amount"]))
+ AttachmentType.FISH:
+ return _inventory.get_catch_by_id(
+ StringName(str(attachment["catch_id"]))
+ ) != null
+ AttachmentType.CONSUMABLE:
+ return (
+ _bag.get_quantity(StringName(str(attachment["item_id"])))
+ >= int(attachment["quantity"])
+ )
+ return false
+
+
+func commit_removal(
+ reservation_id: String,
+ mail_transfer_id: String = "",
+) -> bool:
+ var attachment := get_reservation(reservation_id)
+ if attachment.is_empty() or not can_commit(reservation_id):
+ return false
+ var applied := false
+ match int(attachment["type"]):
+ AttachmentType.FISH_COIN:
+ applied = _wallet.debit(int(attachment["amount"]))
+ AttachmentType.FISH:
+ var catch_id := StringName(str(attachment["catch_id"]))
+ if authorize_mail_transfer(
+ reservation_id, catch_id, mail_transfer_id
+ ):
+ applied = (
+ _inventory.remove_reserved_catch_for_mail_transfer(
+ catch_id, reservation_id, mail_transfer_id
+ )
+ != null
+ )
+ AttachmentType.CONSUMABLE:
+ applied = _bag.remove_item(
+ StringName(str(attachment["item_id"])),
+ int(attachment["quantity"])
+ )
+ if applied:
+ release(reservation_id)
+ else:
+ _mail_transfer_authorizations.erase(reservation_id)
+ return applied
+
+
+func validate_attachment(attachment: Dictionary) -> bool:
+ if typeof(attachment.get("type")) != TYPE_INT:
+ return false
+ match int(attachment["type"]):
+ AttachmentType.NONE:
+ return attachment.size() == 1
+ AttachmentType.FISH_COIN:
+ return (
+ attachment.size() == 2
+ and
+ typeof(attachment.get("amount")) == TYPE_INT
+ and int(attachment["amount"]) >= 1
+ and int(attachment["amount"]) <= 1000000000
+ )
+ AttachmentType.FISH:
+ return (
+ attachment.size() == 3
+ and
+ typeof(attachment.get("catch_id")) == TYPE_STRING
+ and not str(attachment["catch_id"]).is_empty()
+ and str(attachment["catch_id"]).length() <= 160
+ and typeof(attachment.get("catch")) == TYPE_DICTIONARY
+ )
+ AttachmentType.CONSUMABLE:
+ if (
+ attachment.size() != 3
+ or
+ typeof(attachment.get("item_id")) != TYPE_STRING
+ or typeof(attachment.get("quantity")) != TYPE_INT
+ or int(attachment["quantity"]) < 1
+ or int(attachment["quantity"]) > 999
+ ):
+ return false
+ var item := _catalog.get_item_by_id(
+ StringName(str(attachment["item_id"]))
+ )
+ return item != null and item.category == ItemData.Category.CONSUMABLE
+ return false
diff --git a/progression/player_asset_reservation_service.gd.uid b/progression/player_asset_reservation_service.gd.uid
new file mode 100644
index 0000000..aa45786
--- /dev/null
+++ b/progression/player_asset_reservation_service.gd.uid
@@ -0,0 +1 @@
+uid://bheplri3k6ch7
diff --git a/progression/player_experience.gd b/progression/player_experience.gd
new file mode 100644
index 0000000..ec5ba20
--- /dev/null
+++ b/progression/player_experience.gd
@@ -0,0 +1,130 @@
+class_name PlayerExperience
+extends Node
+
+signal experience_changed(total_experience: int, level: int)
+signal experience_awarded(
+ amount: int,
+ previous_total: int,
+ new_total: int,
+ previous_level: int,
+ new_level: int,
+)
+
+const MAX_TOTAL_EXPERIENCE: int = 1000000000000
+const MAX_LEVEL: int = 100000
+
+var _total_experience: int = 0
+
+
+func get_total_experience() -> int:
+ return _total_experience
+
+
+func get_level() -> int:
+ return level_for_total_experience(_total_experience)
+
+
+func get_experience_in_level() -> int:
+ return _total_experience - total_experience_for_level(get_level())
+
+
+func get_experience_for_next_level() -> int:
+ return experience_required_for_next_level(get_level())
+
+
+func get_level_progress() -> float:
+ return progress_for_total_experience(_total_experience)
+
+
+func award_experience(amount: int) -> bool:
+ if amount <= 0 or _total_experience >= MAX_TOTAL_EXPERIENCE:
+ return false
+ var previous_total: int = _total_experience
+ var previous_level: int = level_for_total_experience(previous_total)
+ _total_experience = mini(
+ _total_experience + amount,
+ MAX_TOTAL_EXPERIENCE,
+ )
+ var awarded_amount: int = _total_experience - previous_total
+ if awarded_amount <= 0:
+ return false
+ var new_level: int = level_for_total_experience(_total_experience)
+ experience_changed.emit(_total_experience, new_level)
+ experience_awarded.emit(
+ awarded_amount,
+ previous_total,
+ _total_experience,
+ previous_level,
+ new_level,
+ )
+ return true
+
+
+func restore_total_experience(total_experience: int) -> bool:
+ if total_experience < 0 or total_experience > MAX_TOTAL_EXPERIENCE:
+ return false
+ var changed: bool = _total_experience != total_experience
+ _total_experience = total_experience
+ if changed:
+ experience_changed.emit(_total_experience, get_level())
+ return true
+
+
+func reset_to_defaults() -> void:
+ restore_total_experience(0)
+
+
+func to_save_data() -> Dictionary:
+ return {"total_experience": _total_experience}
+
+
+static func experience_required_for_next_level(level: int) -> int:
+ var safe_level: int = maxi(level, 1)
+ var level_index: int = safe_level - 1
+ return 100 + level_index * 25 + level_index * level_index * 5
+
+
+static func total_experience_for_level(level: int) -> int:
+ var completed_levels: int = clampi(level - 1, 0, MAX_LEVEL - 1)
+ if completed_levels == 0:
+ return 0
+ var linear_sum: int = floori(
+ float(completed_levels) * float(completed_levels - 1) / 2.0
+ )
+ var square_sum: int = floori(
+ float(completed_levels - 1)
+ * float(completed_levels)
+ * float(2 * completed_levels - 1)
+ / 6.0
+ )
+ return completed_levels * 100 + linear_sum * 25 + square_sum * 5
+
+
+static func level_for_total_experience(total_experience: int) -> int:
+ var safe_total: int = clampi(
+ total_experience,
+ 0,
+ MAX_TOTAL_EXPERIENCE,
+ )
+ var low: int = 1
+ var high: int = MAX_LEVEL
+ while low < high:
+ var middle: int = low + floori(float(high - low + 1) / 2.0)
+ if total_experience_for_level(middle) <= safe_total:
+ low = middle
+ else:
+ high = middle - 1
+ return low
+
+
+static func progress_for_total_experience(total_experience: int) -> float:
+ var level: int = level_for_total_experience(total_experience)
+ var level_start: int = total_experience_for_level(level)
+ var required: int = experience_required_for_next_level(level)
+ if required <= 0:
+ return 0.0
+ return clampf(
+ float(total_experience - level_start) / float(required),
+ 0.0,
+ 1.0,
+ )
diff --git a/progression/player_experience.gd.uid b/progression/player_experience.gd.uid
new file mode 100644
index 0000000..9b49089
--- /dev/null
+++ b/progression/player_experience.gd.uid
@@ -0,0 +1 @@
+uid://eml3k7a43e1d
diff --git a/progression/player_item_effects.gd b/progression/player_item_effects.gd
index 47cdbb5..1a73c43 100644
--- a/progression/player_item_effects.gd
+++ b/progression/player_item_effects.gd
@@ -10,6 +10,8 @@ const COFFEE_ID: StringName = &"coffee"
const ENERGY_DRINK_ID: StringName = &"energy_drink"
const SNACK_ID: StringName = &"snack"
const FISH_FINDER_ID: StringName = &"fish_finder"
+const BATTERIES_ID: StringName = &"batteries"
+const FISH_FINDER_DEAD_MESSAGE: String = "hmm... batteries are dead..."
const COFFEE_DURATION: float = 90.0
const ENERGY_DRINK_DURATION: float = 90.0
const SNACK_DURATION: float = 60.0
@@ -45,22 +47,63 @@ func use_consumable(
item: ItemDataType,
bag: PlayerBagType,
) -> bool:
+ return use_item(item, bag)
+
+
+func use_item(
+ item: ItemDataType,
+ bag: PlayerBagType,
+) -> bool:
+ var is_fish_finder: bool = (
+ item != null
+ and item.item_id == FISH_FINDER_ID
+ and item.category == ItemDataType.Category.TOOL
+ and item.equippable
+ )
if (
item == null
or bag == null
- or item.category != ItemDataType.Category.CONSUMABLE
+ or (
+ item.category != ItemDataType.Category.CONSUMABLE
+ and not is_fish_finder
+ )
or not item.usable
or not _remaining.has(item.item_id)
or not bag.owns_item(item.item_id)
):
return false
- if not bag.remove_item(item.item_id, 1):
+ var consumed_item_id: StringName = (
+ BATTERIES_ID if is_fish_finder else item.item_id
+ )
+ if not bag.remove_item(consumed_item_id, 1):
return false
_remaining[item.item_id] = _get_duration(item.item_id)
effects_changed.emit()
return true
+func activate_authoritative(item_id: StringName, duration: float) -> bool:
+ if not _remaining.has(item_id) or not is_finite(duration) or duration <= 0.0:
+ return false
+ _remaining[item_id] = duration
+ effects_changed.emit()
+ return true
+
+
+func restore_remaining(snapshot: Dictionary[StringName, float]) -> void:
+ for item_id: StringName in _remaining:
+ _remaining[item_id] = maxf(snapshot.get(item_id, 0.0), 0.0)
+ effects_changed.emit()
+
+
+func get_remaining_snapshot() -> Dictionary[StringName, float]:
+ return _remaining.duplicate()
+
+
+func get_effect_duration(item_id: StringName) -> float:
+ return _get_duration(item_id)
+
+
func reset_all() -> void:
var changed: bool = false
for item_id: StringName in _remaining:
diff --git a/project.godot b/project.godot
index 1788218..1745ecc 100644
--- a/project.godot
+++ b/project.godot
@@ -11,22 +11,43 @@ config_version=5
[application]
config/name="NETFISHING"
-config/version="0.1.0-prealpha"
+config/version="0.7.1-alpha"
run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
-config/icon="res://icon.svg"
+config/icon="res://art/exported/system_icons/netfishing_256.png"
+
+[autoload]
+
+TextureSamplingPolicy="*res://main/texture_sampling_policy.gd"
[display]
-window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
+[editor_plugins]
+
+enabled=PackedStringArray("res://addons/netfishing_shoreline_baker/plugin.cfg")
+
[gui]
theme/custom="res://ui/game_theme.tres"
[input]
+ui_accept={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
+]
+}
+ui_cancel={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
+]
+}
move_forward={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
@@ -54,11 +75,13 @@ move_right={
sprint={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":7,"pressure":0.0,"pressed":false,"script":null)
]
}
jump={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
sneak={
@@ -74,11 +97,13 @@ slow_walk={
interact={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":3,"pressure":0.0,"pressed":false,"script":null)
]
}
fish_primary={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
camera_drag={
@@ -141,14 +166,51 @@ hotbar_9={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":57,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
+hotbar_previous={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
+]
+}
+hotbar_next={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
+]
+}
open_backpack={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":2,"pressure":0.0,"pressed":false,"script":null)
+]
+}
+open_system_menu={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
]
}
open_emotes={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
+]
+}
+character_call={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":71,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+open_quick_actions={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
+]
+}
+open_chat={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":4,"pressure":0.0,"pressed":false,"script":null)
+]
+}
+focus_gameplay={
+"deadzone": 0.2,
+"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":9,"pressure":0.0,"pressed":false,"script":null)
]
}
open_tacklebox={
@@ -187,6 +249,10 @@ alternate_fishing={
]
}
+[network]
+
+discovery/base_url="https://discovery.netfishing.org"
+
[physics]
3d/physics_engine="Jolt Physics"
@@ -196,3 +262,4 @@ alternate_fishing={
rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
+textures/vram_compression/import_etc2_astc=true
diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd
index 26c8ed6..57e355d 100644
--- a/save/player_save_manager.gd
+++ b/save/player_save_manager.gd
@@ -2,6 +2,7 @@ class_name PlayerSaveManager
extends Node
const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
@@ -17,12 +18,20 @@ const PlayerFishingUpgradesType = preload(
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
+const PlayerArtUnlocksType = preload(
+ "res://progression/player_art_unlocks.gd"
+)
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldWeatherServiceType = preload(
+ "res://world/world_weather_service.gd"
+)
+const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
-const SAVE_VERSION: int = 4
+const SAVE_VERSION: int = 7
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
-const SAVE_PATH: String = "user://player_save.json"
-const TEMP_PATH: String = "user://player_save.json.tmp"
-const BACKUP_PATH: String = "user://player_save.json.backup"
const MAX_SAFE_BALANCE: int = 1000000000000
class LoadSnapshot:
@@ -30,14 +39,28 @@ class LoadSnapshot:
var catches: Array[FishCatchType] = []
var discovered_ids: Array[StringName] = []
+ var discovered_quality_masks: Dictionary[StringName, int] = {}
var wallet_balance: int = 0
var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = []
+ var unlocked_bait_ids: Array[StringName] = []
var hotbar_slots: Array[StringName] = []
+ var fish_hotbar_slots: Array[StringName] = []
var selected_hotbar_slot: int = 0
var reel_speed_level: int = 0
var barrier_power_level: int = 0
var cooler_capacity_level: int = 0
+ var art_unlock_mask: int = 0
+ var total_experience: int = 0
+ var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR
+ var has_world_weather_state: bool = false
+ var world_weather: WorldWeatherServiceType.Weather = (
+ WorldWeatherServiceType.DEFAULT_WEATHER
+ )
+ var world_weather_seconds_remaining: float = (
+ WorldWeatherServiceType.SUNNY_DURATION_RANGE.x
+ )
+ var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5
@@ -51,12 +74,33 @@ var _hotbar: PlayerHotbarType
var _item_catalog: ItemCatalogType
var _fishing_upgrades: PlayerFishingUpgradesType
var _cooler_capacity: PlayerCoolerCapacityType
+var _art_unlocks: PlayerArtUnlocksType
+var _experience: PlayerExperienceType
+var _world_time: WorldTimeServiceType
+var _world_weather: WorldWeatherServiceType
+var _jobs: PlayerJobServiceType
var _autosave_timer: Timer
var _is_configured: bool = false
var _is_restoring: bool = false
var _is_dirty: bool = false
var _automatic_saving_blocked: bool = false
var _autosave_enabled: bool = false
+var _save_path := ""
+var _expected_hash := ""
+var _data_root: PlayerDataRoot
+
+
+func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
+ _save_path = path
+ _data_root = data_root
+
+
+func _temp_path() -> String:
+ return _save_path + ".tmp"
+
+
+func _backup_path() -> String:
+ return _save_path + ".backup"
func _ready() -> void:
@@ -76,6 +120,11 @@ func setup(
item_catalog: ItemCatalogType,
fishing_upgrades: PlayerFishingUpgradesType,
cooler_capacity: PlayerCoolerCapacityType,
+ art_unlocks: PlayerArtUnlocksType,
+ experience: PlayerExperienceType,
+ world_time: WorldTimeServiceType,
+ world_weather: WorldWeatherServiceType,
+ jobs: PlayerJobServiceType,
) -> void:
_inventory = inventory
_collection_log = collection_log
@@ -86,6 +135,11 @@ func setup(
_item_catalog = item_catalog
_fishing_upgrades = fishing_upgrades
_cooler_capacity = cooler_capacity
+ _art_unlocks = art_unlocks
+ _experience = experience
+ _world_time = world_time
+ _world_weather = world_weather
+ _jobs = jobs
_is_configured = (
_inventory != null
and _collection_log != null
@@ -96,16 +150,21 @@ func setup(
and _item_catalog != null
and _fishing_upgrades != null
and _cooler_capacity != null
+ and _art_unlocks != null
+ and _experience != null
+ and _world_time != null
+ and _world_weather != null
+ and _jobs != null
)
if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.")
return
if not _inventory.catches_changed.is_connected(_mark_dirty):
_inventory.catches_changed.connect(_mark_dirty)
- if not _collection_log.fish_discovered.is_connected(
- _on_fish_discovered
+ if not _collection_log.collection_changed.is_connected(
+ _on_collection_changed
):
- _collection_log.fish_discovered.connect(_on_fish_discovered)
+ _collection_log.collection_changed.connect(_on_collection_changed)
if not _wallet.balance_changed.is_connected(_on_balance_changed):
_wallet.balance_changed.connect(_on_balance_changed)
if not _bag.contents_changed.is_connected(_mark_dirty):
@@ -128,6 +187,14 @@ func setup(
_cooler_capacity.capacity_changed.connect(
_on_cooler_capacity_changed
)
+ if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed):
+ _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed)
+ if not _experience.experience_changed.is_connected(
+ _on_experience_changed
+ ):
+ _experience.experience_changed.connect(_on_experience_changed)
+ if not _jobs.changed.is_connected(_mark_dirty):
+ _jobs.changed.connect(_mark_dirty)
func load_player_data() -> bool:
@@ -135,16 +202,17 @@ func load_player_data() -> bool:
return false
_automatic_saving_blocked = false
_recover_interrupted_write()
- if not FileAccess.file_exists(SAVE_PATH):
+ if _save_path.is_empty() or not FileAccess.file_exists(_save_path):
_is_dirty = false
return true
- var save_file := FileAccess.open(SAVE_PATH, FileAccess.READ)
+ var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
_handle_corrupt_save("Unable to open player save.")
return false
var json_text: String = save_file.get_as_text()
save_file.close()
+ _expected_hash = PortableFileGuard.hash_file(_save_path)
var json := JSON.new()
var parse_error: Error = json.parse(json_text)
@@ -183,15 +251,22 @@ func load_player_data() -> bool:
snapshot.next_catch_sequence
)
var collection_restored: bool = (
- _collection_log.replace_discovered_ids(snapshot.discovered_ids)
+ _collection_log.replace_discovery_state(
+ snapshot.discovered_ids,
+ snapshot.discovered_quality_masks,
+ )
)
var wallet_restored: bool = _wallet.restore_balance(
snapshot.wallet_balance
)
- var bag_restored: bool = _bag.replace_all_items(snapshot.bag_items)
+ var bag_restored: bool = (
+ _bag.replace_all_items(snapshot.bag_items)
+ and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids)
+ )
var hotbar_restored: bool = _hotbar.replace_state(
snapshot.hotbar_slots,
- snapshot.selected_hotbar_slot
+ snapshot.selected_hotbar_slot,
+ snapshot.fish_hotbar_slots,
)
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
snapshot.reel_speed_level,
@@ -200,6 +275,22 @@ func load_player_data() -> bool:
var cooler_restored: bool = _cooler_capacity.restore_level(
snapshot.cooler_capacity_level
)
+ var art_restored: bool = _art_unlocks.restore_mask(
+ snapshot.art_unlock_mask
+ )
+ var experience_restored: bool = _experience.restore_total_experience(
+ snapshot.total_experience
+ )
+ var world_time_restored: bool = (
+ _world_time.restore_persistent_time_hours(snapshot.world_time_hours)
+ )
+ var world_weather_restored: bool = true
+ if snapshot.has_world_weather_state:
+ world_weather_restored = _world_weather.restore_persistent_state(
+ snapshot.world_weather,
+ snapshot.world_weather_seconds_remaining,
+ )
+ var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data)
_is_restoring = false
if (
not inventory_restored
@@ -209,6 +300,11 @@ func load_player_data() -> bool:
or not hotbar_restored
or not upgrades_restored
or not cooler_restored
+ or not art_restored
+ or not experience_restored
+ or not world_time_restored
+ or not world_weather_restored
+ or not jobs_restored
):
push_error("Validated player save could not be restored.")
return false
@@ -224,12 +320,12 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new()
_recover_interrupted_write()
- result.has_primary_file = FileAccess.file_exists(SAVE_PATH)
+ result.has_primary_file = FileAccess.file_exists(_save_path)
if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING
result.message = "no save found."
return result
- var save_file := FileAccess.open(SAVE_PATH, FileAccess.READ)
+ var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
result.status = SaveInspectionType.Status.IO_ERROR
result.message = "the save could not be read."
@@ -279,15 +375,16 @@ func initialize_new_game() -> bool:
func delete_progression_save() -> bool:
- if FileAccess.file_exists(SAVE_PATH) and not _remove_if_present(SAVE_PATH):
+ if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
return false
- for path: String in [TEMP_PATH, BACKUP_PATH]:
+ for path: String in [_temp_path(), _backup_path()]:
if FileAccess.file_exists(path) and not _remove_if_present(path):
push_warning("Unable to remove stale player-save auxiliary file.")
if _autosave_timer != null:
_autosave_timer.stop()
_is_dirty = false
_automatic_saving_blocked = false
+ _expected_hash = ""
return true
@@ -301,6 +398,17 @@ func save_if_dirty() -> bool:
return not _is_dirty or save_now()
+func save_world_time_checkpoint() -> bool:
+ if (
+ not _is_configured
+ or _automatic_saving_blocked
+ or not _autosave_enabled
+ ):
+ return false
+ _is_dirty = true
+ return save_now()
+
+
func is_dirty() -> bool:
return _is_dirty
@@ -325,31 +433,22 @@ func save_now() -> bool:
if json_text.is_empty():
return false
- var temp_file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
- if temp_file == null:
- push_warning("Unable to open temporary player save for writing.")
+ var result := PortableFileGuard.write_guarded(
+ _save_path,
+ json_text.to_utf8_buffer(),
+ _expected_hash,
+ _data_root.conflict_directory(),
+ _data_root.device_id,
+ )
+ if bool(result.get("conflict", false)):
+ _data_root.report_conflict(
+ str(result.get("message", "")),
+ str(result.get("conflict_path", "")),
+ )
+ if not bool(result.get("ok", false)):
+ push_warning("Unable to safely write the player save.")
return false
- temp_file.store_string(json_text)
- temp_file.flush()
- var write_error: Error = temp_file.get_error()
- temp_file.close()
- if write_error != OK:
- _remove_if_present(TEMP_PATH)
- push_warning("Unable to finish writing temporary player save.")
- return false
-
- var had_primary: bool = FileAccess.file_exists(SAVE_PATH)
- _remove_if_present(BACKUP_PATH)
- if had_primary and not _rename_file(SAVE_PATH, BACKUP_PATH):
- _remove_if_present(TEMP_PATH)
- push_warning("Unable to preserve the previous player save.")
- return false
- if not _rename_file(TEMP_PATH, SAVE_PATH):
- if had_primary:
- _rename_file(BACKUP_PATH, SAVE_PATH)
- push_warning("Unable to promote the temporary player save.")
- return false
- _remove_if_present(BACKUP_PATH)
+ _expected_hash = str(result["hash"])
_is_dirty = false
return true
@@ -379,14 +478,36 @@ func _build_save_dictionary() -> Dictionary:
if fish_id.is_empty():
return {}
discovered_strings.append(String(fish_id))
+ var serialized_quality_masks: Dictionary = {}
+ var quality_masks: Dictionary[StringName, int] = (
+ _collection_log.get_discovered_quality_masks()
+ )
+ for fish_id: StringName in quality_masks:
+ var quality_mask: int = quality_masks[fish_id]
+ if (
+ fish_id.is_empty()
+ or quality_mask <= 0
+ or (quality_mask & ~FishQualityType.ALL_TIERS_MASK) != 0
+ ):
+ return {}
+ serialized_quality_masks[String(fish_id)] = quality_mask
var serialized_items: Array[Dictionary] = []
for owned: OwnedItemType in _bag.get_all_items():
if owned == null or not owned.is_valid():
return {}
serialized_items.append(owned.to_save_dict())
+ var serialized_unlocked_baits: Array[String] = []
+ for item_id: StringName in _bag.get_unlocked_bait_ids():
+ var item_data = _item_catalog.get_item_by_id(item_id)
+ if item_data == null or not item_data.is_valid() or not item_data.is_bait():
+ return {}
+ serialized_unlocked_baits.append(String(item_id))
var serialized_slots: Array[String] = []
for item_id: StringName in _hotbar.get_slots():
serialized_slots.append(String(item_id))
+ var serialized_fish_slots: Array[String] = []
+ for catch_id: StringName in _hotbar.get_fish_slots():
+ serialized_fish_slots.append(String(catch_id))
if (
_wallet.get_balance() < 0
or _wallet.get_balance() > MAX_SAFE_BALANCE
@@ -405,6 +526,7 @@ func _build_save_dictionary() -> Dictionary:
},
"collection": {
"discovered_fish_ids": discovered_strings,
+ "discovered_quality_masks": serialized_quality_masks,
},
"inventory": {
"next_catch_sequence": _inventory.get_next_catch_sequence(),
@@ -412,13 +534,25 @@ func _build_save_dictionary() -> Dictionary:
},
"bag": {
"items": serialized_items,
+ "unlocked_bait_ids": serialized_unlocked_baits,
},
"hotbar": {
"selected_slot": _hotbar.get_selected_slot(),
"slots": serialized_slots,
+ "fish_slots": serialized_fish_slots,
},
"upgrades": _fishing_upgrades.to_save_data(),
"cooler": _cooler_capacity.to_save_data(),
+ "art": _art_unlocks.to_save_data(),
+ "experience": _experience.to_save_data(),
+ "world": {
+ "time_hours": _world_time.get_persistent_time_hours(),
+ "weather": int(_world_weather.get_persistent_weather()),
+ "weather_seconds_remaining": (
+ _world_weather.get_persistent_seconds_remaining()
+ ),
+ },
+ "jobs": _jobs.to_save_data(),
}
@@ -429,6 +563,8 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
+ or typeof(save_data.get("experience")) != TYPE_DICTIONARY
+ or typeof(save_data.get("jobs")) != TYPE_DICTIONARY
):
return null
var wallet_data: Dictionary = save_data["wallet"]
@@ -436,8 +572,14 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var inventory_data: Dictionary = save_data["inventory"]
var bag_data: Dictionary = save_data["bag"]
var hotbar_data: Dictionary = save_data["hotbar"]
+ var experience_data: Dictionary = save_data["experience"]
+ var jobs_data: Dictionary = save_data["jobs"]
+ var world_data: Dictionary = {}
+ if typeof(save_data.get("world")) == TYPE_DICTIONARY:
+ world_data = save_data["world"]
var upgrades_data: Dictionary = {}
var cooler_data: Dictionary = {}
+ var art_data: Dictionary = {}
if typeof(save_data.get("upgrades")) == TYPE_DICTIONARY:
upgrades_data = save_data["upgrades"]
else:
@@ -448,14 +590,23 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
cooler_data = save_data["cooler"]
else:
push_warning("Saved Cooler data was missing or invalid; using defaults.")
+ if typeof(save_data.get("art")) == TYPE_DICTIONARY:
+ art_data = save_data["art"]
if (
not wallet_data.has("balance")
or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY
+ or typeof(collection_data.get("discovered_quality_masks"))
+ != TYPE_DICTIONARY
or typeof(inventory_data.get("catches")) != TYPE_ARRAY
or not inventory_data.has("next_catch_sequence")
or typeof(bag_data.get("items")) != TYPE_ARRAY
+ or (
+ bag_data.has("unlocked_bait_ids")
+ and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY
+ )
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
or not hotbar_data.has("selected_slot")
+ or not experience_data.has("total_experience")
):
return null
@@ -474,6 +625,47 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var snapshot := LoadSnapshot.new()
snapshot.wallet_balance = balance
+ snapshot.total_experience = _read_integer(
+ experience_data["total_experience"],
+ -1,
+ PlayerExperienceType.MAX_TOTAL_EXPERIENCE,
+ )
+ if snapshot.total_experience < 0:
+ return null
+ if not PlayerJobServiceType.validate_save_data(jobs_data):
+ return null
+ snapshot.jobs_data = jobs_data.duplicate(true)
+ if world_data.has("time_hours"):
+ snapshot.world_time_hours = _read_world_time_hours(
+ world_data["time_hours"]
+ )
+ if snapshot.world_time_hours < 0.0:
+ return null
+ var has_weather: bool = world_data.has("weather")
+ var has_weather_seconds: bool = world_data.has(
+ "weather_seconds_remaining"
+ )
+ if has_weather != has_weather_seconds:
+ return null
+ if has_weather:
+ var weather_value: int = _read_integer(
+ world_data["weather"],
+ -1,
+ WorldWeatherServiceType.Weather.FOGGY,
+ )
+ var weather_seconds: float = _read_world_weather_seconds(
+ world_data["weather_seconds_remaining"]
+ )
+ if (
+ not WorldWeatherServiceType.is_valid_weather(weather_value)
+ or weather_seconds < 0.0
+ ):
+ return null
+ snapshot.has_world_weather_state = true
+ snapshot.world_weather = (
+ weather_value as WorldWeatherServiceType.Weather
+ )
+ snapshot.world_weather_seconds_remaining = weather_seconds
var discovered_values: Array = collection_data["discovered_fish_ids"]
var seen_discoveries: Dictionary[StringName, bool] = {}
for value: Variant in discovered_values:
@@ -484,6 +676,26 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
continue
seen_discoveries[fish_id] = true
snapshot.discovered_ids.append(fish_id)
+ var quality_mask_values: Dictionary = (
+ collection_data["discovered_quality_masks"]
+ )
+ for key: Variant in quality_mask_values:
+ if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ return null
+ var fish_id: StringName = StringName(str(key))
+ var mask: int = _read_integer(
+ quality_mask_values[key],
+ -1,
+ FishQualityType.ALL_TIERS_MASK,
+ )
+ if (
+ fish_id.is_empty()
+ or not seen_discoveries.has(fish_id)
+ or mask <= 0
+ or (mask & ~FishQualityType.ALL_TIERS_MASK) != 0
+ ):
+ return null
+ snapshot.discovered_quality_masks[fish_id] = mask
var seen_ids: Dictionary[StringName, bool] = {}
var seen_sequences: Dictionary[int, bool] = {}
@@ -584,6 +796,34 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
seen_items[item_id] = true
snapshot.bag_items.append(owned)
+ var seen_unlocked_baits: Dictionary[StringName, bool] = {}
+ if bag_data.has("unlocked_bait_ids"):
+ var unlocked_bait_values: Array = bag_data["unlocked_bait_ids"]
+ for value: Variant in unlocked_bait_values:
+ if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ continue
+ var bait_id: StringName = StringName(str(value))
+ var bait_data = _item_catalog.get_item_by_id(bait_id)
+ if (
+ bait_id.is_empty()
+ or seen_unlocked_baits.has(bait_id)
+ or bait_data == null
+ or not bait_data.is_valid()
+ or not bait_data.is_bait()
+ ):
+ continue
+ seen_unlocked_baits[bait_id] = true
+ snapshot.unlocked_bait_ids.append(bait_id)
+ for owned: OwnedItemType in snapshot.bag_items:
+ var owned_item_data = _item_catalog.get_item_by_id(owned.item_id)
+ if (
+ owned_item_data != null
+ and owned_item_data.is_bait()
+ and not seen_unlocked_baits.has(owned.item_id)
+ ):
+ seen_unlocked_baits[owned.item_id] = true
+ snapshot.unlocked_bait_ids.append(owned.item_id)
+
var slot_values: Array = hotbar_data["slots"]
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
snapshot.hotbar_slots.fill(StringName())
@@ -604,6 +844,22 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
and seen_items.has(slot_item_id)
):
snapshot.hotbar_slots[slot_index] = slot_item_id
+ var fish_slot_values: Array = []
+ if typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY:
+ fish_slot_values = hotbar_data["fish_slots"]
+ snapshot.fish_hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
+ snapshot.fish_hotbar_slots.fill(StringName())
+ for slot_index: int in range(
+ mini(fish_slot_values.size(), PlayerHotbarType.SLOT_COUNT)
+ ):
+ if not snapshot.hotbar_slots[slot_index].is_empty():
+ continue
+ var slot_value: Variant = fish_slot_values[slot_index]
+ if typeof(slot_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ continue
+ var catch_id: StringName = StringName(str(slot_value))
+ if not catch_id.is_empty() and seen_ids.has(catch_id):
+ snapshot.fish_hotbar_slots[slot_index] = catch_id
var selected_slot: int = _read_integer(
hotbar_data["selected_slot"],
0,
@@ -629,6 +885,11 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
PlayerCoolerCapacityType.MAX_LEVEL,
"cooler.capacity_level"
)
+ snapshot.art_unlock_mask = _read_integer(
+ art_data.get("unlock_mask"),
+ 0,
+ PlayerArtUnlocksType.ALL_UNLOCK_MASK,
+ )
return snapshot
@@ -650,6 +911,12 @@ func _migrate_save(
migrated = _migrate_version_2_to_3(migrated)
3:
migrated = _migrate_version_3_to_4(migrated)
+ 4:
+ migrated = _migrate_version_4_to_5(migrated)
+ 5:
+ migrated = _migrate_version_5_to_6(migrated)
+ 6:
+ migrated = _migrate_version_6_to_7(migrated)
_:
return {}
if migrated.is_empty():
@@ -697,6 +964,77 @@ func _migrate_version_3_to_4(data: Dictionary) -> Dictionary:
return migrated
+func _migrate_version_4_to_5(data: Dictionary) -> Dictionary:
+ var migrated: Dictionary = data.duplicate(true)
+ if (
+ typeof(migrated.get("inventory")) != TYPE_DICTIONARY
+ or typeof(migrated.get("collection")) != TYPE_DICTIONARY
+ ):
+ return {}
+ var inventory_data: Dictionary = migrated["inventory"]
+ var collection_data: Dictionary = migrated["collection"]
+ if (
+ typeof(inventory_data.get("catches")) != TYPE_ARRAY
+ or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY
+ ):
+ return {}
+ var catches: Array = inventory_data["catches"]
+ var discovered_values: Array = collection_data["discovered_fish_ids"]
+ var discovered_lookup: Dictionary[String, bool] = {}
+ for value: Variant in discovered_values:
+ if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
+ var existing_id: String = str(value)
+ if not existing_id.is_empty():
+ discovered_lookup[existing_id] = true
+ for index: int in catches.size():
+ if typeof(catches[index]) != TYPE_DICTIONARY:
+ continue
+ var catch_data: Dictionary = catches[index]
+ catch_data["quality"] = FishQualityType.Tier.BORING
+ var catch_fish_id: String = str(catch_data.get("fish_id", ""))
+ if not catch_fish_id.is_empty() and not discovered_lookup.has(
+ catch_fish_id
+ ):
+ discovered_values.append(catch_fish_id)
+ discovered_lookup[catch_fish_id] = true
+ catches[index] = catch_data
+ inventory_data["catches"] = catches
+ migrated["inventory"] = inventory_data
+ var quality_masks: Dictionary = {}
+ for value: Variant in discovered_values:
+ if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
+ continue
+ var fish_id: String = str(value)
+ if not fish_id.is_empty():
+ quality_masks[fish_id] = FishQualityType.bit_for(
+ FishQualityType.Tier.BORING
+ )
+ collection_data["discovered_quality_masks"] = quality_masks
+ collection_data["discovered_fish_ids"] = discovered_values
+ migrated["collection"] = collection_data
+ migrated["save_version"] = 5
+ return migrated
+
+
+func _migrate_version_5_to_6(data: Dictionary) -> Dictionary:
+ var migrated: Dictionary = data.duplicate(true)
+ migrated["save_version"] = 6
+ migrated["experience"] = {"total_experience": 0}
+ migrated["world"] = {
+ "time_hours": WorldTimeServiceType.DEFAULT_START_HOUR,
+ }
+ return migrated
+
+
+func _migrate_version_6_to_7(data: Dictionary) -> Dictionary:
+ var migrated: Dictionary = data.duplicate(true)
+ migrated["save_version"] = 7
+ # Historical catch and sale totals cannot be reconstructed honestly from
+ # current inventory. New cumulative counters begin at feature introduction.
+ migrated["jobs"] = PlayerJobServiceType.default_save_data()
+ return migrated
+
+
func _mark_dirty() -> void:
if (
_is_restoring
@@ -709,7 +1047,7 @@ func _mark_dirty() -> void:
_autosave_timer.start(maxf(autosave_delay, 0.05))
-func _on_fish_discovered(_fish_id: StringName) -> void:
+func _on_collection_changed() -> void:
_mark_dirty()
@@ -738,33 +1076,41 @@ func _on_cooler_capacity_changed(
_mark_dirty()
+func _on_art_unlocks_changed(_unlock_mask: int) -> void:
+ _mark_dirty()
+
+
+func _on_experience_changed(_total_experience: int, _level: int) -> void:
+ _mark_dirty()
+
+
func _on_autosave_timeout() -> void:
if _is_dirty:
save_now()
func _recover_interrupted_write() -> void:
- if FileAccess.file_exists(SAVE_PATH):
- _remove_if_present(TEMP_PATH)
- _remove_if_present(BACKUP_PATH)
+ if FileAccess.file_exists(_save_path):
+ _remove_if_present(_temp_path())
+ _remove_if_present(_backup_path())
return
- if FileAccess.file_exists(BACKUP_PATH):
- _rename_file(BACKUP_PATH, SAVE_PATH)
- _remove_if_present(TEMP_PATH)
+ if FileAccess.file_exists(_backup_path()):
+ _rename_file(_backup_path(), _save_path)
+ _remove_if_present(_temp_path())
func _handle_corrupt_save(message: String) -> void:
push_warning(message)
_restore_defaults()
- if not FileAccess.file_exists(SAVE_PATH):
+ if not FileAccess.file_exists(_save_path):
return
var timestamp: int = int(Time.get_unix_time_from_system())
var corrupt_path: String = (
- "%s.corrupt-%d" % [SAVE_PATH, timestamp]
+ "%s.corrupt-%d" % [_save_path, timestamp]
)
if FileAccess.file_exists(corrupt_path):
corrupt_path += "-%d" % Time.get_ticks_usec()
- if not _rename_file(SAVE_PATH, corrupt_path):
+ if not _rename_file(_save_path, corrupt_path):
_automatic_saving_blocked = true
push_warning(
"Corrupt player save was left in place; automatic saving is "
@@ -778,6 +1124,7 @@ func _restore_defaults() -> void:
_is_restoring = true
var empty_catches: Array[FishCatchType] = []
var empty_discoveries: Array[StringName] = []
+ var empty_quality_masks: Dictionary[StringName, int] = {}
var default_items: Array[OwnedItemType] = []
var basic_rod := OwnedItemType.new()
basic_rod.item_id = BASIC_ROD_ID
@@ -788,12 +1135,24 @@ func _restore_defaults() -> void:
default_slots.fill(StringName())
default_slots[0] = BASIC_ROD_ID
_inventory.replace_all_catches(empty_catches, 1)
- _collection_log.replace_discovered_ids(empty_discoveries)
+ _collection_log.replace_discovery_state(
+ empty_discoveries,
+ empty_quality_masks,
+ )
_wallet.restore_balance(0)
_bag.replace_all_items(default_items)
+ var default_unlocked_baits: Array[StringName] = []
+ _bag.replace_unlocked_bait_ids(default_unlocked_baits)
_hotbar.replace_state(default_slots, 0)
_fishing_upgrades.reset_to_defaults()
_cooler_capacity.reset_to_defaults()
+ _art_unlocks.reset_to_defaults()
+ _experience.reset_to_defaults()
+ _world_time.restore_persistent_time_hours(
+ WorldTimeServiceType.DEFAULT_START_HOUR
+ )
+ _world_weather.reset_persistent_state()
+ _jobs.reset_to_defaults()
_is_restoring = false
_is_dirty = false
@@ -819,6 +1178,32 @@ func _read_integer(
return invalid_value
+func _read_world_time_hours(value: Variant) -> float:
+ if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
+ return -1.0
+ var time_hours: float = float(value)
+ if (
+ not is_finite(time_hours)
+ or time_hours < 0.0
+ or time_hours >= WorldTimeServiceType.HOURS_PER_DAY
+ ):
+ return -1.0
+ return time_hours
+
+
+func _read_world_weather_seconds(value: Variant) -> float:
+ if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
+ return -1.0
+ var seconds_remaining: float = float(value)
+ if (
+ not is_finite(seconds_remaining)
+ or seconds_remaining < 0.0
+ or seconds_remaining > WorldWeatherServiceType.MAX_PERSISTED_SECONDS
+ ):
+ return -1.0
+ return seconds_remaining
+
+
func _read_upgrade_level(
value: Variant,
maximum_level: int,
diff --git a/scripts/bake_shoreline_ribbons.gd b/scripts/bake_shoreline_ribbons.gd
new file mode 100644
index 0000000..c030579
--- /dev/null
+++ b/scripts/bake_shoreline_ribbons.gd
@@ -0,0 +1,42 @@
+extends SceneTree
+
+const REGION_SCENE := preload("res://world/regions/starter_island_region.tscn")
+func _initialize() -> void:
+ call_deferred("_bake")
+
+
+func _bake() -> void:
+ var started := Time.get_ticks_usec()
+ var region := REGION_SCENE.instantiate()
+ root.add_child(region)
+ var baker := region.get_node_or_null("ShorelineRibbonBaker") as ShorelineRibbonBaker
+ if baker == null:
+ push_error("The map does not contain a configured ShorelineRibbonBaker.")
+ quit(1)
+ return
+ var results := baker.rebuild_all()
+ if results.size() != baker.water_bodies.size():
+ quit(1)
+ return
+ for result: Dictionary in results:
+ if result.get("skipped", false):
+ print(
+ "Shoreline %s: type=%s skipped intentionally"
+ % [result["output_path"], WaterType.label(result["water_type"])]
+ )
+ continue
+ print(
+ "Shoreline %s: type=%s segments=%d loops=%d raw=%d simplified=%d smoothed=%d triangles=%d"
+ % [
+ result["output_path"],
+ WaterType.label(result["water_type"]),
+ result["segment_count"],
+ result["loop_count"],
+ result["raw_point_count"],
+ result["simplified_point_count"],
+ result["smoothed_point_count"],
+ result["triangle_count"],
+ ]
+ )
+ print("Shoreline bake completed in %.2f ms" % ((Time.get_ticks_usec() - started) / 1000.0))
+ quit()
diff --git a/scripts/bake_shoreline_ribbons.gd.uid b/scripts/bake_shoreline_ribbons.gd.uid
new file mode 100644
index 0000000..7b789be
--- /dev/null
+++ b/scripts/bake_shoreline_ribbons.gd.uid
@@ -0,0 +1 @@
+uid://eosavaa6wu1e
diff --git a/scripts/build_android_debug.sh b/scripts/build_android_debug.sh
new file mode 100755
index 0000000..8f856f8
--- /dev/null
+++ b/scripts/build_android_debug.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+readonly OUTPUT_PATH="${PROJECT_ROOT}/builds/android/NETfishing-debug.apk"
+readonly GODOT_BIN="${GODOT_BIN:-godot}"
+
+mkdir -p -- "$(dirname -- "${OUTPUT_PATH}")"
+
+"${GODOT_BIN}" \
+ --headless \
+ --path "${PROJECT_ROOT}" \
+ --export-debug "Android" \
+ "${OUTPUT_PATH}"
+
+test -s "${OUTPUT_PATH}"
+sha256sum "${OUTPUT_PATH}"
diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh
index 3cf206e..7749b3d 100755
--- a/scripts/build_playtest.sh
+++ b/scripts/build_playtest.sh
@@ -4,12 +4,14 @@ set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
-readonly BUILD_ROOT="${PROJECT_ROOT}/builds/playtest-0.1"
+readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.7.1-alpha"
readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
-readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt"
-readonly WINDOWS_ZIP="${BUILD_ROOT}/netfishing-prealpha-0.1-windows-x86_64.zip"
-readonly LINUX_ZIP="${BUILD_ROOT}/netfishing-prealpha-0.1-linux-x86_64.zip"
+readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
+readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
+readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
+readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.7.1-alpha-windows-x86_64.zip"
+readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.7.1-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then
@@ -29,29 +31,61 @@ mkdir -p -- "${WINDOWS_DIR}" "${LINUX_DIR}"
--headless \
--path "${PROJECT_ROOT}" \
--export-release "Windows Desktop" \
- "${WINDOWS_DIR}/netfishing.exe"
+ "${WINDOWS_DIR}/NETfishing.exe"
"${GODOT_BIN}" \
--headless \
--path "${PROJECT_ROOT}" \
--export-release "Linux Desktop" \
- "${LINUX_DIR}/netfishing"
+ "${LINUX_DIR}/NETfishing.x86_64"
for required_file in \
- "${WINDOWS_DIR}/netfishing.exe" \
- "${WINDOWS_DIR}/netfishing.pck" \
- "${LINUX_DIR}/netfishing" \
- "${LINUX_DIR}/netfishing.pck"; do
+ "${WINDOWS_DIR}/NETfishing.exe" \
+ "${WINDOWS_DIR}/NETfishing.pck" \
+ "${LINUX_DIR}/NETfishing.x86_64" \
+ "${LINUX_DIR}/NETfishing.pck"; do
if [[ ! -s "${required_file}" ]]; then
echo "error: expected export output is missing: ${required_file}" >&2
exit 1
fi
done
-chmod +x -- "${LINUX_DIR}/netfishing"
+chmod +x -- "${LINUX_DIR}/NETfishing.x86_64"
cp -- "${README_SOURCE}" "${WINDOWS_DIR}/README-PLAYTEST.txt"
cp -- "${README_SOURCE}" "${LINUX_DIR}/README-PLAYTEST.txt"
+stage_licenses() {
+ local destination="$1/licenses"
+ mkdir -p -- "${destination}"
+ install -m 0644 "${PROJECT_ROOT}/LICENSE" \
+ "${destination}/GPL-3.0-or-later.txt"
+ install -m 0644 "${PROJECT_ROOT}/ASSET-LICENSE.md" \
+ "${destination}/NETFISHING-ASSET-LICENSE.md"
+ install -m 0644 "${PROJECT_ROOT}/docs/ATTRIBUTION.md" \
+ "${destination}/ATTRIBUTION.md"
+ install -m 0644 "${PROJECT_ROOT}/TRADEMARKS.md" \
+ "${destination}/TRADEMARKS.md"
+ install -m 0644 "${PROJECT_ROOT}/ui/fonts/Tuffy-LICENSE.txt" \
+ "${destination}/Tuffy-LICENSE.txt"
+ install -m 0644 "${PROJECT_ROOT}/ui/fonts/Seattle-Avenue-LICENSE.txt" \
+ "${destination}/Seattle-Avenue-LICENSE.txt"
+ cat >"${destination}/SOURCE-CODE.md" <&2
+ exit 2
+fi
+
+readonly PROJECT_VERSION="$(
+ sed -n 's/^config\/version="\([^"]*\)"/\1/p' "${PROJECT_ROOT}/project.godot"
+)"
+if [[ -z "${PROJECT_VERSION}" ]]; then
+ echo "Could not read application/config/version from project.godot." >&2
+ exit 1
+fi
+
+readonly RELEASE_TAG="v${PROJECT_VERSION}"
+readonly RELEASE_ROOT="${PROJECT_ROOT}/builds/${RELEASE_TAG}"
+readonly ARM64_ROOT="${RELEASE_ROOT}/linux-arm64"
+readonly ARM64_EXECUTABLE="${ARM64_ROOT}/NETfishing.arm64"
+readonly ARM64_PCK="${ARM64_ROOT}/NETfishing.pck"
+readonly STAGE_ROOT="${RELEASE_ROOT}/portmaster-stage"
+readonly GAME_ROOT="${STAGE_ROOT}/netfishing"
+readonly ARCHIVE="${RELEASE_ROOT}/netfishing.zip"
+readonly LEGACY_ARCHIVE="${RELEASE_ROOT}/netfishing-${RELEASE_TAG}-portmaster-arm64.zip"
+
+readonly HEAD_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
+readonly TAG_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse "${RELEASE_TAG}^{commit}")"
+if [[ "${HEAD_COMMIT}" != "${TAG_COMMIT}" ]]; then
+ echo "HEAD does not match ${RELEASE_TAG}. Refusing to package mutable source." >&2
+ exit 1
+fi
+if [[ "${NETFISHING_ALLOW_DIRTY_RELEASE:-0}" != "1" ]]; then
+ dirty_tree=0
+ git -C "${PROJECT_ROOT}" diff --quiet || dirty_tree=1
+ git -C "${PROJECT_ROOT}" diff --cached --quiet || dirty_tree=1
+ if [[ -n "$(git -C "${PROJECT_ROOT}" status --porcelain --untracked-files=normal)" ]]; then
+ dirty_tree=1
+ fi
+ if [[ ${dirty_tree} -ne 0 ]]; then
+ echo "The repository is not clean. Refusing to build a release package." >&2
+ exit 1
+ fi
+fi
+
+for command_name in git file zip unzip sha256sum; do
+ command -v "${command_name}" >/dev/null 2>&1 || {
+ echo "Required command not found: ${command_name}" >&2
+ exit 1
+ }
+done
+
+mkdir -p -- "${ARM64_ROOT}" "${RELEASE_ROOT}"
+if [[ ${PACKAGE_ONLY} -eq 0 ]]; then
+ rm -f -- "${ARM64_EXECUTABLE}" "${ARM64_PCK}"
+ "${GODOT_BIN}" \
+ --headless \
+ --path "${PROJECT_ROOT}" \
+ --export-release "Linux ARM64"
+fi
+
+test -s "${ARM64_EXECUTABLE}"
+test -s "${ARM64_PCK}"
+file "${ARM64_EXECUTABLE}" | grep -q "ARM aarch64"
+
+rm -rf -- "${STAGE_ROOT}"
+rm -f -- "${ARCHIVE}" "${LEGACY_ARCHIVE}"
+mkdir -p -- "${GAME_ROOT}/licenses"
+
+install -m 0755 "${TEMPLATE_ROOT}/NETfishing.sh" "${STAGE_ROOT}/NETfishing.sh"
+install -m 0644 "${TEMPLATE_ROOT}/port.json" "${STAGE_ROOT}/port.json"
+install -m 0755 "${ARM64_EXECUTABLE}" "${GAME_ROOT}/NETfishing.aarch64"
+install -m 0644 "${ARM64_PCK}" "${GAME_ROOT}/NETfishing.pck"
+install -m 0644 "${TEMPLATE_ROOT}/gameinfo.xml" "${GAME_ROOT}/gameinfo.xml"
+install -m 0644 "${TEMPLATE_ROOT}/screenshot.png" "${GAME_ROOT}/screenshot.png"
+install -m 0644 \
+ "${PROJECT_ROOT}/docs/ATTRIBUTION.md" \
+ "${GAME_ROOT}/licenses/ATTRIBUTION.md"
+install -m 0644 \
+ "${PROJECT_ROOT}/LICENSE" \
+ "${GAME_ROOT}/licenses/GPL-3.0-or-later.txt"
+install -m 0644 \
+ "${PROJECT_ROOT}/ASSET-LICENSE.md" \
+ "${GAME_ROOT}/licenses/NETFISHING-ASSET-LICENSE.md"
+install -m 0644 \
+ "${PROJECT_ROOT}/TRADEMARKS.md" \
+ "${GAME_ROOT}/licenses/TRADEMARKS.md"
+install -m 0644 \
+ "${PROJECT_ROOT}/ui/fonts/Tuffy-LICENSE.txt" \
+ "${GAME_ROOT}/licenses/Tuffy-LICENSE.txt"
+install -m 0644 \
+ "${PROJECT_ROOT}/ui/fonts/Seattle-Avenue-LICENSE.txt" \
+ "${GAME_ROOT}/licenses/Seattle-Avenue-LICENSE.txt"
+
+cat >"${GAME_ROOT}/BUILD-INFO.txt" <"${GAME_ROOT}/licenses/SOURCE-CODE.md" <"${GAME_ROOT}/README.md" </dev/null
+readonly TOP_LEVELS="$(
+ unzip -Z1 "${ARCHIVE}" | cut -d/ -f1 | sort -u
+)"
+readonly EXPECTED_TOP_LEVELS="$(printf '%s\n' NETfishing.sh netfishing port.json)"
+if [[ "${TOP_LEVELS}" != "${EXPECTED_TOP_LEVELS}" ]]; then
+ echo "Unexpected PortMaster archive roots:" >&2
+ printf '%s\n' "${TOP_LEVELS}" >&2
+ exit 1
+fi
+grep -q '"version": 4' "${STAGE_ROOT}/port.json"
+grep -q '"name": "netfishing.zip"' "${STAGE_ROOT}/port.json"
+grep -q '^# PORTMASTER: netfishing.zip, NETfishing.sh$' \
+ "${STAGE_ROOT}/NETfishing.sh"
+if grep -q 'GPTOKEYB' "${STAGE_ROOT}/NETfishing.sh"; then
+ echo "GPTOKEYB must not be enabled for NETfishing." >&2
+ exit 1
+fi
+if unzip -Z1 "${ARCHIVE}" | grep -E \
+ '(^|/)(conf|\.git|\.godot|logs?|saves?|identities?)(/|$)|\.(gd|tscn|blend)$' \
+ >/dev/null; then
+ echo "The PortMaster archive contains prohibited release content." >&2
+ exit 1
+fi
+
+sha256sum "${ARCHIVE}"
+echo "PortMaster package created at ${ARCHIVE}"
diff --git a/scripts/generate_placeholder_animalese.gd b/scripts/generate_placeholder_animalese.gd
new file mode 100644
index 0000000..8119fd3
--- /dev/null
+++ b/scripts/generate_placeholder_animalese.gd
@@ -0,0 +1,71 @@
+extends SceneTree
+
+const OUTPUT_DIRECTORY := "res://sound/dialogue/animalese/placeholder"
+const SAMPLE_RATE: int = 22050
+const SAMPLE_SECONDS: float = 0.055
+const CHARACTERS := "abcdefghijklmnopqrstuvwxyz"
+const VOWELS := "aeiou"
+
+
+func _initialize() -> void:
+ var absolute_directory := ProjectSettings.globalize_path(OUTPUT_DIRECTORY)
+ var directory_error: Error = DirAccess.make_dir_recursive_absolute(
+ absolute_directory
+ )
+ if directory_error != OK:
+ push_error("Could not create animalese sample directory.")
+ quit(1)
+ return
+ for character_index: int in range(CHARACTERS.length()):
+ var character := CHARACTERS.substr(character_index, 1)
+ _save_sample(character, character_index)
+ _save_sample("fallback", CHARACTERS.length())
+ quit()
+
+
+func _save_sample(sample_name: String, sample_index: int) -> void:
+ var stream := _build_sample(sample_name, sample_index)
+ var output_path := "%s/%s" % [OUTPUT_DIRECTORY, sample_name]
+ var save_error: Error = stream.save_to_wav(output_path)
+ if save_error != OK:
+ push_error("Could not save animalese sample: %s" % sample_name)
+
+
+func _build_sample(sample_name: String, sample_index: int) -> AudioStreamWAV:
+ var frame_count := roundi(SAMPLE_RATE * SAMPLE_SECONDS)
+ var pcm := PackedByteArray()
+ pcm.resize(frame_count * 2)
+ var base_frequency := 245.0 + float(sample_index % 7) * 18.0
+ var formant_one := 620.0 + float(sample_index % 5) * 85.0
+ var formant_two := 1320.0 + float(sample_index % 6) * 115.0
+ var is_vowel := VOWELS.contains(sample_name)
+ var noise_seed := sample_index * 7919 + 104729
+ for frame_index: int in range(frame_count):
+ var time := float(frame_index) / SAMPLE_RATE
+ var attack := minf(time / 0.004, 1.0)
+ var release := minf((SAMPLE_SECONDS - time) / 0.014, 1.0)
+ var envelope := maxf(minf(attack, release), 0.0)
+ noise_seed = int(
+ (noise_seed * 1103515245 + 12345) & 0x7fffffff
+ )
+ var noise := float(noise_seed % 65536) / 32767.5 - 1.0
+ var voiced := (
+ sin(TAU * base_frequency * time) * 0.42
+ + sin(TAU * base_frequency * 2.0 * time) * 0.16
+ )
+ var formants := (
+ sin(TAU * formant_one * time) * 0.18
+ + sin(TAU * formant_two * time) * 0.10
+ )
+ var consonant_noise := noise * (0.08 if is_vowel else 0.24)
+ var value := envelope * (voiced + formants + consonant_noise) * 0.72
+ var signed_sample := clampi(roundi(value * 32767.0), -32768, 32767)
+ var encoded_sample := signed_sample if signed_sample >= 0 else signed_sample + 65536
+ pcm[frame_index * 2] = encoded_sample & 0xff
+ pcm[frame_index * 2 + 1] = (encoded_sample >> 8) & 0xff
+ var stream := AudioStreamWAV.new()
+ stream.set("format", AudioStreamWAV.FORMAT_16_BITS)
+ stream.mix_rate = SAMPLE_RATE
+ stream.stereo = false
+ stream.data = pcm
+ return stream
diff --git a/scripts/generate_placeholder_animalese.gd.uid b/scripts/generate_placeholder_animalese.gd.uid
new file mode 100644
index 0000000..9399a64
--- /dev/null
+++ b/scripts/generate_placeholder_animalese.gd.uid
@@ -0,0 +1 @@
+uid://cwqm75icgcr4u
diff --git a/scripts/normalize_texture_imports.gd b/scripts/normalize_texture_imports.gd
new file mode 100644
index 0000000..ae36fa0
--- /dev/null
+++ b/scripts/normalize_texture_imports.gd
@@ -0,0 +1,204 @@
+extends SceneTree
+
+const DEFAULT_PROFILE: Dictionary = {
+ "params": {
+ "compress/mode": 2,
+ "compress/high_quality": false,
+ "compress/lossy_quality": 0.7,
+ "compress/uastc_level": 0,
+ "compress/rdo_quality_loss": 0.0,
+ "compress/hdr_compression": 1,
+ "compress/normal_map": 0,
+ "compress/channel_pack": 0,
+ "mipmaps/generate": false,
+ "mipmaps/limit": -1,
+ "roughness/mode": 0,
+ "roughness/src_normal": "",
+ "process/channel_remap/red": 0,
+ "process/channel_remap/green": 1,
+ "process/channel_remap/blue": 2,
+ "process/channel_remap/alpha": 3,
+ "process/fix_alpha_border": true,
+ "process/premult_alpha": false,
+ "process/normal_map_invert_y": false,
+ "process/hdr_as_srgb": false,
+ "process/hdr_clamp_exposure": false,
+ "process/size_limit": 0,
+ "detect_3d/compress_to": 0,
+ },
+}
+
+var apply_changes: bool = false
+var verbose: bool = false
+var roots: PackedStringArray = PackedStringArray()
+var should_exit: bool = false
+
+var errors: int = 0
+var skipped_non_texture: int = 0
+var updated: int = 0
+var unchanged: int = 0
+
+func _initialize() -> void:
+ _parse_args()
+ if should_exit:
+ return
+ if roots.is_empty():
+ printerr("At least one --root path is required.")
+ quit(2)
+ return
+
+ for root: String in roots:
+ var normalized_root := root
+ if not normalized_root.ends_with("/"):
+ normalized_root += "/"
+ if not normalized_root.begins_with("res://") and not normalized_root.is_absolute_path():
+ if normalized_root.begins_with("./"):
+ normalized_root = "res://%s" % normalized_root.trim_prefix("./")
+ else:
+ normalized_root = "res://%s" % normalized_root
+ _print("Scanning %s" % normalized_root)
+ _scan_directory(normalized_root)
+
+ print_summary()
+ quit(1 if errors > 0 else 0)
+
+
+func _parse_args() -> void:
+ var args := OS.get_cmdline_user_args()
+ var i := 0
+ while i < args.size():
+ var arg := args[i]
+ if arg == "--help" or arg == "-h":
+ _print_usage()
+ should_exit = true
+ quit(0)
+ return
+ elif arg == "--apply":
+ apply_changes = true
+ i += 1
+ elif arg == "--verbose" or arg == "-v":
+ verbose = true
+ i += 1
+ elif arg == "--root":
+ if i + 1 >= args.size():
+ printerr("--root requires a path value")
+ should_exit = true
+ quit(1)
+ return
+ i += 1
+ roots.append(args[i])
+ i += 1
+ elif arg.begins_with("--root="):
+ roots.append(arg.trim_prefix("--root="))
+ i += 1
+ else:
+ printerr("Unknown argument: ", arg)
+ should_exit = true
+ quit(2)
+ return
+
+
+func _scan_directory(path: String) -> void:
+ var dir := DirAccess.open(path)
+ if dir == null:
+ printerr("Failed to open root: ", path)
+ errors += 1
+ return
+
+ dir.list_dir_begin()
+ while true:
+ var name := dir.get_next()
+ if name.is_empty():
+ break
+ if name == "." or name == "..":
+ continue
+ if name == ".godot":
+ continue
+
+ var candidate := path.path_join(name)
+ if dir.current_is_dir():
+ _scan_directory(candidate)
+ continue
+
+ if name.get_extension().to_lower() != "import":
+ continue
+
+ _handle_import_file(candidate)
+
+
+func _handle_import_file(import_path: String) -> void:
+ var cfg := ConfigFile.new()
+ var err := cfg.load(import_path)
+ if err != OK:
+ printerr("Could not load import settings: ", import_path, " (", err, ")")
+ errors += 1
+ return
+
+ if not cfg.has_section("remap") or not cfg.has_section_key("remap", "importer"):
+ skipped_non_texture += 1
+ return
+
+ if str(cfg.get_value("remap", "importer")) != "texture":
+ skipped_non_texture += 1
+ return
+
+ var changed := false
+ for section_name: String in DEFAULT_PROFILE.keys():
+ var section_data: Dictionary = DEFAULT_PROFILE[section_name]
+ for section_key: String in section_data.keys():
+ var desired: Variant = section_data[section_key]
+ var existing: Variant = null
+ if cfg.has_section(section_name) and cfg.has_section_key(section_name, section_key):
+ existing = cfg.get_value(section_name, section_key)
+ if existing != desired:
+ cfg.set_value(section_name, section_key, desired)
+ changed = true
+
+ if not changed:
+ unchanged += 1
+ if verbose:
+ _print("Unchanged: %s" % import_path)
+ return
+
+ if apply_changes:
+ err = cfg.save(import_path)
+ if err != OK:
+ printerr("Could not save import settings: ", import_path, " (", err, ")")
+ errors += 1
+ return
+ if verbose:
+ _print("Updated: %s" % import_path)
+ else:
+ _print("Would update: %s" % import_path)
+
+ updated += 1
+
+
+func print_summary() -> void:
+ if apply_changes:
+ _print(
+ "Texture import normalization complete: "
+ + "updated=%d unchanged=%d skipped_non_texture=%d errors=%d"
+ % [updated, unchanged, skipped_non_texture, errors]
+ )
+ else:
+ _print(
+ "Dry-run complete: "
+ + "would-update=%d unchanged=%d skipped_non_texture=%d errors=%d"
+ % [updated, unchanged, skipped_non_texture, errors]
+ )
+ _print("Pass --apply to write normalized settings into .import files.")
+
+
+func _print(msg: String) -> void:
+ print(msg)
+
+
+func _print_usage() -> void:
+ print("Usage:")
+ print(" godot --headless --path . --script scripts/normalize_texture_imports.gd [--apply] [--verbose] [--root ] [--root=] [--help]")
+ print()
+ print("Defaults:")
+ print("- Dry-run only unless --apply is provided")
+ print("- Requires at least one explicit --root path")
+ print("- Skips .godot")
diff --git a/scripts/normalize_texture_imports.gd.uid b/scripts/normalize_texture_imports.gd.uid
new file mode 100644
index 0000000..d2006e5
--- /dev/null
+++ b/scripts/normalize_texture_imports.gd.uid
@@ -0,0 +1 @@
+uid://b24mlapvqxj5a
diff --git a/scripts/normalize_texture_profiles.sh b/scripts/normalize_texture_profiles.sh
new file mode 100755
index 0000000..1483a61
--- /dev/null
+++ b/scripts/normalize_texture_profiles.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+readonly GODOT_BIN="${GODOT_BIN:-godot}"
+
+if [[ $# -ne 1 ]]; then
+ echo "usage: scripts/normalize_texture_profiles.sh res://path/to/textures" >&2
+ exit 2
+fi
+
+readonly ROOT_PATH="$1"
+
+readonly WORK_ROOT="$(mktemp -d -t netfishing-godot-data.XXXXXX)"
+trap 'rm -rf -- "${WORK_ROOT}"' EXIT
+
+cd "${PROJECT_ROOT}"
+
+export XDG_DATA_HOME="${WORK_ROOT}/data"
+export XDG_CONFIG_HOME="${WORK_ROOT}/config"
+export XDG_CACHE_HOME="${WORK_ROOT}/cache"
+
+"${GODOT_BIN}" --headless --path . \
+ --script scripts/normalize_texture_imports.gd -- --apply --root "${ROOT_PATH}"
+
+# Synchronize Godot-generated remap and destination metadata with the updated
+# import parameters before the caller reviews or commits the sidecars.
+"${GODOT_BIN}" --headless --path . --import
diff --git a/scripts/portmaster/NETfishing.sh b/scripts/portmaster/NETfishing.sh
new file mode 100755
index 0000000..58fbfbd
--- /dev/null
+++ b/scripts/portmaster/NETfishing.sh
@@ -0,0 +1,120 @@
+#!/bin/bash
+# PORTMASTER: netfishing.zip, NETfishing.sh
+
+XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
+
+if [ -d "/opt/system/Tools/PortMaster/" ]; then
+ controlfolder="/opt/system/Tools/PortMaster"
+elif [ -d "/opt/tools/PortMaster/" ]; then
+ controlfolder="/opt/tools/PortMaster"
+elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
+ controlfolder="$XDG_DATA_HOME/PortMaster"
+else
+ controlfolder="/roms/ports/PortMaster"
+fi
+
+if [ ! -f "$controlfolder/control.txt" ]; then
+ echo "PortMaster control.txt was not found at $controlfolder" >&2
+ exit 1
+fi
+
+source "$controlfolder/control.txt"
+[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
+get_controls
+
+GAMEDIR="/${directory}/ports/netfishing"
+if [ ! -d "$GAMEDIR" ] && [ -d "/mnt/mmc/ports/netfishing" ]; then
+ GAMEDIR="/mnt/mmc/ports/netfishing"
+fi
+
+CONFDIR="$GAMEDIR/conf"
+GAME_EXECUTABLE="$GAMEDIR/NETfishing.aarch64"
+WESTON_DIR="/tmp/netfishing-weston"
+WESTON_RUNTIME="weston_pkg_0.2"
+HARBOURMASTER="$controlfolder/harbourmaster"
+if [ ! -x "$HARBOURMASTER" ] && [ -x "/mnt/mmc/MUOS/PortMaster/harbourmaster" ]; then
+ HARBOURMASTER="/mnt/mmc/MUOS/PortMaster/harbourmaster"
+fi
+
+mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR"
+chmod +x "$GAME_EXECUTABLE"
+
+> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
+
+if [ ! -f "$controlfolder/libs/${WESTON_RUNTIME}.squashfs" ]; then
+ if [ ! -x "$HARBOURMASTER" ]; then
+ pm_message "NETfishing requires the latest PortMaster and WestonPack runtime."
+ sleep 5
+ exit 1
+ fi
+ $ESUDO "$HARBOURMASTER" --quiet --no-check runtime_check "${WESTON_RUNTIME}.squashfs"
+fi
+
+if [ ! -f "$controlfolder/libs/${WESTON_RUNTIME}.squashfs" ]; then
+ pm_message "WestonPack could not be installed. Check the network connection and PortMaster runtime manager."
+ sleep 5
+ exit 1
+fi
+
+if [[ "$PM_CAN_MOUNT" != "N" ]]; then
+ $ESUDO umount "$WESTON_DIR" 2>/dev/null
+fi
+$ESUDO mount "$controlfolder/libs/${WESTON_RUNTIME}.squashfs" "$WESTON_DIR"
+
+cd "$GAMEDIR" || exit 1
+
+# Godot 4.7 identifies the muOS H700 virtual controller with a CRC-bearing
+# GUID and numbers its buttons without the volume keys exposed by SDL2. The
+# PortMaster SDL2 database entry therefore cannot drive Godot's gamepad layer.
+GODOT_MUOS_MAPPING="19004ca6010000000100000000010000,muOS-Keys,a:b0,b:b1,x:b3,y:b2,leftshoulder:b4,rightshoulder:b5,lefttrigger:b10,righttrigger:b11,guide:b8,start:b7,back:b6,dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4,leftx:a0,lefty:a1,leftstick:b9,rightx:a2,righty:a3,rightstick:b12,platform:Linux,"
+MUOS_SDL2_GUID="19000000010000000100000000010000"
+if [[ "$sdl_controllerconfig" == "${MUOS_SDL2_GUID},"* ]]; then
+ netfishing_controllerconfig="$GODOT_MUOS_MAPPING"
+elif [ -n "$sdl_controllerconfig" ]; then
+ netfishing_controllerconfig="$sdl_controllerconfig"
+else
+ netfishing_controllerconfig="$GODOT_MUOS_MAPPING"
+fi
+
+NETFISHING_GODOT_OPTIONS=()
+NETFISHING_GAME_ENVIRONMENT=()
+DEVICE_COMPATIBILITY=""
+if [[ -r /proc/device-tree/compatible ]]; then
+ DEVICE_COMPATIBILITY="$(tr '\0' ' ' /dev/null
+fi
+pm_finish
diff --git a/scripts/portmaster/gameinfo.xml b/scripts/portmaster/gameinfo.xml
new file mode 100755
index 0000000..ef608bd
--- /dev/null
+++ b/scripts/portmaster/gameinfo.xml
@@ -0,0 +1,13 @@
+
+
+
+ ./NETfishing.sh
+ NETfishing
+ A relaxed multiplayer fishing game about exploring a small island, catching and collecting fish, customizing your character, and spending time by the water.
+ 20260804T000000
+ NETfishing project
+ Independent
+ adventure, simulation
+ ./netfishing/screenshot.png
+
+
diff --git a/scripts/portmaster/port.json b/scripts/portmaster/port.json
new file mode 100644
index 0000000..98df160
--- /dev/null
+++ b/scripts/portmaster/port.json
@@ -0,0 +1,38 @@
+{
+ "version": 4,
+ "name": "netfishing.zip",
+ "items": [
+ "NETfishing.sh",
+ "netfishing/"
+ ],
+ "items_opt": null,
+ "attr": {
+ "title": "NETfishing",
+ "porter": [
+ "Voyager"
+ ],
+ "desc": "A relaxed multiplayer fishing game about exploring a small island, catching and collecting fish, customizing your character, and spending time by the water.",
+ "desc_md": null,
+ "inst": "Ready to run on supported ARM64 PortMaster devices. This alpha package requires two analog sticks.",
+ "inst_md": null,
+ "genres": [
+ "adventure",
+ "simulation"
+ ],
+ "image": {},
+ "rtr": false,
+ "exp": false,
+ "runtime": [
+ "weston_pkg_0.2.squashfs"
+ ],
+ "store": [],
+ "availability": "full",
+ "reqs": [
+ "analog_2"
+ ],
+ "arch": [
+ "aarch64"
+ ],
+ "min_glibc": "2.28"
+ }
+}
diff --git a/scripts/portmaster/screenshot.png b/scripts/portmaster/screenshot.png
new file mode 100755
index 0000000..4fe8f2e
Binary files /dev/null and b/scripts/portmaster/screenshot.png differ
diff --git a/scripts/portmaster/screenshot.png.import b/scripts/portmaster/screenshot.png.import
new file mode 100644
index 0000000..3aa5f2d
--- /dev/null
+++ b/scripts/portmaster/screenshot.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ww0w8iubiwsf"
+path="res://.godot/imported/screenshot.png-6eed5589b80ced9ab24aed11cadf34f5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://scripts/portmaster/screenshot.png"
+dest_files=["res://.godot/imported/screenshot.png-6eed5589b80ced9ab24aed11cadf34f5.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh
new file mode 100755
index 0000000..7ae8929
--- /dev/null
+++ b/scripts/run_validations.sh
@@ -0,0 +1,235 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+readonly GODOT_BIN="${GODOT_BIN:-godot}"
+readonly TEST_TIMEOUT_SECONDS="${TEST_TIMEOUT_SECONDS:-120}"
+readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
+
+readonly -a QUICK_TESTS=(
+ "tests/android_readiness_validation.gd"
+ "tests/dedicated_server_config_validation.gd"
+ "tests/fish_catalog_content_validation.gd"
+ "tests/fish_quality_validation.gd"
+ "tests/fishing_audio_validation.gd"
+ "tests/fishing_surface_validation.gd"
+ "tests/fur_pattern_validation.gd"
+ "tests/logbook_validation.gd"
+ "tests/network_player_animation_protocol_validation.gd"
+ "tests/player_experience_validation.gd"
+ "tests/shoreline_ambience_validation.gd"
+ "tests/surface_drawing_validation.gd"
+ "tests/terrain_blender_material_validation.gd"
+ "tests/texture_sampling_validation.gd"
+ "tests/world_time_validation.gd"
+ "tests/world_weather_validation.gd"
+)
+
+readonly -a RUNTIME_TESTS=(
+ "tests/inventory_notepad_art_validation.gd"
+ "tests/logbook_runtime_validation.gd"
+ "tests/player_experience_ui_validation.gd"
+ "tests/title_credits_validation.gd"
+ "tests/ui_scaling_runtime_validation.gd"
+)
+
+readonly -a HOST_TESTS=(
+ "tests/art_tools_validation.gd"
+ "tests/dedicated_host_session_validation.gd"
+ "tests/economy_regression_validation.gd"
+ "tests/fish_hotbar_showcase_validation.gd"
+ "tests/fishing_authority_validation.gd"
+ "tests/job_system_validation.gd"
+ "tests/surface_drawing_runtime_validation.gd"
+)
+
+readonly -a NETWORK_TESTS=(
+ "tests/economy_regression_validation.gd"
+ "tests/fish_showcase_multiplayer_validation.gd"
+ "tests/fishing_multiplayer_validation.gd"
+ "tests/job_multiplayer_validation.gd"
+ "tests/movement_multiplayer_validation.gd"
+ "tests/operator_multiplayer_validation.gd"
+ "tests/surface_drawing_multiplayer_validation.gd"
+ "tests/world_time_multiplayer_validation.gd"
+)
+
+cleanup() {
+ rm -rf -- "${RUN_ROOT}"
+}
+trap cleanup EXIT INT TERM
+
+usage() {
+ printf 'Usage: %s {quick|full|host|network|all|--list}\n' "$0"
+}
+
+prepare_root() {
+ local name="$1"
+ local root="${RUN_ROOT}/${name}"
+ mkdir -p -- "${root}/data" "${root}/config" "${root}/cache"
+ printf '%s\n' "${root}"
+}
+
+run_test() {
+ local script="$1"
+ local name="${script#tests/}"
+ name="${name%.gd}"
+ local root
+ root="$(prepare_root "${name}")"
+ printf '\n==> %s\n' "${script}"
+ XDG_DATA_HOME="${root}/data" \
+ XDG_CONFIG_HOME="${root}/config" \
+ XDG_CACHE_HOME="${root}/cache" \
+ timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \
+ --headless --path "${PROJECT_ROOT}" --script "${script}"
+}
+
+
+wait_for_network_host() {
+ local script="$1"
+ local host_pid="$2"
+ local test_port="7777"
+ local declared_port
+ declared_port="$(
+ sed -n -E \
+ 's/^const TEST_PORT(: int)? = ([0-9]+)$/\2/p' \
+ "${PROJECT_ROOT}/${script}"
+ )"
+ if [[ -n "${declared_port}" ]]; then
+ test_port="${declared_port}"
+ fi
+ if ! command -v ss >/dev/null 2>&1; then
+ sleep 2
+ return 0
+ fi
+ for _attempt in {1..100}; do
+ if ! kill -0 "${host_pid}" 2>/dev/null; then
+ return 1
+ fi
+ if ss -H -lun "sport = :${test_port}" | grep -q .; then
+ return 0
+ fi
+ sleep 0.1
+ done
+ return 1
+}
+
+
+run_network_test() {
+ local script="$1"
+ local name="${script#tests/}"
+ name="${name%.gd}"
+ local host_root client_root host_pid host_status client_status
+ host_root="$(prepare_root "${name}-host")"
+ client_root="$(prepare_root "${name}-client")"
+ printf '\n==> %s (host + client)\n' "${script}"
+ XDG_DATA_HOME="${host_root}/data" \
+ XDG_CONFIG_HOME="${host_root}/config" \
+ XDG_CACHE_HOME="${host_root}/cache" \
+ timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \
+ --headless --path "${PROJECT_ROOT}" --script "${script}" -- host \
+ >"${host_root}/output.log" 2>&1 &
+ host_pid=$!
+ if ! wait_for_network_host "${script}" "${host_pid}"; then
+ set +e
+ wait "${host_pid}"
+ host_status=$?
+ set -e
+ printf '%s\n' '-- host output --'
+ sed -n '1,240p' "${host_root}/output.log"
+ printf 'error: host did not become ready (exit %d)\n' \
+ "${host_status}" >&2
+ return 1
+ fi
+ set +e
+ XDG_DATA_HOME="${client_root}/data" \
+ XDG_CONFIG_HOME="${client_root}/config" \
+ XDG_CACHE_HOME="${client_root}/cache" \
+ timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \
+ --headless --path "${PROJECT_ROOT}" --script "${script}" -- client \
+ >"${client_root}/output.log" 2>&1
+ client_status=$?
+ wait "${host_pid}"
+ host_status=$?
+ set -e
+ printf '%s\n' '-- host output --'
+ sed -n '1,240p' "${host_root}/output.log"
+ printf '%s\n' '-- client output --'
+ sed -n '1,240p' "${client_root}/output.log"
+ if ((host_status != 0 || client_status != 0)); then
+ printf 'error: host exited %d; client exited %d\n' \
+ "${host_status}" "${client_status}" >&2
+ return 1
+ fi
+}
+
+run_quick() {
+ local test_script
+ for test_script in "${QUICK_TESTS[@]}"; do
+ run_test "${test_script}"
+ done
+}
+
+run_full() {
+ local test_script
+ run_quick
+ for test_script in "${RUNTIME_TESTS[@]}"; do
+ run_test "${test_script}"
+ done
+}
+
+run_host() {
+ local test_script
+ for test_script in "${HOST_TESTS[@]}"; do
+ run_test "${test_script}"
+ done
+}
+
+run_network() {
+ local test_script
+ for test_script in "${NETWORK_TESTS[@]}"; do
+ run_network_test "${test_script}"
+ done
+}
+
+list_tests() {
+ printf '%s\n' 'Quick tests:'
+ printf ' %s\n' "${QUICK_TESTS[@]}"
+ printf '%s\n' 'Additional full-suite tests:'
+ printf ' %s\n' "${RUNTIME_TESTS[@]}"
+ printf '%s\n' 'Single-process tests requiring a local UDP bind:'
+ printf ' %s\n' "${HOST_TESTS[@]}"
+ printf '%s\n' 'Paired network tests:'
+ printf ' %s\n' "${NETWORK_TESTS[@]}"
+}
+
+case "${1:-}" in
+ quick)
+ run_quick
+ ;;
+ full)
+ run_full
+ ;;
+ host)
+ run_host
+ ;;
+ network)
+ run_network
+ ;;
+ all)
+ run_full
+ run_host
+ run_network
+ ;;
+ --list)
+ list_tests
+ ;;
+ *)
+ usage >&2
+ exit 2
+ ;;
+esac
+
+printf '\nAll requested validations passed.\n'
diff --git a/scripts/setup_android_build.sh b/scripts/setup_android_build.sh
new file mode 100755
index 0000000..c1cebdc
--- /dev/null
+++ b/scripts/setup_android_build.sh
@@ -0,0 +1,394 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ANDROID_HOME="${ANDROID_HOME:-${HOME}/Android/Sdk}"
+JAVA_HOME="${JAVA_HOME:-}"
+INSTALL=0
+AUTO=0
+CMDLINE_TOOLS_ZIP="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
+SDK_VERSION="33"
+BUILDTOOLS_VERSION="34.0.0"
+
+usage() {
+ cat <&2
+ usage
+ exit 2 ;;
+ esac
+done
+
+PROJECT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
+GODOT="${GODOT_BIN:-godot}"
+PROJECT_VERSION="$(
+ sed -n 's/^config\/version="\([^"]*\)"/\1/p' "${PROJECT_ROOT}/project.godot"
+)"
+MISSING=0
+
+print_status() { printf '%s %s\n' "[$1]" "$2"; }
+
+check_dir() {
+ local path="$1" label="$2"
+ if [[ -d "$path" ]]; then
+ print_status OK "${label}: ${path}"
+ else
+ print_status ERR "Missing ${label}: ${path}"
+ MISSING=$((MISSING+1))
+ fi
+}
+
+check_file() {
+ local path="$1" label="$2"
+ if [[ -x "$path" ]]; then
+ print_status OK "${label}: ${path}"
+ else
+ print_status ERR "Missing ${label}: ${path}"
+ MISSING=$((MISSING+1))
+ fi
+}
+
+resolve_java_home() {
+ local candidate=""
+ if [[ -n "$JAVA_HOME" && -x "$JAVA_HOME/bin/java" ]]; then
+ return 0
+ fi
+
+ if command -v java >/dev/null 2>&1; then
+ candidate="$(readlink -f "$(command -v java)")"
+ candidate="$(dirname "$(dirname "$candidate")")"
+ if [[ -x "$candidate/bin/java" ]]; then
+ JAVA_HOME="$candidate"
+ return 0
+ fi
+ fi
+
+ # Common Ubuntu/Debian/JDK locations
+ for p in \
+ /usr/lib/jvm/default-java \
+ /usr/lib/jvm/java-17-openjdk-amd64 \
+ /usr/lib/jvm/java-17-openjdk \
+ /usr/lib/jvm/jdk-17* \
+ /usr/lib/jvm/temurin-17*;
+ do
+ if [[ -e $p && -x "$p/bin/java" ]]; then
+ JAVA_HOME="$p"
+ return 0
+ fi
+ done
+ return 1
+}
+
+check_java() {
+ if resolve_java_home && [[ -x "$JAVA_HOME/bin/java" ]]; then
+ print_status OK "Java binary: $JAVA_HOME/bin/java"
+ check_file "$JAVA_HOME/bin/javac" "javac binary"
+ check_file "$JAVA_HOME/bin/keytool" "keytool binary"
+ "$JAVA_HOME/bin/java" -version 2>&1 | sed -n '1,2p'
+ local java_ver
+ java_ver=$("$JAVA_HOME/bin/java" -version 2>&1 | sed -n '1p')
+ if [[ "$java_ver" =~ \"([0-9]+)\\. ]]; then
+ local java_major="${BASH_REMATCH[1]}"
+ if [[ "$java_major" != "17" ]]; then
+ print_status WARN "Detected Java $java_major. Godot Android export is commonly validated with JDK 17."
+ fi
+ fi
+ else
+ print_status ERR "Java not found. Set JAVA_HOME to a JDK 17+ home or install one."
+ MISSING=$((MISSING+1))
+ JAVA_HOME=""
+ fi
+}
+
+find_sdkmanager() {
+ if [[ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]]; then
+ echo "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
+ return
+ fi
+ if [[ -x "$ANDROID_HOME/cmdline-tools/bin/sdkmanager" ]]; then
+ echo "$ANDROID_HOME/cmdline-tools/bin/sdkmanager"; return
+ fi
+ if [[ -x "$ANDROID_HOME/tools/bin/sdkmanager" ]]; then
+ echo "$ANDROID_HOME/tools/bin/sdkmanager"; return
+ fi
+ if command -v sdkmanager >/dev/null 2>&1; then
+ command -v sdkmanager
+ return
+ fi
+ echo ""
+}
+
+sdkmanager_path="$(find_sdkmanager)"
+TMP_CMDLINE_TOOLS_DIR=""
+
+cleanup_tmpdir() {
+ [[ -n "${TMP_CMDLINE_TOOLS_DIR:-}" && -d "$TMP_CMDLINE_TOOLS_DIR" ]] && rm -rf "$TMP_CMDLINE_TOOLS_DIR"
+ TMP_CMDLINE_TOOLS_DIR=""
+}
+
+check_sdk_paths() {
+ print_status INFO "ANDROID_HOME: ${ANDROID_HOME}"
+ check_dir "$ANDROID_HOME" "Android SDK root"
+ check_dir "$ANDROID_HOME/platform-tools" "platform-tools"
+ check_file "$ANDROID_HOME/platform-tools/adb" "adb binary"
+
+ if [[ -d "$ANDROID_HOME/build-tools/$BUILDTOOLS_VERSION" ]]; then
+ print_status OK "build-tools ${BUILDTOOLS_VERSION}: present"
+ check_file \
+ "$ANDROID_HOME/build-tools/$BUILDTOOLS_VERSION/apksigner" \
+ "apksigner"
+ else
+ print_status ERR "Missing build-tools version: ${BUILDTOOLS_VERSION}"
+ MISSING=$((MISSING+1))
+ fi
+
+ if [[ -d "$ANDROID_HOME/platforms" ]]; then
+ if ls "$ANDROID_HOME/platforms/android-${SDK_VERSION}" >/dev/null 2>&1; then
+ print_status OK "platform ${SDK_VERSION}: present"
+ else
+ print_status ERR "Missing Android platform: android-${SDK_VERSION}"
+ MISSING=$((MISSING+1))
+ fi
+ else
+ print_status ERR "Missing platforms directory: $ANDROID_HOME/platforms"
+ MISSING=$((MISSING+1))
+ fi
+
+ if [[ -x "$sdkmanager_path" ]]; then
+ print_status OK "sdkmanager: ${sdkmanager_path}"
+ else
+ print_status ERR "sdkmanager not found in SDK or PATH"
+ MISSING=$((MISSING+1))
+ fi
+}
+
+install_jdk_hint() {
+ local pm=0
+ local -a elevate=()
+ echo "Attempting automatic JDK install..."
+ if command -v apt-get >/dev/null 2>&1; then
+ pm="apt-get"
+ elif command -v dnf >/dev/null 2>&1; then
+ pm="dnf"
+ elif command -v pacman >/dev/null 2>&1; then
+ pm="pacman"
+ else
+ echo "No supported package manager found (apt-get/dnf/pacman)."
+ echo "Install JDK 17 manually, then rerun with --java-home set."
+ return 1
+ fi
+
+ if [[ $EUID -ne 0 ]]; then
+ if ! command -v sudo >/dev/null 2>&1; then
+ echo "sudo not found; please run with root privileges for package install."
+ return 1
+ fi
+ elevate=(sudo)
+ fi
+
+ if [[ "$pm" == "apt-get" ]]; then
+ echo "apt-get install -y openjdk-17-jdk"
+ if [[ $AUTO -eq 1 ]]; then
+ "${elevate[@]}" apt-get update
+ "${elevate[@]}" apt-get install -y openjdk-17-jdk
+ else
+ echo "Run as manual step first: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk"
+ return 1
+ fi
+ elif [[ "$pm" == "dnf" ]]; then
+ echo "dnf install -y java-17-openjdk-devel"
+ if [[ $AUTO -eq 1 ]]; then
+ "${elevate[@]}" dnf install -y java-17-openjdk-devel
+ else
+ return 1
+ fi
+ else
+ echo "pacman -S --noconfirm jdk17-openjdk"
+ if [[ $AUTO -eq 1 ]]; then
+ "${elevate[@]}" pacman -S --noconfirm jdk17-openjdk
+ else
+ return 1
+ fi
+ fi
+}
+
+install_cmdline_tools() {
+ if [[ -x "$sdkmanager_path" ]]; then
+ return 0
+ fi
+
+ echo "Downloading Android cmdline-tools to bootstrap sdkmanager..."
+ TMP_CMDLINE_TOOLS_DIR=""
+ TMP_CMDLINE_TOOLS_DIR="$(mktemp -d)"
+ if [[ -z "$TMP_CMDLINE_TOOLS_DIR" ]]; then
+ echo "Failed to create temporary directory."
+ return 1
+ fi
+ trap cleanup_tmpdir RETURN
+
+ if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
+ echo "curl/wget required to download cmdline-tools. Install one and rerun."
+ return 1
+ fi
+
+ local zipfile="${TMP_CMDLINE_TOOLS_DIR}/cmdline-tools.zip"
+ if command -v curl >/dev/null 2>&1; then
+ if ! curl -fL "$CMDLINE_TOOLS_ZIP" -o "$zipfile"; then
+ echo "Failed to download cmdline-tools from $CMDLINE_TOOLS_ZIP"
+ return 1
+ fi
+ else
+ if ! wget -q "$CMDLINE_TOOLS_ZIP" -O "$zipfile"; then
+ echo "Failed to download cmdline-tools from $CMDLINE_TOOLS_ZIP"
+ return 1
+ fi
+ fi
+
+ if [[ ! -s "$zipfile" ]]; then
+ echo "Downloaded cmdline-tools archive is empty."
+ return 1
+ fi
+ unzip -q "$zipfile" -d "$TMP_CMDLINE_TOOLS_DIR"
+ if [[ ! -d "$TMP_CMDLINE_TOOLS_DIR/cmdline-tools" ]]; then
+ echo "Downloaded archive did not contain expected cmdline-tools payload."
+ return 1
+ fi
+ mkdir -p "$ANDROID_HOME" || {
+ echo "Cannot create Android SDK root: $ANDROID_HOME"
+ return 1
+ }
+ mkdir -p "$ANDROID_HOME/cmdline-tools"
+ rm -rf "$ANDROID_HOME/cmdline-tools/latest"
+ mkdir -p "$ANDROID_HOME/cmdline-tools/latest"
+ mv "$TMP_CMDLINE_TOOLS_DIR/cmdline-tools"/* "$ANDROID_HOME/cmdline-tools/latest/"
+ echo "Installed SDK cmdline-tools to $ANDROID_HOME/cmdline-tools/latest"
+ sdkmanager_path="$(find_sdkmanager)"
+ return 0
+}
+
+install_sdk_components() {
+ if [[ -z "$sdkmanager_path" ]]; then
+ echo "Cannot install SDK packages without sdkmanager."
+ return 1
+ fi
+ "$sdkmanager_path" --sdk_root="${ANDROID_HOME}" --list | sed -n '1,40p'
+ echo "Installing required SDK components: platform-tools, platform-android-${SDK_VERSION}, build-tools-${BUILDTOOLS_VERSION}"
+ if [[ $AUTO -eq 1 ]]; then
+ yes | "$sdkmanager_path" --sdk_root="${ANDROID_HOME}" --install "platform-tools" "platforms;android-${SDK_VERSION}" "build-tools;${BUILDTOOLS_VERSION}"
+ else
+ echo "Run this manually after accepting licenses:"
+ echo " yes | \"${sdkmanager_path}\" --sdk_root=\"${ANDROID_HOME}\" --install \"platform-tools\" \"platforms;android-${SDK_VERSION}\" \"build-tools;${BUILDTOOLS_VERSION}\""
+ return 1
+ fi
+}
+
+check_editor_settings() {
+ local editor_settings="$HOME/.config/godot/editor_settings-4.7.tres"
+ print_status INFO "Checking editor settings: $editor_settings"
+ if [[ -f "$editor_settings" ]]; then
+ if rg -n 'export/android/android_sdk_path|export/android/java_sdk_path' "$editor_settings" >/dev/null 2>&1; then
+ rg -n 'export/android/android_sdk_path|export/android/java_sdk_path' "$editor_settings"
+ else
+ print_status WARN "Editor export settings entries are missing for Android in this file"
+ echo " Set in editor: Export > Presets > Android:"
+ echo " android_sdk_path = ${ANDROID_HOME}"
+ echo " java_sdk_path = ${JAVA_HOME}"
+ fi
+ else
+ print_status WARN "No editor settings file found at expected path: $editor_settings"
+ fi
+}
+
+run_export_hint() {
+ echo
+ echo "Example export commands:"
+ echo " ${GODOT} --headless --path \"$PROJECT_ROOT\" --export-release \"Android\" builds/${PROJECT_VERSION}/android/NETfishing.apk"
+ echo " ${GODOT} --headless --path \"$PROJECT_ROOT\" --export-debug \"Android\" builds/android/NETfishing-debug.apk"
+}
+
+print_status INFO "Starting Android dependency preflight..."
+check_java
+check_sdk_paths
+check_editor_settings
+
+if [[ $MISSING -eq 0 ]]; then
+ print_status OK "Dependency check passed."
+ run_export_hint
+ exit 0
+fi
+
+if [[ $INSTALL -eq 1 ]]; then
+ echo
+ print_status INFO "Attempting dependency install path..."
+ if [[ \
+ -z "$JAVA_HOME" \
+ || ! -x "$JAVA_HOME/bin/java" \
+ || ! -x "$JAVA_HOME/bin/javac" \
+ || ! -x "$JAVA_HOME/bin/keytool" \
+ ]]; then
+ install_jdk_hint || true
+ fi
+
+ resolve_java_home || true
+ check_java
+
+ install_cmdline_tools || true
+ sdkmanager_path="$(find_sdkmanager)"
+ install_sdk_components || true
+
+ MISSING=0
+ check_java
+ check_sdk_paths
+ if [[ $MISSING -eq 0 ]]; then
+ echo
+ print_status OK "Dependencies fixed."
+ run_export_hint
+ exit 0
+ fi
+fi
+
+print_status ERR "Dependency check failed with ${MISSING} issue(s)."
+cat < void:
+ var root_to_create: String = OS.get_environment("NETFISHING_TEST_CREATE_ROOT")
+ if not root_to_create.is_empty():
+ _create_validation_root(root_to_create)
+ return
+ var failures: PackedStringArray = []
+ _expect(
+ "external temporary directory",
+ "/tmp/NETfishing-test-data",
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ )
+ _expect(
+ "external trailing slash",
+ "/tmp/NETfishing-test-data/",
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ )
+ _expect(
+ "similar project-name sibling",
+ "/tmp/netfishing-data",
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ )
+ _expect(
+ "documents directory",
+ "/home/player/Documents/NETfishing",
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ )
+ _expect("source project", PROJECT_PATH, PlayerDataRoot.PATH_SOURCE_PROJECT, failures)
+ _expect(
+ "source project child",
+ PROJECT_PATH.path_join("player-data"),
+ PlayerDataRoot.PATH_SOURCE_PROJECT,
+ failures,
+ )
+ _expect(
+ "installation directory",
+ INSTALL_PATH,
+ PlayerDataRoot.PATH_INSTALLATION,
+ failures,
+ )
+ _expect(
+ "installation child",
+ INSTALL_PATH.path_join("portable-data"),
+ PlayerDataRoot.PATH_INSTALLATION,
+ failures,
+ )
+ _expect_with_references(
+ "empty project reference",
+ "/tmp/NETfishing-test-data",
+ "",
+ INSTALL_PATH,
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ )
+ _expect_with_references(
+ "root project reference",
+ "/tmp/NETfishing-test-data",
+ "/",
+ INSTALL_PATH,
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ )
+ _expect_with_references(
+ "Windows component and case normalization",
+ "C:\\Games\\NETfishing Data",
+ "C:\\Games\\NETfishing",
+ "C:\\Program Files\\NETfishing",
+ PlayerDataRoot.PATH_ALLOWED,
+ failures,
+ true,
+ )
+ if failures.is_empty():
+ print("PlayerDataRoot path validation: PASS")
+ quit(0)
+ return
+ for failure: String in failures:
+ push_error(failure)
+ quit(1)
+
+
+func _create_validation_root(path: String) -> void:
+ var data_root := PlayerDataRoot.new()
+ get_root().add_child(data_root)
+ if data_root.select_new_root(path):
+ print("PlayerDataRoot validation root created: ", data_root.root_path)
+ quit(0)
+ return
+ push_error(data_root.error_message)
+ quit(1)
+
+
+func _expect(
+ label: String,
+ candidate: String,
+ expected: StringName,
+ failures: PackedStringArray,
+) -> void:
+ _expect_with_references(
+ label,
+ candidate,
+ PROJECT_PATH,
+ INSTALL_PATH,
+ expected,
+ failures,
+ )
+
+
+func _expect_with_references(
+ label: String,
+ candidate: String,
+ project: String,
+ installation: String,
+ expected: StringName,
+ failures: PackedStringArray,
+ case_insensitive: bool = false,
+) -> void:
+ var actual: StringName = PlayerDataRoot.classify_candidate_path(
+ candidate, project, installation, case_insensitive
+ )
+ if actual != expected:
+ failures.append("%s: expected %s, got %s" % [label, expected, actual])
diff --git a/scripts/test_player_data_root_paths.gd.uid b/scripts/test_player_data_root_paths.gd.uid
new file mode 100644
index 0000000..90f8e0b
--- /dev/null
+++ b/scripts/test_player_data_root_paths.gd.uid
@@ -0,0 +1 @@
+uid://e8glhlm205au
diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd
new file mode 100644
index 0000000..0618466
--- /dev/null
+++ b/server/dedicated_server_config.gd
@@ -0,0 +1,217 @@
+class_name DedicatedServerConfig
+extends RefCounted
+
+const DEFAULT_NAME: String = "NETfishing Dedicated Server"
+const DEFAULT_BIND_ADDRESS: String = "*"
+const DEFAULT_PORT: int = 7777
+const DEFAULT_MAX_PLAYERS: int = 8
+const MAX_OPERATORS: int = 64
+
+var server_name: String = DEFAULT_NAME
+var bind_address: String = DEFAULT_BIND_ADDRESS
+var port: int = DEFAULT_PORT
+var max_players: int = DEFAULT_MAX_PLAYERS
+var public_listing: bool = false
+var discovery_url: String = ""
+var data_directory: String = ""
+var operator_fingerprints: PackedStringArray = PackedStringArray()
+var error_message: String = ""
+
+
+static func from_runtime() -> DedicatedServerConfig:
+ var result := DedicatedServerConfig.new()
+ result.data_directory = ProjectSettings.globalize_path(
+ "user://dedicated-server-data"
+ )
+ result.discovery_url = str(ProjectSettings.get_setting(
+ "network/discovery/base_url", ""
+ )).strip_edges()
+ var config_path: String = OS.get_environment(
+ "NETFISHING_SERVER_CONFIG"
+ ).strip_edges()
+ for argument: String in OS.get_cmdline_user_args():
+ if argument.begins_with("--config="):
+ config_path = argument.trim_prefix("--config=").strip_edges()
+ if not config_path.is_empty() and not result._load_file(config_path):
+ return result
+ result._apply_environment()
+ result._apply_arguments(OS.get_cmdline_user_args())
+ result._validate()
+ return result
+
+
+func is_valid() -> bool:
+ return error_message.is_empty()
+
+
+func _load_file(path: String) -> bool:
+ var file := ConfigFile.new()
+ var error: Error = file.load(path)
+ if error != OK:
+ error_message = "Could not load server configuration: %s" % path
+ return false
+ server_name = str(file.get_value("server", "name", server_name))
+ bind_address = str(file.get_value(
+ "server", "bind_address", bind_address
+ ))
+ port = int(file.get_value("server", "port", port))
+ max_players = int(file.get_value(
+ "server", "max_players", max_players
+ ))
+ public_listing = bool(file.get_value(
+ "server", "public", public_listing
+ ))
+ data_directory = str(file.get_value(
+ "server", "data_directory", data_directory
+ ))
+ discovery_url = str(file.get_value(
+ "discovery", "url", discovery_url
+ ))
+ operator_fingerprints = _parse_fingerprint_list(file.get_value(
+ "moderation", "operators", operator_fingerprints
+ ))
+ return true
+
+
+func _apply_environment() -> void:
+ server_name = _environment_string(
+ "NETFISHING_SERVER_NAME", server_name
+ )
+ bind_address = _environment_string(
+ "NETFISHING_SERVER_BIND", bind_address
+ )
+ port = _environment_int("NETFISHING_SERVER_PORT", port)
+ max_players = _environment_int(
+ "NETFISHING_SERVER_MAX_PLAYERS", max_players
+ )
+ public_listing = _environment_bool(
+ "NETFISHING_SERVER_PUBLIC", public_listing
+ )
+ discovery_url = _environment_string(
+ "NETFISHING_DISCOVERY_URL", discovery_url
+ )
+ data_directory = _environment_string(
+ "NETFISHING_DATA_DIR", data_directory
+ )
+ if OS.has_environment("NETFISHING_SERVER_OPERATORS"):
+ operator_fingerprints = _parse_fingerprint_list(
+ OS.get_environment("NETFISHING_SERVER_OPERATORS")
+ )
+
+
+func _apply_arguments(arguments: PackedStringArray) -> void:
+ for argument: String in arguments:
+ if argument.begins_with("--name="):
+ server_name = argument.trim_prefix("--name=")
+ elif argument.begins_with("--bind="):
+ bind_address = argument.trim_prefix("--bind=")
+ elif argument.begins_with("--port="):
+ port = _parse_int(argument.trim_prefix("--port="), port)
+ elif argument.begins_with("--max-players="):
+ max_players = _parse_int(
+ argument.trim_prefix("--max-players="), max_players
+ )
+ elif argument.begins_with("--data-dir="):
+ data_directory = argument.trim_prefix("--data-dir=")
+ elif argument.begins_with("--discovery-url="):
+ discovery_url = argument.trim_prefix("--discovery-url=")
+ elif argument.begins_with("--operators="):
+ operator_fingerprints = _parse_fingerprint_list(
+ argument.trim_prefix("--operators=")
+ )
+ elif argument == "--public":
+ public_listing = true
+ elif argument == "--private":
+ public_listing = false
+
+
+func _validate() -> void:
+ server_name = server_name.strip_edges().left(48)
+ bind_address = bind_address.strip_edges()
+ discovery_url = discovery_url.strip_edges().trim_suffix("/")
+ data_directory = data_directory.strip_edges()
+ operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
+ if server_name.is_empty():
+ error_message = "Server name cannot be empty."
+ elif not _safe_text(server_name):
+ error_message = "Server name contains unsupported characters."
+ elif bind_address.is_empty():
+ error_message = "Bind address cannot be empty."
+ elif port < 1 or port > 65535:
+ error_message = "Server port must be between 1 and 65535."
+ elif max_players < 1 or max_players > 128:
+ error_message = "Maximum players must be between 1 and 128."
+ elif not data_directory.is_empty() and not data_directory.is_absolute_path():
+ error_message = "Server data directory must be an absolute path."
+ elif public_listing and not (
+ discovery_url.begins_with("https://")
+ or discovery_url.begins_with("http://")
+ ):
+ error_message = "A public server requires a discovery URL."
+ elif operator_fingerprints.size() > MAX_OPERATORS:
+ error_message = "A server may configure at most %d operators." % (
+ MAX_OPERATORS
+ )
+ else:
+ for fingerprint: String in operator_fingerprints:
+ if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
+ error_message = (
+ "Server operator fingerprints must be 64 lowercase hex characters."
+ )
+ break
+
+
+static func _safe_text(value: String) -> bool:
+ for index: int in value.length():
+ var codepoint: int = value.unicode_at(index)
+ if codepoint < 32 or codepoint == 127:
+ return false
+ return true
+
+
+static func _environment_string(name: String, fallback: String) -> String:
+ return OS.get_environment(name) if OS.has_environment(name) else fallback
+
+
+static func _environment_int(name: String, fallback: int) -> int:
+ return (
+ _parse_int(OS.get_environment(name), fallback)
+ if OS.has_environment(name) else fallback
+ )
+
+
+static func _environment_bool(name: String, fallback: bool) -> bool:
+ if not OS.has_environment(name):
+ return fallback
+ var value: String = OS.get_environment(name).strip_edges().to_lower()
+ return value in ["1", "true", "yes", "on"]
+
+
+static func _parse_int(value: String, fallback: int) -> int:
+ return int(value) if value.strip_edges().is_valid_int() else fallback
+
+
+static func _parse_fingerprint_list(value: Variant) -> PackedStringArray:
+ var values: PackedStringArray = PackedStringArray()
+ if typeof(value) == TYPE_STRING:
+ var text: String = str(value)
+ for separator: String in [";", "\n", "\r", "\t", " "]:
+ text = text.replace(separator, ",")
+ values = text.split(",", false)
+ elif typeof(value) in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]:
+ for entry: Variant in value:
+ if typeof(entry) in [TYPE_STRING, TYPE_STRING_NAME]:
+ values.append(str(entry))
+ return _normalized_fingerprints(values)
+
+
+static func _normalized_fingerprints(
+ values: PackedStringArray,
+) -> PackedStringArray:
+ var result: PackedStringArray = PackedStringArray()
+ for value: String in values:
+ var fingerprint: String = value.strip_edges().to_lower()
+ if not fingerprint.is_empty() and fingerprint not in result:
+ result.append(fingerprint)
+ result.sort()
+ return result
diff --git a/server/dedicated_server_config.gd.uid b/server/dedicated_server_config.gd.uid
new file mode 100644
index 0000000..720cccc
--- /dev/null
+++ b/server/dedicated_server_config.gd.uid
@@ -0,0 +1 @@
+uid://nmg477qkcbhe
diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd
new file mode 100644
index 0000000..1690963
--- /dev/null
+++ b/settings/controller_mapping_manager.gd
@@ -0,0 +1,986 @@
+class_name ControllerMappingManager
+extends Node
+
+signal active_profile_changed
+signal active_controller_changed(controller_name: String)
+signal controller_input_observed(event: InputEvent)
+
+const FORMAT_VERSION: int = 1
+const PROFILE_PATH: String = "user://controller_mappings.json"
+const PROFILE_TEMP_PATH: String = "user://controller_mappings.json.tmp"
+const PROFILE_BACKUP_PATH: String = "user://controller_mappings.json.backup"
+const MAX_PROFILE_BYTES: int = 1024 * 1024
+const CAPTURE_AXIS_THRESHOLD: float = 0.55
+const CAPTURE_AXIS_RELEASE_THRESHOLD: float = 0.30
+const ACTIVE_DEVICE_AXIS_THRESHOLD: float = 0.35
+const MUOS_MAPPING_REVISION: String = "muos-v4"
+const MUOS_CONTROLLER_NAMES: Array[String] = [
+ "muOS-Keys",
+ "Deeplay-keys",
+]
+const MUOS_MAPPING_BINDINGS: String = (
+ "a:b0,b:b1,x:b3,y:b2,"
+ + "leftshoulder:b4,rightshoulder:b5,"
+ + "lefttrigger:b10,righttrigger:b11,"
+ + "guide:b8,start:b7,back:b6,"
+ + "dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4,"
+ + "leftx:a0,lefty:a1,leftstick:b9,"
+ + "rightx:a2,righty:a3,rightstick:b12,platform:Linux,"
+)
+const ROLE_A: StringName = &"a"
+const ROLE_B: StringName = &"b"
+const ROLE_X: StringName = &"x"
+const ROLE_Y: StringName = &"y"
+const ROLE_LB: StringName = &"lb"
+const ROLE_RB: StringName = &"rb"
+# Keep the serialized keys for profile compatibility, but name the roles after
+# their game actions. A handheld may expose either action as a button, a
+# dedicated trigger axis, or one half of a shared axis.
+const ROLE_POINTER_MODIFIER: StringName = &"lt"
+const ROLE_CAMERA_ZOOM: StringName = &"rt"
+const ROLE_LT: StringName = ROLE_POINTER_MODIFIER
+const ROLE_RT: StringName = ROLE_CAMERA_ZOOM
+const ROLE_SELECT: StringName = &"select"
+const ROLE_START: StringName = &"start"
+const ROLE_LEFT_STICK_CLICK: StringName = &"left_stick_click"
+const ROLE_RIGHT_STICK_CLICK: StringName = &"right_stick_click"
+const ROLE_DPAD_UP: StringName = &"dpad_up"
+const ROLE_DPAD_DOWN: StringName = &"dpad_down"
+const ROLE_DPAD_LEFT: StringName = &"dpad_left"
+const ROLE_DPAD_RIGHT: StringName = &"dpad_right"
+const ROLE_LEFT_STICK_X: StringName = &"left_stick_x"
+const ROLE_LEFT_STICK_Y: StringName = &"left_stick_y"
+const ROLE_RIGHT_STICK_X: StringName = &"right_stick_x"
+const ROLE_RIGHT_STICK_Y: StringName = &"right_stick_y"
+
+const ROLE_ORDER: Array[StringName] = [
+ ROLE_A,
+ ROLE_B,
+ ROLE_X,
+ ROLE_Y,
+ ROLE_LB,
+ ROLE_RB,
+ ROLE_POINTER_MODIFIER,
+ ROLE_CAMERA_ZOOM,
+ ROLE_SELECT,
+ ROLE_START,
+ ROLE_LEFT_STICK_CLICK,
+ ROLE_RIGHT_STICK_CLICK,
+ ROLE_DPAD_UP,
+ ROLE_DPAD_DOWN,
+ ROLE_DPAD_LEFT,
+ ROLE_DPAD_RIGHT,
+ ROLE_LEFT_STICK_X,
+ ROLE_LEFT_STICK_Y,
+ ROLE_RIGHT_STICK_X,
+ ROLE_RIGHT_STICK_Y,
+]
+
+const ROLE_LABELS: Dictionary = {
+ ROLE_A: "jump / menu accept",
+ ROLE_B: "menu back",
+ ROLE_X: "player menu",
+ ROLE_Y: "interact",
+ ROLE_LB: "focus chat or world",
+ ROLE_RB: "primary action",
+ ROLE_POINTER_MODIFIER: "virtual mouse modifier",
+ ROLE_CAMERA_ZOOM: "camera zoom",
+ ROLE_SELECT: "chat",
+ ROLE_START: "pause",
+ ROLE_LEFT_STICK_CLICK: "sprint",
+ ROLE_RIGHT_STICK_CLICK: "unassigned stick click",
+ ROLE_DPAD_UP: "emote wheel",
+ ROLE_DPAD_DOWN: "quick menu",
+ ROLE_DPAD_LEFT: "previous hotbar slot",
+ ROLE_DPAD_RIGHT: "next hotbar slot",
+ ROLE_LEFT_STICK_X: "move left / right",
+ ROLE_LEFT_STICK_Y: "move up / down",
+ ROLE_RIGHT_STICK_X: "camera left / right",
+ ROLE_RIGHT_STICK_Y: "camera up / down",
+}
+
+const ROLE_PROMPTS: Dictionary = {
+ ROLE_A: "press the a button",
+ ROLE_B: "press the b button",
+ ROLE_X: "press the x button",
+ ROLE_Y: "press the y button",
+ ROLE_LB: "press the left bumper",
+ ROLE_RB: "press the right bumper",
+ ROLE_POINTER_MODIFIER: "press or squeeze the virtual mouse modifier",
+ ROLE_CAMERA_ZOOM: "press or squeeze the camera zoom control",
+ ROLE_SELECT: "press select / back",
+ ROLE_START: "press start",
+ ROLE_LEFT_STICK_CLICK: "click the left stick",
+ ROLE_RIGHT_STICK_CLICK: "click the right stick",
+ ROLE_DPAD_UP: "press d-pad up",
+ ROLE_DPAD_DOWN: "press d-pad down",
+ ROLE_DPAD_LEFT: "press d-pad left",
+ ROLE_DPAD_RIGHT: "press d-pad right",
+ ROLE_LEFT_STICK_X: "move the left stick left",
+ ROLE_LEFT_STICK_Y: "move the left stick up",
+ ROLE_RIGHT_STICK_X: "move the right stick left",
+ ROLE_RIGHT_STICK_Y: "move the right stick up",
+}
+
+const STICK_AXIS_ROLES: Array[StringName] = [
+ ROLE_LEFT_STICK_X,
+ ROLE_LEFT_STICK_Y,
+ ROLE_RIGHT_STICK_X,
+ ROLE_RIGHT_STICK_Y,
+]
+const TRIGGER_ROLES: Array[StringName] = [
+ ROLE_POINTER_MODIFIER,
+ ROLE_CAMERA_ZOOM,
+]
+
+const BUTTON_ACTION_ROLES: Dictionary = {
+ &"jump": ROLE_A,
+ &"ui_accept": ROLE_A,
+ &"ui_cancel": ROLE_B,
+ &"open_backpack": ROLE_X,
+ &"interact": ROLE_Y,
+ &"focus_gameplay": ROLE_LB,
+ &"fish_primary": ROLE_RB,
+ &"open_chat": ROLE_SELECT,
+ &"open_system_menu": ROLE_START,
+ &"sprint": ROLE_LEFT_STICK_CLICK,
+ &"open_emotes": ROLE_DPAD_UP,
+ &"open_quick_actions": ROLE_DPAD_DOWN,
+ &"hotbar_previous": ROLE_DPAD_LEFT,
+ &"hotbar_next": ROLE_DPAD_RIGHT,
+}
+
+const AXIS_ACTION_ROLES: Dictionary = {
+ &"move_left": [ROLE_LEFT_STICK_X, -1.0],
+ &"move_right": [ROLE_LEFT_STICK_X, 1.0],
+ &"move_forward": [ROLE_LEFT_STICK_Y, -1.0],
+ &"move_backward": [ROLE_LEFT_STICK_Y, 1.0],
+ &"ui_left": [ROLE_LEFT_STICK_X, -1.0],
+ &"ui_right": [ROLE_LEFT_STICK_X, 1.0],
+ &"ui_up": [ROLE_LEFT_STICK_Y, -1.0],
+ &"ui_down": [ROLE_LEFT_STICK_Y, 1.0],
+}
+
+const UI_DPAD_ACTION_ROLES: Dictionary = {
+ &"ui_up": ROLE_DPAD_UP,
+ &"ui_down": ROLE_DPAD_DOWN,
+ &"ui_left": ROLE_DPAD_LEFT,
+ &"ui_right": ROLE_DPAD_RIGHT,
+}
+
+var _profiles: Dictionary = {}
+var _active_device_id: int = 0
+var _active_profile_key: String = "default"
+var _active_controller_name: String = "controller"
+var _default_joy_events: Dictionary = {}
+var _axis_rest_by_device: Dictionary = {}
+var _installed_compatibility_guids: Dictionary = {}
+
+
+func _ready() -> void:
+ if not Input.joy_connection_changed.is_connected(
+ _on_joy_connection_changed
+ ):
+ Input.joy_connection_changed.connect(_on_joy_connection_changed)
+ _install_known_controller_mappings()
+ for device_id: int in Input.get_connected_joypads():
+ _sample_axis_rest_values(device_id)
+ _capture_project_defaults()
+ load_profiles()
+ _refresh_active_controller()
+
+
+func _input(event: InputEvent) -> void:
+ var device_id: int = -1
+ var button_event := event as InputEventJoypadButton
+ if button_event != null:
+ device_id = button_event.device
+ var motion_event := event as InputEventJoypadMotion
+ if motion_event != null:
+ device_id = motion_event.device
+ var claims_active_device: bool = (
+ button_event != null and button_event.pressed
+ )
+ if motion_event != null:
+ claims_active_device = _axis_motion_claims_device(motion_event)
+ if (
+ claims_active_device
+ and device_id >= 0
+ and device_id != _active_device_id
+ ):
+ _set_active_controller(device_id)
+ if button_event != null or motion_event != null:
+ controller_input_observed.emit(event)
+
+
+func _process(_delta: float) -> void:
+ var connected: Array[int] = Input.get_connected_joypads()
+ if connected.is_empty():
+ connected.append(0)
+ if not connected.has(_active_device_id):
+ _set_active_controller(connected[0])
+ elif (
+ _active_controller_name == "controller"
+ and Input.get_connected_joypads().has(_active_device_id)
+ ):
+ _set_active_controller(_active_device_id)
+ _sample_axis_rest_values(_active_device_id)
+
+
+func load_profiles() -> bool:
+ _recover_interrupted_write()
+ if not FileAccess.file_exists(PROFILE_PATH):
+ _profiles = {}
+ return true
+ var file := FileAccess.open(PROFILE_PATH, FileAccess.READ)
+ if file == null:
+ return false
+ if file.get_length() > MAX_PROFILE_BYTES:
+ file.close()
+ push_warning("Controller mappings are too large; using defaults.")
+ _profiles = {}
+ return false
+ var json := JSON.new()
+ var error: Error = json.parse(file.get_as_text())
+ file.close()
+ if error != OK or typeof(json.data) != TYPE_DICTIONARY:
+ push_warning("Controller mappings are malformed; using defaults.")
+ _profiles = {}
+ return false
+ var data: Dictionary = json.data
+ if int(data.get("format_version", -1)) != FORMAT_VERSION:
+ push_warning("Controller mapping version is unsupported; using defaults.")
+ _profiles = {}
+ return false
+ var raw_profiles: Variant = data.get("profiles", {})
+ if typeof(raw_profiles) != TYPE_DICTIONARY:
+ _profiles = {}
+ return false
+ _profiles = _validated_profiles(raw_profiles as Dictionary)
+ return true
+
+
+func _validated_profiles(raw_profiles: Dictionary) -> Dictionary:
+ var validated: Dictionary = {}
+ for key_value: Variant in raw_profiles:
+ var raw_profile: Variant = raw_profiles[key_value]
+ if typeof(raw_profile) != TYPE_DICTIONARY:
+ continue
+ var profile := raw_profile as Dictionary
+ var raw_bindings: Variant = profile.get("bindings", {})
+ if typeof(raw_bindings) != TYPE_DICTIONARY:
+ continue
+ var bindings := raw_bindings as Dictionary
+ var complete: bool = true
+ for role: StringName in ROLE_ORDER:
+ var raw_binding: Variant = bindings.get(str(role), {})
+ if (
+ typeof(raw_binding) != TYPE_DICTIONARY
+ or not validate_binding(role, raw_binding as Dictionary)
+ ):
+ complete = false
+ break
+ if not complete:
+ continue
+ validated[str(key_value)] = {
+ "controller_name": str(
+ profile.get("controller_name", "controller")
+ ),
+ "bindings": bindings.duplicate(true),
+ }
+ return validated
+
+
+func has_custom_mapping() -> bool:
+ return _profiles.has(_active_profile_key)
+
+
+func get_active_device_id() -> int:
+ return _active_device_id
+
+
+func get_active_controller_name() -> String:
+ return _active_controller_name
+
+
+func get_active_controller_guid() -> String:
+ if not Input.get_connected_joypads().has(_active_device_id):
+ return ""
+ return Input.get_joy_guid(_active_device_id).strip_edges()
+
+
+func get_active_profile_key() -> String:
+ return _active_profile_key
+
+
+func get_active_controller_platform() -> String:
+ return OS.get_name()
+
+
+func recalibrate_active_device() -> void:
+ _recalibrate_device(_active_device_id)
+
+
+func get_role_label(role: StringName) -> String:
+ return str(ROLE_LABELS.get(role, str(role)))
+
+
+func get_role_prompt(role: StringName) -> String:
+ return str(ROLE_PROMPTS.get(role, "press or move the requested input"))
+
+
+func role_expects_axis(role: StringName) -> bool:
+ return role in STICK_AXIS_ROLES
+
+
+func role_accepts_axis(role: StringName) -> bool:
+ return role in STICK_AXIS_ROLES or role in TRIGGER_ROLES
+
+
+func are_capture_inputs_neutral(device_id: int = -1) -> bool:
+ var target_device: int = (
+ _active_device_id if device_id < 0 else device_id
+ )
+ for button_index: int in JOY_BUTTON_MAX:
+ if Input.is_joy_button_pressed(target_device, button_index):
+ return false
+ for axis_index: int in JOY_AXIS_MAX:
+ var rest: float = _axis_rest_value(target_device, axis_index)
+ var current: float = Input.get_joy_axis(target_device, axis_index)
+ if absf(current - rest) > CAPTURE_AXIS_RELEASE_THRESHOLD:
+ return false
+ return true
+
+
+func get_pressed_capture_button(device_id: int = -1) -> int:
+ var target_device: int = (
+ _active_device_id if device_id < 0 else device_id
+ )
+ for button_index: int in JOY_BUTTON_MAX:
+ if Input.is_joy_button_pressed(target_device, button_index):
+ return button_index
+ return -1
+
+
+func get_active_bindings() -> Dictionary:
+ var raw_profile: Variant = _profiles.get(_active_profile_key, {})
+ var profile: Dictionary = (
+ raw_profile as Dictionary
+ if typeof(raw_profile) == TYPE_DICTIONARY else {}
+ )
+ var bindings: Variant = profile.get("bindings", {})
+ if (
+ typeof(bindings) == TYPE_DICTIONARY
+ and not (bindings as Dictionary).is_empty()
+ ):
+ return (bindings as Dictionary).duplicate(true)
+ return default_bindings()
+
+
+func get_binding(role: StringName) -> Dictionary:
+ var bindings: Dictionary = get_active_bindings()
+ var binding: Variant = bindings.get(str(role), {})
+ if typeof(binding) == TYPE_DICTIONARY:
+ return (binding as Dictionary).duplicate(true)
+ return {}
+
+
+func set_binding(role: StringName, binding: Dictionary) -> bool:
+ if role not in ROLE_ORDER or not validate_binding(role, binding):
+ return false
+ var bindings: Dictionary = get_active_bindings()
+ bindings[str(role)] = binding.duplicate(true)
+ return replace_active_bindings(bindings)
+
+
+func replace_active_bindings(bindings: Dictionary) -> bool:
+ for role: StringName in ROLE_ORDER:
+ var binding: Variant = bindings.get(str(role), {})
+ if typeof(binding) != TYPE_DICTIONARY:
+ return false
+ if not validate_binding(role, binding as Dictionary):
+ return false
+ var previous: Dictionary = _profiles.duplicate(true)
+ _profiles[_active_profile_key] = {
+ "controller_name": _active_controller_name,
+ "bindings": bindings.duplicate(true),
+ }
+ if not _save_profiles():
+ _profiles = previous
+ return false
+ _apply_active_profile()
+ active_profile_changed.emit()
+ return true
+
+
+func reset_active_profile() -> bool:
+ if not _profiles.has(_active_profile_key):
+ _restore_project_defaults()
+ active_profile_changed.emit()
+ return true
+ var previous: Dictionary = _profiles.duplicate(true)
+ _profiles.erase(_active_profile_key)
+ if not _save_profiles():
+ _profiles = previous
+ return false
+ _restore_project_defaults()
+ active_profile_changed.emit()
+ return true
+
+
+func binding_from_event(role: StringName, event: InputEvent) -> Dictionary:
+ var button := event as InputEventJoypadButton
+ if role in TRIGGER_ROLES and button != null and button.pressed:
+ return {
+ "kind": "button",
+ "button": int(button.button_index),
+ }
+ if role_accepts_axis(role):
+ var motion := event as InputEventJoypadMotion
+ if motion == null:
+ return {}
+ _sample_axis_rest_values(motion.device)
+ var rest: float = _axis_rest_value(motion.device, motion.axis)
+ var travel: float = motion.axis_value - rest
+ if absf(travel) < CAPTURE_AXIS_THRESHOLD:
+ return {}
+ return {
+ "kind": "axis",
+ "axis": int(motion.axis),
+ "direction": signf(travel),
+ "rest": rest,
+ }
+ if button == null or not button.pressed:
+ return {}
+ return {
+ "kind": "button",
+ "button": int(button.button_index),
+ }
+
+
+func validate_binding(role: StringName, binding: Dictionary) -> bool:
+ var binding_kind: String = str(binding.get("kind", ""))
+ if role_expects_axis(role) and binding_kind != "axis":
+ return false
+ if role not in STICK_AXIS_ROLES and role not in TRIGGER_ROLES:
+ if binding_kind != "button":
+ return false
+ if role in TRIGGER_ROLES and binding_kind not in ["axis", "button"]:
+ return false
+ if binding_kind == "button":
+ var button_index: int = int(binding.get("button", -1))
+ return button_index >= 0 and button_index < JOY_BUTTON_MAX
+ var axis_index: int = int(binding.get("axis", -1))
+ var direction: float = float(binding.get("direction", 0.0))
+ var rest: float = float(binding.get("rest", 0.0))
+ return (
+ axis_index >= 0
+ and axis_index < JOY_AXIS_MAX
+ and not is_zero_approx(direction)
+ and rest >= -1.0
+ and rest <= 1.0
+ )
+
+
+func binding_label(binding: Dictionary) -> String:
+ if str(binding.get("kind", "")) == "button":
+ return _button_label(int(binding.get("button", -1)))
+ if str(binding.get("kind", "")) == "axis":
+ return "%s %s" % [
+ _axis_label(int(binding.get("axis", -1))),
+ "+" if float(binding.get("direction", 0.0)) > 0.0 else "−",
+ ]
+ return "unmapped"
+
+
+static func _button_label(button_index: int) -> String:
+ match button_index:
+ JOY_BUTTON_A:
+ return "A button"
+ JOY_BUTTON_B:
+ return "B button"
+ JOY_BUTTON_X:
+ return "X button"
+ JOY_BUTTON_Y:
+ return "Y button"
+ JOY_BUTTON_BACK:
+ return "back / select"
+ JOY_BUTTON_GUIDE:
+ return "guide button"
+ JOY_BUTTON_START:
+ return "start button"
+ JOY_BUTTON_LEFT_STICK:
+ return "left stick click"
+ JOY_BUTTON_RIGHT_STICK:
+ return "right stick click"
+ JOY_BUTTON_LEFT_SHOULDER:
+ return "left shoulder"
+ JOY_BUTTON_RIGHT_SHOULDER:
+ return "right shoulder"
+ JOY_BUTTON_DPAD_UP:
+ return "d-pad up"
+ JOY_BUTTON_DPAD_DOWN:
+ return "d-pad down"
+ JOY_BUTTON_DPAD_LEFT:
+ return "d-pad left"
+ JOY_BUTTON_DPAD_RIGHT:
+ return "d-pad right"
+ _:
+ return "button %d" % button_index
+
+
+static func _axis_label(axis_index: int) -> String:
+ match axis_index:
+ JOY_AXIS_LEFT_X:
+ return "left stick X"
+ JOY_AXIS_LEFT_Y:
+ return "left stick Y"
+ JOY_AXIS_RIGHT_X:
+ return "right stick X"
+ JOY_AXIS_RIGHT_Y:
+ return "right stick Y"
+ JOY_AXIS_TRIGGER_LEFT:
+ return "left trigger axis"
+ JOY_AXIS_TRIGGER_RIGHT:
+ return "right trigger axis"
+ _:
+ return "axis %d" % axis_index
+
+
+func get_role_strength(role: StringName) -> float:
+ var binding: Dictionary = get_binding(role)
+ if str(binding.get("kind", "")) == "button":
+ return (
+ 1.0
+ if Input.is_joy_button_pressed(
+ _active_device_id,
+ int(binding.get("button", -1)),
+ )
+ else 0.0
+ )
+ return _axis_binding_strength(binding)
+
+
+func get_role_axis(role: StringName) -> float:
+ var binding: Dictionary = get_binding(role)
+ if str(binding.get("kind", "")) != "axis":
+ return 0.0
+ var raw: float = Input.get_joy_axis(
+ _active_device_id,
+ int(binding.get("axis", -1)),
+ )
+ var direction: float = signf(float(binding.get("direction", -1.0)))
+ var rest: float = float(binding.get("rest", 0.0))
+ var delta: float = raw - rest
+ if is_zero_approx(delta):
+ return 0.0
+ var toward_captured_direction: bool = signf(delta) == direction
+ var endpoint: float = direction if toward_captured_direction else -direction
+ var available: float = absf(endpoint - rest)
+ if available <= 0.001:
+ return 0.0
+ var magnitude: float = clampf(absf(delta) / available, 0.0, 1.0)
+ return -magnitude if toward_captured_direction else magnitude
+
+
+func event_matches_role(event: InputEvent, role: StringName) -> bool:
+ if not _event_belongs_to_active_device(event):
+ return false
+ var binding: Dictionary = get_binding(role)
+ var button := event as InputEventJoypadButton
+ if button != null and str(binding.get("kind", "")) == "button":
+ return button.button_index == int(binding.get("button", -1))
+ var motion := event as InputEventJoypadMotion
+ if motion == null or str(binding.get("kind", "")) != "axis":
+ return false
+ if motion.axis != int(binding.get("axis", -1)):
+ return false
+ return _axis_binding_strength(binding, motion.axis_value) >= 0.5
+
+
+func event_uses_role(event: InputEvent, role: StringName) -> bool:
+ if not _event_belongs_to_active_device(event):
+ return false
+ var binding: Dictionary = get_binding(role)
+ var button := event as InputEventJoypadButton
+ if button != null and str(binding.get("kind", "")) == "button":
+ return button.button_index == int(binding.get("button", -1))
+ var motion := event as InputEventJoypadMotion
+ return (
+ motion != null
+ and str(binding.get("kind", "")) == "axis"
+ and motion.axis == int(binding.get("axis", -1))
+ )
+
+
+static func default_bindings() -> Dictionary:
+ return {
+ str(ROLE_A): _button_binding(JOY_BUTTON_A),
+ str(ROLE_B): _button_binding(JOY_BUTTON_B),
+ str(ROLE_X): _button_binding(JOY_BUTTON_X),
+ str(ROLE_Y): _button_binding(JOY_BUTTON_Y),
+ str(ROLE_LB): _button_binding(JOY_BUTTON_LEFT_SHOULDER),
+ str(ROLE_RB): _button_binding(JOY_BUTTON_RIGHT_SHOULDER),
+ str(ROLE_POINTER_MODIFIER): _axis_binding(
+ JOY_AXIS_TRIGGER_RIGHT, 1.0, 0.0
+ ),
+ str(ROLE_CAMERA_ZOOM): _axis_binding(
+ JOY_AXIS_TRIGGER_LEFT, 1.0, 0.0
+ ),
+ str(ROLE_SELECT): _button_binding(JOY_BUTTON_BACK),
+ str(ROLE_START): _button_binding(JOY_BUTTON_START),
+ str(ROLE_LEFT_STICK_CLICK): _button_binding(JOY_BUTTON_LEFT_STICK),
+ str(ROLE_RIGHT_STICK_CLICK): _button_binding(JOY_BUTTON_RIGHT_STICK),
+ str(ROLE_DPAD_UP): _button_binding(JOY_BUTTON_DPAD_UP),
+ str(ROLE_DPAD_DOWN): _button_binding(JOY_BUTTON_DPAD_DOWN),
+ str(ROLE_DPAD_LEFT): _button_binding(JOY_BUTTON_DPAD_LEFT),
+ str(ROLE_DPAD_RIGHT): _button_binding(JOY_BUTTON_DPAD_RIGHT),
+ str(ROLE_LEFT_STICK_X): _axis_binding(JOY_AXIS_LEFT_X, -1.0, 0.0),
+ str(ROLE_LEFT_STICK_Y): _axis_binding(JOY_AXIS_LEFT_Y, -1.0, 0.0),
+ str(ROLE_RIGHT_STICK_X): _axis_binding(JOY_AXIS_RIGHT_X, -1.0, 0.0),
+ str(ROLE_RIGHT_STICK_Y): _axis_binding(JOY_AXIS_RIGHT_Y, -1.0, 0.0),
+ }
+
+
+static func _button_binding(button: JoyButton) -> Dictionary:
+ return {"kind": "button", "button": int(button)}
+
+
+static func _axis_binding(
+ axis: JoyAxis,
+ direction: float,
+ rest: float,
+) -> Dictionary:
+ return {
+ "kind": "axis",
+ "axis": int(axis),
+ "direction": direction,
+ "rest": rest,
+ }
+
+
+func _axis_binding_strength(
+ binding: Dictionary,
+ raw_override: float = INF,
+) -> float:
+ var axis: int = int(binding.get("axis", -1))
+ if axis < 0:
+ return 0.0
+ var raw: float = (
+ Input.get_joy_axis(_active_device_id, axis)
+ if is_inf(raw_override) else raw_override
+ )
+ var rest: float = float(binding.get("rest", 0.0))
+ var direction: float = signf(float(binding.get("direction", 0.0)))
+ var endpoint: float = direction
+ var available: float = absf(endpoint - rest)
+ if available <= 0.001:
+ return 0.0
+ return clampf((raw - rest) * direction / available, 0.0, 1.0)
+
+
+func _refresh_active_controller() -> void:
+ var connected: Array[int] = Input.get_connected_joypads()
+ if connected.has(_active_device_id):
+ _set_active_controller(_active_device_id)
+ return
+ _set_active_controller(connected[0] if not connected.is_empty() else 0)
+
+
+func _on_joy_connection_changed(device_id: int, connected: bool) -> void:
+ _axis_rest_by_device.erase(str(device_id))
+ if connected:
+ _install_known_controller_mapping(device_id)
+ call_deferred("_recalibrate_device", device_id)
+ _refresh_active_controller()
+
+
+func _install_known_controller_mappings() -> void:
+ for device_id: int in Input.get_connected_joypads():
+ _install_known_controller_mapping(device_id)
+
+
+func _install_known_controller_mapping(device_id: int) -> bool:
+ var controller_name: String = Input.get_joy_name(device_id).strip_edges()
+ if not should_install_muos_compatibility_mapping(
+ controller_name,
+ Input.is_joy_known(device_id),
+ ):
+ return false
+ var guid: String = Input.get_joy_guid(device_id).strip_edges()
+ if guid.is_empty():
+ return false
+ if _installed_compatibility_guids.has(guid):
+ return true
+ _installed_compatibility_guids[guid] = true
+ Input.add_joy_mapping(
+ build_muos_controller_mapping(guid, controller_name),
+ true,
+ )
+ return true
+
+
+static func build_muos_controller_mapping(
+ guid: String,
+ controller_name: String,
+) -> String:
+ return "%s,%s,%s" % [
+ guid.strip_edges(),
+ controller_name.strip_edges(),
+ MUOS_MAPPING_BINDINGS,
+ ]
+
+
+static func is_muos_controller_name(controller_name: String) -> bool:
+ return controller_name.strip_edges() in MUOS_CONTROLLER_NAMES
+
+
+static func should_install_muos_compatibility_mapping(
+ controller_name: String,
+ is_known: bool,
+) -> bool:
+ # Never update an already-recognized virtual controller at runtime. Godot
+ # can stop delivering its standardized input events after that replacement.
+ return is_muos_controller_name(controller_name) and not is_known
+
+
+func _set_active_controller(device_id: int) -> void:
+ var previous_device_id: int = _active_device_id
+ var previous_profile_key: String = _active_profile_key
+ var previous_controller_name: String = _active_controller_name
+ _active_device_id = maxi(device_id, 0)
+ var controller_is_listed: bool = Input.get_connected_joypads().has(
+ _active_device_id
+ )
+ var guid: String = (
+ Input.get_joy_guid(_active_device_id).strip_edges()
+ if controller_is_listed else ""
+ )
+ var controller_name: String = (
+ Input.get_joy_name(_active_device_id).strip_edges()
+ if controller_is_listed else "controller"
+ )
+ if controller_name.is_empty():
+ controller_name = "controller"
+ _active_controller_name = controller_name
+ if guid.is_empty():
+ _active_profile_key = "name:" + controller_name
+ elif is_muos_controller_name(controller_name):
+ _active_profile_key = guid + ":" + MUOS_MAPPING_REVISION
+ else:
+ _active_profile_key = guid
+ if (
+ previous_profile_key == "name:controller"
+ and _active_profile_key != previous_profile_key
+ and _profiles.has(previous_profile_key)
+ and not _profiles.has(_active_profile_key)
+ ):
+ var previous_profile: Variant = _profiles[previous_profile_key]
+ if typeof(previous_profile) == TYPE_DICTIONARY:
+ _profiles[_active_profile_key] = (
+ previous_profile as Dictionary
+ ).duplicate(true)
+ _profiles.erase(previous_profile_key)
+ _save_profiles()
+ _sample_axis_rest_values(_active_device_id)
+ _apply_active_profile()
+ if (
+ previous_device_id != _active_device_id
+ or previous_profile_key != _active_profile_key
+ or previous_controller_name != _active_controller_name
+ ):
+ active_controller_changed.emit(_active_controller_name)
+
+
+func _axis_motion_claims_device(event: InputEventJoypadMotion) -> bool:
+ var device_key: String = str(event.device)
+ if not _axis_rest_by_device.has(device_key):
+ _sample_axis_rest_values(event.device)
+ return false
+ var rest: float = _axis_rest_value(event.device, event.axis)
+ return absf(event.axis_value - rest) >= ACTIVE_DEVICE_AXIS_THRESHOLD
+
+
+func _event_belongs_to_active_device(event: InputEvent) -> bool:
+ var button := event as InputEventJoypadButton
+ if button != null:
+ return button.device == _active_device_id
+ var motion := event as InputEventJoypadMotion
+ return motion != null and motion.device == _active_device_id
+
+
+func _sample_axis_rest_values(device_id: int) -> void:
+ var key: String = str(device_id)
+ if _axis_rest_by_device.has(key):
+ return
+ var values: Dictionary = {}
+ for axis: int in JOY_AXIS_MAX:
+ values[str(axis)] = Input.get_joy_axis(device_id, axis)
+ _axis_rest_by_device[key] = values
+
+
+func _recalibrate_device(device_id: int) -> void:
+ _axis_rest_by_device.erase(str(device_id))
+ var connected: Array[int] = Input.get_connected_joypads()
+ if not connected.has(device_id) and not (
+ connected.is_empty() and device_id == 0
+ ):
+ return
+ _sample_axis_rest_values(device_id)
+
+
+func _axis_rest_value(device_id: int, axis: int) -> float:
+ _sample_axis_rest_values(device_id)
+ var values: Dictionary = _axis_rest_by_device.get(str(device_id), {})
+ return float(values.get(str(axis), 0.0))
+
+
+func _capture_project_defaults() -> void:
+ for action: StringName in _all_managed_actions():
+ var joy_events: Array[InputEvent] = []
+ for event: InputEvent in InputMap.action_get_events(action):
+ if event is InputEventJoypadButton or event is InputEventJoypadMotion:
+ joy_events.append(event.duplicate())
+ _default_joy_events[action] = joy_events
+
+
+func _all_managed_actions() -> Array[StringName]:
+ var result: Array[StringName] = []
+ for source: Dictionary in [
+ BUTTON_ACTION_ROLES,
+ AXIS_ACTION_ROLES,
+ UI_DPAD_ACTION_ROLES,
+ ]:
+ for action_value: Variant in source.keys():
+ var action: StringName = StringName(action_value)
+ if action not in result:
+ result.append(action)
+ return result
+
+
+func _remove_managed_joy_events() -> void:
+ for action: StringName in _all_managed_actions():
+ if not InputMap.has_action(action):
+ continue
+ for event: InputEvent in InputMap.action_get_events(action):
+ if event is InputEventJoypadButton or event is InputEventJoypadMotion:
+ InputMap.action_erase_event(action, event)
+
+
+func _restore_project_defaults() -> void:
+ _remove_managed_joy_events()
+ for action_value: Variant in _default_joy_events:
+ var action: StringName = StringName(action_value)
+ for event: InputEvent in _default_joy_events[action]:
+ var active_event: InputEvent = event.duplicate()
+ active_event.device = _active_device_id
+ InputMap.action_add_event(action, active_event)
+
+
+func _apply_active_profile() -> void:
+ if not has_custom_mapping():
+ _restore_project_defaults()
+ return
+ _remove_managed_joy_events()
+ var bindings: Dictionary = get_active_bindings()
+ for action_value: Variant in BUTTON_ACTION_ROLES:
+ var action: StringName = StringName(action_value)
+ _add_button_action_binding(
+ action,
+ bindings.get(str(BUTTON_ACTION_ROLES[action]), {}),
+ )
+ for action_value: Variant in AXIS_ACTION_ROLES:
+ var action: StringName = StringName(action_value)
+ var role_data: Array = AXIS_ACTION_ROLES[action]
+ _add_axis_action_binding(
+ action,
+ bindings.get(str(role_data[0]), {}),
+ float(role_data[1]),
+ )
+ for action_value: Variant in UI_DPAD_ACTION_ROLES:
+ var action: StringName = StringName(action_value)
+ _add_button_action_binding(
+ action,
+ bindings.get(str(UI_DPAD_ACTION_ROLES[action]), {}),
+ )
+
+
+func _add_button_action_binding(action: StringName, value: Variant) -> void:
+ if not InputMap.has_action(action) or typeof(value) != TYPE_DICTIONARY:
+ return
+ var binding: Dictionary = value as Dictionary
+ if str(binding.get("kind", "")) != "button":
+ return
+ var event := InputEventJoypadButton.new()
+ event.device = _active_device_id
+ event.button_index = int(binding.get("button", -1)) as JoyButton
+ InputMap.action_add_event(action, event)
+
+
+func _add_axis_action_binding(
+ action: StringName,
+ value: Variant,
+ logical_direction: float,
+) -> void:
+ if not InputMap.has_action(action) or typeof(value) != TYPE_DICTIONARY:
+ return
+ var binding: Dictionary = value as Dictionary
+ if str(binding.get("kind", "")) != "axis":
+ return
+ var captured_negative_direction: float = signf(
+ float(binding.get("direction", -1.0))
+ )
+ var event := InputEventJoypadMotion.new()
+ event.device = _active_device_id
+ event.axis = int(binding.get("axis", -1)) as JoyAxis
+ event.axis_value = logical_direction * -captured_negative_direction
+ InputMap.action_add_event(action, event)
+
+
+func _save_profiles() -> bool:
+ var file := FileAccess.open(PROFILE_TEMP_PATH, FileAccess.WRITE)
+ if file == null:
+ return false
+ file.store_string(JSON.stringify({
+ "format_version": FORMAT_VERSION,
+ "profiles": _profiles,
+ }, "\t"))
+ file.flush()
+ var write_error: Error = file.get_error()
+ file.close()
+ if write_error != OK:
+ _remove_if_present(PROFILE_TEMP_PATH)
+ return false
+ _remove_if_present(PROFILE_BACKUP_PATH)
+ var had_primary: bool = FileAccess.file_exists(PROFILE_PATH)
+ if had_primary and not _rename_file(PROFILE_PATH, PROFILE_BACKUP_PATH):
+ _remove_if_present(PROFILE_TEMP_PATH)
+ return false
+ if not _rename_file(PROFILE_TEMP_PATH, PROFILE_PATH):
+ if had_primary:
+ _rename_file(PROFILE_BACKUP_PATH, PROFILE_PATH)
+ return false
+ _remove_if_present(PROFILE_BACKUP_PATH)
+ return true
+
+
+func _recover_interrupted_write() -> void:
+ if FileAccess.file_exists(PROFILE_PATH):
+ _remove_if_present(PROFILE_TEMP_PATH)
+ _remove_if_present(PROFILE_BACKUP_PATH)
+ return
+ if FileAccess.file_exists(PROFILE_BACKUP_PATH):
+ _rename_file(PROFILE_BACKUP_PATH, PROFILE_PATH)
+ _remove_if_present(PROFILE_TEMP_PATH)
+
+
+func _rename_file(from_path: String, to_path: String) -> bool:
+ return DirAccess.rename_absolute(
+ ProjectSettings.globalize_path(from_path),
+ ProjectSettings.globalize_path(to_path),
+ ) == OK
+
+
+func _remove_if_present(path: String) -> void:
+ if FileAccess.file_exists(path):
+ DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
diff --git a/settings/controller_mapping_manager.gd.uid b/settings/controller_mapping_manager.gd.uid
new file mode 100644
index 0000000..3fcbc13
--- /dev/null
+++ b/settings/controller_mapping_manager.gd.uid
@@ -0,0 +1 @@
+uid://c80x8jkdnx0xy
diff --git a/settings/player_settings.gd b/settings/player_settings.gd
index 3c5581a..a20a3e1 100644
--- a/settings/player_settings.gd
+++ b/settings/player_settings.gd
@@ -7,12 +7,41 @@ const MIN_MOUSE_SENSITIVITY: float = 0.001
const MAX_MOUSE_SENSITIVITY: float = 0.012
const MIN_CONTROLLER_SENSITIVITY: float = 0.5
const MAX_CONTROLLER_SENSITIVITY: float = 5.0
+const MIN_AUDIO_VOLUME: float = 0.0
+const MAX_AUDIO_VOLUME: float = 1.0
+const MIN_WORLD_PIXEL_SIZE: int = 1
+const MAX_WORLD_PIXEL_SIZE: int = 5
+const DEFAULT_WORLD_PIXEL_SIZE: int = 3
+const MIN_UI_PIXEL_SIZE: int = 1
+const MAX_UI_PIXEL_SIZE: int = 5
+const DEFAULT_UI_PIXEL_SIZE: int = 3
+const MAX_CHAT_DRAFT_CHARACTERS: int = 256
+const COMPACT_DISPLAY_HEIGHT: int = 560
+const WORLD_DESKTOP_GRID_HEIGHTS: Array[int] = [0, 540, 360, 216, 96]
+const WORLD_COMPACT_GRID_HEIGHTS: Array[int] = [0, 360, 240, 144, 72]
+const UI_DESKTOP_RENDER_HEIGHTS: Array[int] = [0, 612, 504, 396, 288]
+const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192]
@export var auto_click_enabled: bool = false
+# Legacy settings-file field. Tuffy is now always used at runtime.
+@export var use_readable_interface_font: bool = true
@export_range(0.10, 0.50, 0.01) var auto_click_interval: float = 0.20
@export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005
@export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5
@export var invert_camera_y: bool = false
+@export var on_screen_keyboard_enabled: bool = false
+@export var chat_draft: String = ""
+@export var chat_collapsed: bool = false
+@export var chat_dock_right: bool = false
+@export var chat_mobile_mode: bool = false
+@export var paint_dock_right: bool = true
+@export var fullscreen_enabled: bool = false
+@export_range(0.0, 1.0, 0.01) var master_volume: float = 1.0
+@export_range(0.0, 1.0, 0.01) var music_volume: float = 1.0
+@export_range(0.0, 1.0, 0.01) var effects_volume: float = 1.0
+@export_range(0.0, 1.0, 0.01) var environment_volume: float = 1.0
+@export_range(1, 5, 1) var world_pixel_size: int = DEFAULT_WORLD_PIXEL_SIZE
+@export_range(1, 5, 1) var ui_pixel_size: int = DEFAULT_UI_PIXEL_SIZE
func is_valid() -> bool:
@@ -26,14 +55,133 @@ func is_valid() -> bool:
and is_finite(controller_camera_sensitivity)
and controller_camera_sensitivity >= MIN_CONTROLLER_SENSITIVITY
and controller_camera_sensitivity <= MAX_CONTROLLER_SENSITIVITY
+ and _is_valid_audio_volume(master_volume)
+ and _is_valid_audio_volume(music_volume)
+ and _is_valid_audio_volume(effects_volume)
+ and _is_valid_audio_volume(environment_volume)
+ and chat_draft.length() <= MAX_CHAT_DRAFT_CHARACTERS
+ and world_pixel_size >= MIN_WORLD_PIXEL_SIZE
+ and world_pixel_size <= MAX_WORLD_PIXEL_SIZE
+ and ui_pixel_size >= MIN_UI_PIXEL_SIZE
+ and ui_pixel_size <= MAX_UI_PIXEL_SIZE
)
func copy() -> PlayerSettings:
var result := PlayerSettings.new()
result.auto_click_enabled = auto_click_enabled
+ result.use_readable_interface_font = true
result.auto_click_interval = auto_click_interval
result.mouse_camera_sensitivity = mouse_camera_sensitivity
result.controller_camera_sensitivity = controller_camera_sensitivity
result.invert_camera_y = invert_camera_y
+ result.on_screen_keyboard_enabled = on_screen_keyboard_enabled
+ result.chat_draft = chat_draft
+ result.chat_collapsed = chat_collapsed
+ result.chat_dock_right = chat_dock_right
+ result.chat_mobile_mode = chat_mobile_mode
+ result.paint_dock_right = paint_dock_right
+ result.fullscreen_enabled = fullscreen_enabled
+ result.master_volume = master_volume
+ result.music_volume = music_volume
+ result.effects_volume = effects_volume
+ result.environment_volume = environment_volume
+ result.world_pixel_size = world_pixel_size
+ result.ui_pixel_size = ui_pixel_size
return result
+
+
+static func _is_valid_audio_volume(value: float) -> bool:
+ return (
+ is_finite(value)
+ and value >= MIN_AUDIO_VOLUME
+ and value <= MAX_AUDIO_VOLUME
+ )
+
+
+static func get_world_grid_height(
+ pixel_size: int,
+ displayed_height: int,
+) -> int:
+ var index: int = clampi(
+ pixel_size,
+ MIN_WORLD_PIXEL_SIZE,
+ MAX_WORLD_PIXEL_SIZE
+ ) - 1
+ if index == 0:
+ return maxi(1, displayed_height)
+ var targets: Array[int] = (
+ WORLD_COMPACT_GRID_HEIGHTS
+ if displayed_height < COMPACT_DISPLAY_HEIGHT
+ else WORLD_DESKTOP_GRID_HEIGHTS
+ )
+ return mini(maxi(1, displayed_height), targets[index])
+
+
+static func get_world_grid_size(
+ pixel_size: int,
+ displayed_size: Vector2i,
+) -> Vector2i:
+ var safe_size := Vector2i(
+ maxi(1, displayed_size.x),
+ maxi(1, displayed_size.y)
+ )
+ var target_height: int = get_world_grid_height(
+ pixel_size,
+ safe_size.y
+ )
+ return Vector2i(
+ maxi(
+ 1,
+ roundi(
+ float(target_height)
+ * float(safe_size.x)
+ / float(safe_size.y)
+ )
+ ),
+ target_height
+ )
+
+
+static func get_ui_render_height(
+ pixel_size: int,
+ displayed_height: int,
+) -> int:
+ var index: int = clampi(
+ pixel_size,
+ MIN_UI_PIXEL_SIZE,
+ MAX_UI_PIXEL_SIZE
+ ) - 1
+ if index == 0:
+ return maxi(1, displayed_height)
+ var targets: Array[int] = (
+ UI_COMPACT_RENDER_HEIGHTS
+ if displayed_height < COMPACT_DISPLAY_HEIGHT
+ else UI_DESKTOP_RENDER_HEIGHTS
+ )
+ return mini(maxi(1, displayed_height), targets[index])
+
+
+static func get_ui_render_size(
+ pixel_size: int,
+ displayed_size: Vector2i,
+) -> Vector2i:
+ var safe_size := Vector2i(
+ maxi(1, displayed_size.x),
+ maxi(1, displayed_size.y)
+ )
+ var target_height: int = get_ui_render_height(
+ pixel_size,
+ safe_size.y
+ )
+ return Vector2i(
+ maxi(
+ 1,
+ roundi(
+ float(target_height)
+ * float(safe_size.x)
+ / float(safe_size.y)
+ )
+ ),
+ target_height
+ )
diff --git a/settings/player_settings_manager.gd b/settings/player_settings_manager.gd
index 47db648..84b3f44 100644
--- a/settings/player_settings_manager.gd
+++ b/settings/player_settings_manager.gd
@@ -5,6 +5,7 @@ const SETTINGS_VERSION: int = 1
const SETTINGS_PATH: String = "user://player_settings.json"
const TEMP_PATH: String = "user://player_settings.json.tmp"
const BACKUP_PATH: String = "user://player_settings.json.backup"
+const SILENT_VOLUME_DB: float = -80.0
signal settings_changed(settings: PlayerSettings)
@@ -16,12 +17,14 @@ func load_settings() -> bool:
_recover_interrupted_write()
if not FileAccess.file_exists(SETTINGS_PATH):
current_settings = PlayerSettings.new()
+ _apply_audio_settings(current_settings)
emit_signal("settings_changed", current_settings)
return true
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file == null:
push_warning("Unable to open player settings; using defaults.")
current_settings = PlayerSettings.new()
+ _apply_audio_settings(current_settings)
emit_signal("settings_changed", current_settings)
return false
var json := JSON.new()
@@ -39,13 +42,53 @@ func load_settings() -> bool:
return _use_defaults_after_corruption("Player settings structure is invalid.")
var accessibility: Dictionary = data["accessibility"]
var camera: Dictionary = data["camera"]
+ var presentation: Dictionary = {}
+ if typeof(data.get("presentation")) == TYPE_DICTIONARY:
+ presentation = data["presentation"]
+ var audio: Dictionary = {}
+ if typeof(data.get("audio")) == TYPE_DICTIONARY:
+ audio = data["audio"]
if (
typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL
or typeof(camera.get("invert_vertical")) != TYPE_BOOL
+ or (
+ accessibility.has("on_screen_keyboard_enabled")
+ and typeof(accessibility["on_screen_keyboard_enabled"]) != TYPE_BOOL
+ )
+ or (
+ accessibility.has("use_readable_interface_font")
+ and typeof(accessibility["use_readable_interface_font"]) != TYPE_BOOL
+ )
+ or (
+ presentation.has("chat_draft")
+ and typeof(presentation["chat_draft"]) != TYPE_STRING
+ )
+ or (
+ presentation.has("chat_collapsed")
+ and typeof(presentation["chat_collapsed"]) != TYPE_BOOL
+ )
+ or (
+ presentation.has("chat_dock_right")
+ and typeof(presentation["chat_dock_right"]) != TYPE_BOOL
+ )
+ or (
+ presentation.has("chat_mobile_mode")
+ and typeof(presentation["chat_mobile_mode"]) != TYPE_BOOL
+ )
+ or (
+ presentation.has("paint_dock_right")
+ and typeof(presentation["paint_dock_right"]) != TYPE_BOOL
+ )
+ or (
+ presentation.has("fullscreen_enabled")
+ and typeof(presentation["fullscreen_enabled"]) != TYPE_BOOL
+ )
):
return _use_defaults_after_corruption("Player settings values are invalid.")
var loaded := PlayerSettings.new()
loaded.auto_click_enabled = accessibility["auto_click_enabled"]
+ # Accept the legacy field, but normalize runtime and future saved state.
+ loaded.use_readable_interface_font = true
loaded.auto_click_interval = _read_float(
accessibility.get("auto_click_interval"),
-1.0
@@ -59,10 +102,50 @@ func load_settings() -> bool:
-1.0
)
loaded.invert_camera_y = camera["invert_vertical"]
+ loaded.on_screen_keyboard_enabled = bool(
+ accessibility.get("on_screen_keyboard_enabled", false)
+ )
+ loaded.world_pixel_size = _read_clamped_integer(
+ presentation.get(
+ "world_pixel_size",
+ PlayerSettings.DEFAULT_WORLD_PIXEL_SIZE
+ ),
+ PlayerSettings.MIN_WORLD_PIXEL_SIZE,
+ PlayerSettings.MAX_WORLD_PIXEL_SIZE,
+ PlayerSettings.DEFAULT_WORLD_PIXEL_SIZE
+ )
+ loaded.ui_pixel_size = _read_clamped_integer(
+ presentation.get(
+ "ui_pixel_size",
+ PlayerSettings.DEFAULT_UI_PIXEL_SIZE
+ ),
+ PlayerSettings.MIN_UI_PIXEL_SIZE,
+ PlayerSettings.MAX_UI_PIXEL_SIZE,
+ PlayerSettings.DEFAULT_UI_PIXEL_SIZE
+ )
+ loaded.chat_draft = str(presentation.get("chat_draft", "")).left(
+ PlayerSettings.MAX_CHAT_DRAFT_CHARACTERS
+ )
+ loaded.chat_collapsed = bool(presentation.get("chat_collapsed", false))
+ loaded.chat_dock_right = bool(presentation.get("chat_dock_right", false))
+ loaded.chat_mobile_mode = bool(
+ presentation.get("chat_mobile_mode", false)
+ )
+ loaded.paint_dock_right = bool(presentation.get("paint_dock_right", true))
+ loaded.fullscreen_enabled = bool(
+ presentation.get("fullscreen_enabled", false)
+ )
+ loaded.master_volume = _read_float(audio.get("master", 1.0), 1.0)
+ loaded.music_volume = _read_float(audio.get("music", 1.0), 1.0)
+ loaded.effects_volume = _read_float(audio.get("effects", 1.0), 1.0)
+ loaded.environment_volume = _read_float(
+ audio.get("environment", 1.0), 1.0
+ )
if not loaded.is_valid():
return _use_defaults_after_corruption("Player settings values are invalid.")
current_settings = loaded
_is_dirty = false
+ _apply_audio_settings(current_settings)
emit_signal("settings_changed", current_settings)
return true
@@ -75,11 +158,44 @@ func apply_settings(settings: PlayerSettings) -> bool:
_is_dirty = true
if not save_now():
current_settings = previous
+ _apply_audio_settings(current_settings)
return false
+ _apply_audio_settings(current_settings)
emit_signal("settings_changed", current_settings)
return true
+func apply_pixelation(world_pixel_size: int, ui_pixel_size: int) -> bool:
+ var edited: PlayerSettings = current_settings.copy()
+ edited.world_pixel_size = clampi(
+ world_pixel_size,
+ PlayerSettings.MIN_WORLD_PIXEL_SIZE,
+ PlayerSettings.MAX_WORLD_PIXEL_SIZE
+ )
+ edited.ui_pixel_size = clampi(
+ ui_pixel_size,
+ PlayerSettings.MIN_UI_PIXEL_SIZE,
+ PlayerSettings.MAX_UI_PIXEL_SIZE
+ )
+ return apply_settings(edited)
+
+
+func update_chat_preferences(draft: String, collapsed: bool) -> void:
+ var clean_draft := draft.left(PlayerSettings.MAX_CHAT_DRAFT_CHARACTERS)
+ if (
+ current_settings.chat_draft == clean_draft
+ and current_settings.chat_collapsed == collapsed
+ ):
+ return
+ current_settings.chat_draft = clean_draft
+ current_settings.chat_collapsed = collapsed
+ _is_dirty = true
+
+
+func save_chat_preferences() -> bool:
+ return save_if_dirty()
+
+
func save_now() -> bool:
if current_settings == null or not current_settings.is_valid():
return false
@@ -88,13 +204,35 @@ func save_now() -> bool:
"accessibility": {
"auto_click_enabled": current_settings.auto_click_enabled,
"auto_click_interval": current_settings.auto_click_interval,
+ "use_readable_interface_font": true,
+ "on_screen_keyboard_enabled": (
+ current_settings.on_screen_keyboard_enabled
+ ),
},
"camera": {
"mouse_sensitivity": current_settings.mouse_camera_sensitivity,
"controller_sensitivity": current_settings.controller_camera_sensitivity,
"invert_vertical": current_settings.invert_camera_y,
},
+ "audio": {
+ "master": current_settings.master_volume,
+ "music": current_settings.music_volume,
+ "effects": current_settings.effects_volume,
+ "environment": current_settings.environment_volume,
+ },
+ "presentation": {
+ "world_pixel_size": current_settings.world_pixel_size,
+ "ui_pixel_size": current_settings.ui_pixel_size,
+ "chat_draft": current_settings.chat_draft,
+ "chat_collapsed": current_settings.chat_collapsed,
+ "chat_dock_right": current_settings.chat_dock_right,
+ "chat_mobile_mode": current_settings.chat_mobile_mode,
+ "paint_dock_right": current_settings.paint_dock_right,
+ "fullscreen_enabled": current_settings.fullscreen_enabled,
+ },
}
+ if current_settings.chat_draft.is_empty():
+ (data["presentation"] as Dictionary).erase("chat_draft")
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
@@ -123,6 +261,22 @@ func save_if_dirty() -> bool:
return not _is_dirty or save_now()
+func preview_audio_levels(
+ master: float,
+ music: float,
+ effects: float,
+ environment: float,
+) -> void:
+ _set_bus_volume(&"Master", master)
+ _set_bus_volume(&"Music", music)
+ _set_bus_volume(&"SFX", effects)
+ _set_bus_volume(&"Environment", environment)
+
+
+func restore_audio_levels() -> void:
+ _apply_audio_settings(current_settings)
+
+
func _exit_tree() -> void:
save_if_dirty()
@@ -131,10 +285,38 @@ func _use_defaults_after_corruption(message: String) -> bool:
push_warning(message)
current_settings = PlayerSettings.new()
_is_dirty = false
+ _apply_audio_settings(current_settings)
emit_signal("settings_changed", current_settings)
return false
+func _apply_audio_settings(settings: PlayerSettings) -> void:
+ preview_audio_levels(
+ settings.master_volume,
+ settings.music_volume,
+ settings.effects_volume,
+ settings.environment_volume,
+ )
+
+
+func _set_bus_volume(bus_name: StringName, linear_volume: float) -> void:
+ var bus_index: int = AudioServer.get_bus_index(bus_name)
+ if bus_index < 0:
+ return
+ var resolved_volume: float = clampf(
+ linear_volume,
+ PlayerSettings.MIN_AUDIO_VOLUME,
+ PlayerSettings.MAX_AUDIO_VOLUME,
+ )
+ AudioServer.set_bus_mute(bus_index, resolved_volume <= 0.0)
+ AudioServer.set_bus_volume_db(
+ bus_index,
+ SILENT_VOLUME_DB
+ if resolved_volume <= 0.0
+ else linear_to_db(resolved_volume),
+ )
+
+
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(SETTINGS_PATH):
_remove_if_present(TEMP_PATH)
@@ -160,6 +342,16 @@ func _read_float(value: Variant, fallback: float) -> float:
return result if is_finite(result) else fallback
+func _read_clamped_integer(
+ value: Variant,
+ minimum: int,
+ maximum: int,
+ fallback: int,
+) -> int:
+ var result: int = _read_integer(value, fallback)
+ return clampi(result, minimum, maximum)
+
+
func _rename_file(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(from_path),
diff --git a/sound/dialogue/animalese/placeholder/a.wav b/sound/dialogue/animalese/placeholder/a.wav
new file mode 100644
index 0000000..ca652d4
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/a.wav differ
diff --git a/sound/dialogue/animalese/placeholder/a.wav.import b/sound/dialogue/animalese/placeholder/a.wav.import
new file mode 100644
index 0000000..86de7ca
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/a.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dvajjy4bwadio"
+path="res://.godot/imported/a.wav-9ab4b1a9a555e6fcf07951f399769b30.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/a.wav"
+dest_files=["res://.godot/imported/a.wav-9ab4b1a9a555e6fcf07951f399769b30.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/b.wav b/sound/dialogue/animalese/placeholder/b.wav
new file mode 100644
index 0000000..71ceb88
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/b.wav differ
diff --git a/sound/dialogue/animalese/placeholder/b.wav.import b/sound/dialogue/animalese/placeholder/b.wav.import
new file mode 100644
index 0000000..4aea49c
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/b.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://cs30tdyuo6wtc"
+path="res://.godot/imported/b.wav-eed860af2033bda258bd172899061b45.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/b.wav"
+dest_files=["res://.godot/imported/b.wav-eed860af2033bda258bd172899061b45.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/c.wav b/sound/dialogue/animalese/placeholder/c.wav
new file mode 100644
index 0000000..8b5eb64
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/c.wav differ
diff --git a/sound/dialogue/animalese/placeholder/c.wav.import b/sound/dialogue/animalese/placeholder/c.wav.import
new file mode 100644
index 0000000..bbd8074
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/c.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://2inox7x31xam"
+path="res://.godot/imported/c.wav-61dc49533634805d70c71d509b63e37b.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/c.wav"
+dest_files=["res://.godot/imported/c.wav-61dc49533634805d70c71d509b63e37b.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/d.wav b/sound/dialogue/animalese/placeholder/d.wav
new file mode 100644
index 0000000..978009a
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/d.wav differ
diff --git a/sound/dialogue/animalese/placeholder/d.wav.import b/sound/dialogue/animalese/placeholder/d.wav.import
new file mode 100644
index 0000000..ffc5bb9
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/d.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://yvha8eukw6l7"
+path="res://.godot/imported/d.wav-910bc02bc42bdb1b37561959a317c28a.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/d.wav"
+dest_files=["res://.godot/imported/d.wav-910bc02bc42bdb1b37561959a317c28a.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/e.wav b/sound/dialogue/animalese/placeholder/e.wav
new file mode 100644
index 0000000..76f7b8c
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/e.wav differ
diff --git a/sound/dialogue/animalese/placeholder/e.wav.import b/sound/dialogue/animalese/placeholder/e.wav.import
new file mode 100644
index 0000000..49de309
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/e.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://8ujopf8aqs83"
+path="res://.godot/imported/e.wav-7da5f8fcd017bf931de2bd49dc00f652.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/e.wav"
+dest_files=["res://.godot/imported/e.wav-7da5f8fcd017bf931de2bd49dc00f652.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/f.wav b/sound/dialogue/animalese/placeholder/f.wav
new file mode 100644
index 0000000..bec92c1
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/f.wav differ
diff --git a/sound/dialogue/animalese/placeholder/f.wav.import b/sound/dialogue/animalese/placeholder/f.wav.import
new file mode 100644
index 0000000..dc85ee3
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/f.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://c0gv4cphnfcxf"
+path="res://.godot/imported/f.wav-8eb31f2de6240647a0d6d2520aadcd6a.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/f.wav"
+dest_files=["res://.godot/imported/f.wav-8eb31f2de6240647a0d6d2520aadcd6a.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/fallback.wav b/sound/dialogue/animalese/placeholder/fallback.wav
new file mode 100644
index 0000000..63a759e
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/fallback.wav differ
diff --git a/sound/dialogue/animalese/placeholder/fallback.wav.import b/sound/dialogue/animalese/placeholder/fallback.wav.import
new file mode 100644
index 0000000..e116a3c
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/fallback.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://bw0pod4tmexm0"
+path="res://.godot/imported/fallback.wav-7a40516b188f10028ac39bdc24fb52e5.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/fallback.wav"
+dest_files=["res://.godot/imported/fallback.wav-7a40516b188f10028ac39bdc24fb52e5.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/g.wav b/sound/dialogue/animalese/placeholder/g.wav
new file mode 100644
index 0000000..39dccf1
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/g.wav differ
diff --git a/sound/dialogue/animalese/placeholder/g.wav.import b/sound/dialogue/animalese/placeholder/g.wav.import
new file mode 100644
index 0000000..1709852
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/g.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://c7706p0fc8y7c"
+path="res://.godot/imported/g.wav-45b2cbd9653a31477e3f00c9432d560c.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/g.wav"
+dest_files=["res://.godot/imported/g.wav-45b2cbd9653a31477e3f00c9432d560c.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/h.wav b/sound/dialogue/animalese/placeholder/h.wav
new file mode 100644
index 0000000..acebfed
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/h.wav differ
diff --git a/sound/dialogue/animalese/placeholder/h.wav.import b/sound/dialogue/animalese/placeholder/h.wav.import
new file mode 100644
index 0000000..96b2359
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/h.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://cl66wka105xmo"
+path="res://.godot/imported/h.wav-2da53a6638eebb75f0867eaf75dc24ba.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/h.wav"
+dest_files=["res://.godot/imported/h.wav-2da53a6638eebb75f0867eaf75dc24ba.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/i.wav b/sound/dialogue/animalese/placeholder/i.wav
new file mode 100644
index 0000000..fbd10e1
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/i.wav differ
diff --git a/sound/dialogue/animalese/placeholder/i.wav.import b/sound/dialogue/animalese/placeholder/i.wav.import
new file mode 100644
index 0000000..0c11fd6
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/i.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://jicgdbnyt6bm"
+path="res://.godot/imported/i.wav-1e8d7f13980a6f2f61d73f65a2efc91c.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/i.wav"
+dest_files=["res://.godot/imported/i.wav-1e8d7f13980a6f2f61d73f65a2efc91c.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/j.wav b/sound/dialogue/animalese/placeholder/j.wav
new file mode 100644
index 0000000..0087db5
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/j.wav differ
diff --git a/sound/dialogue/animalese/placeholder/j.wav.import b/sound/dialogue/animalese/placeholder/j.wav.import
new file mode 100644
index 0000000..46a0baf
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/j.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://cji2evr0u3m6c"
+path="res://.godot/imported/j.wav-2f355374ffcea77f8dc630ee45879444.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/j.wav"
+dest_files=["res://.godot/imported/j.wav-2f355374ffcea77f8dc630ee45879444.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/k.wav b/sound/dialogue/animalese/placeholder/k.wav
new file mode 100644
index 0000000..c1b5e69
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/k.wav differ
diff --git a/sound/dialogue/animalese/placeholder/k.wav.import b/sound/dialogue/animalese/placeholder/k.wav.import
new file mode 100644
index 0000000..4af149c
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/k.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://eypaqf1iaq5"
+path="res://.godot/imported/k.wav-b85d045dc022b9055c25a650b2e3af2a.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/k.wav"
+dest_files=["res://.godot/imported/k.wav-b85d045dc022b9055c25a650b2e3af2a.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/l.wav b/sound/dialogue/animalese/placeholder/l.wav
new file mode 100644
index 0000000..fe2624b
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/l.wav differ
diff --git a/sound/dialogue/animalese/placeholder/l.wav.import b/sound/dialogue/animalese/placeholder/l.wav.import
new file mode 100644
index 0000000..8193ecf
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/l.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://bixgnaq4idkhj"
+path="res://.godot/imported/l.wav-b21006c3b048e0c797d6baf19ab0f663.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/l.wav"
+dest_files=["res://.godot/imported/l.wav-b21006c3b048e0c797d6baf19ab0f663.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/m.wav b/sound/dialogue/animalese/placeholder/m.wav
new file mode 100644
index 0000000..4660d96
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/m.wav differ
diff --git a/sound/dialogue/animalese/placeholder/m.wav.import b/sound/dialogue/animalese/placeholder/m.wav.import
new file mode 100644
index 0000000..2a2439b
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/m.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dan4o17oreugv"
+path="res://.godot/imported/m.wav-aa394c4b1d5309d859ce73a368754264.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/m.wav"
+dest_files=["res://.godot/imported/m.wav-aa394c4b1d5309d859ce73a368754264.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/n.wav b/sound/dialogue/animalese/placeholder/n.wav
new file mode 100644
index 0000000..97e5457
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/n.wav differ
diff --git a/sound/dialogue/animalese/placeholder/n.wav.import b/sound/dialogue/animalese/placeholder/n.wav.import
new file mode 100644
index 0000000..f5e432c
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/n.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dxup6pw80km61"
+path="res://.godot/imported/n.wav-84e9a6fa6664222868d647187a09f374.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/n.wav"
+dest_files=["res://.godot/imported/n.wav-84e9a6fa6664222868d647187a09f374.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/o.wav b/sound/dialogue/animalese/placeholder/o.wav
new file mode 100644
index 0000000..bee6bc1
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/o.wav differ
diff --git a/sound/dialogue/animalese/placeholder/o.wav.import b/sound/dialogue/animalese/placeholder/o.wav.import
new file mode 100644
index 0000000..b094a9f
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/o.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://lntkug4avr2k"
+path="res://.godot/imported/o.wav-6c18faa9701875d7753d91afcd1b09e6.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/o.wav"
+dest_files=["res://.godot/imported/o.wav-6c18faa9701875d7753d91afcd1b09e6.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/p.wav b/sound/dialogue/animalese/placeholder/p.wav
new file mode 100644
index 0000000..29fdd60
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/p.wav differ
diff --git a/sound/dialogue/animalese/placeholder/p.wav.import b/sound/dialogue/animalese/placeholder/p.wav.import
new file mode 100644
index 0000000..a431c1f
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/p.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://crubsdrd220eh"
+path="res://.godot/imported/p.wav-c32b759c822f258d8d947827adf8127a.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/p.wav"
+dest_files=["res://.godot/imported/p.wav-c32b759c822f258d8d947827adf8127a.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/q.wav b/sound/dialogue/animalese/placeholder/q.wav
new file mode 100644
index 0000000..df184ca
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/q.wav differ
diff --git a/sound/dialogue/animalese/placeholder/q.wav.import b/sound/dialogue/animalese/placeholder/q.wav.import
new file mode 100644
index 0000000..574c906
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/q.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://ct0decr76agoq"
+path="res://.godot/imported/q.wav-d494b927c6f2b8bfbaa44d1f81887731.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/q.wav"
+dest_files=["res://.godot/imported/q.wav-d494b927c6f2b8bfbaa44d1f81887731.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/r.wav b/sound/dialogue/animalese/placeholder/r.wav
new file mode 100644
index 0000000..41fda60
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/r.wav differ
diff --git a/sound/dialogue/animalese/placeholder/r.wav.import b/sound/dialogue/animalese/placeholder/r.wav.import
new file mode 100644
index 0000000..1f196b1
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/r.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dpq7rin4pr1n3"
+path="res://.godot/imported/r.wav-d1d419e3246c0e918ff51a9dd662a742.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/r.wav"
+dest_files=["res://.godot/imported/r.wav-d1d419e3246c0e918ff51a9dd662a742.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/s.wav b/sound/dialogue/animalese/placeholder/s.wav
new file mode 100644
index 0000000..f9c542a
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/s.wav differ
diff --git a/sound/dialogue/animalese/placeholder/s.wav.import b/sound/dialogue/animalese/placeholder/s.wav.import
new file mode 100644
index 0000000..a794fdf
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/s.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://c00meumj2blfa"
+path="res://.godot/imported/s.wav-15abb20075aacc8d5fe95cd206ae25ce.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/s.wav"
+dest_files=["res://.godot/imported/s.wav-15abb20075aacc8d5fe95cd206ae25ce.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/t.wav b/sound/dialogue/animalese/placeholder/t.wav
new file mode 100644
index 0000000..76e0948
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/t.wav differ
diff --git a/sound/dialogue/animalese/placeholder/t.wav.import b/sound/dialogue/animalese/placeholder/t.wav.import
new file mode 100644
index 0000000..ce35ba2
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/t.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://dr0dp5v8fdb8r"
+path="res://.godot/imported/t.wav-3a26c5be0f8e3007adf1daa05a6a1ea2.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/t.wav"
+dest_files=["res://.godot/imported/t.wav-3a26c5be0f8e3007adf1daa05a6a1ea2.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/u.wav b/sound/dialogue/animalese/placeholder/u.wav
new file mode 100644
index 0000000..81ebbe5
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/u.wav differ
diff --git a/sound/dialogue/animalese/placeholder/u.wav.import b/sound/dialogue/animalese/placeholder/u.wav.import
new file mode 100644
index 0000000..8ccd096
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/u.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://bln1bkdu20muo"
+path="res://.godot/imported/u.wav-06e44c423f3d1cd6cdd34b54270211bb.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/u.wav"
+dest_files=["res://.godot/imported/u.wav-06e44c423f3d1cd6cdd34b54270211bb.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/v.wav b/sound/dialogue/animalese/placeholder/v.wav
new file mode 100644
index 0000000..d5c0d12
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/v.wav differ
diff --git a/sound/dialogue/animalese/placeholder/v.wav.import b/sound/dialogue/animalese/placeholder/v.wav.import
new file mode 100644
index 0000000..ff876a8
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/v.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://y3g1s3pw3bn0"
+path="res://.godot/imported/v.wav-5a9986e91dbdd0da4f0583b35a986b1c.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/v.wav"
+dest_files=["res://.godot/imported/v.wav-5a9986e91dbdd0da4f0583b35a986b1c.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/w.wav b/sound/dialogue/animalese/placeholder/w.wav
new file mode 100644
index 0000000..5122f9c
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/w.wav differ
diff --git a/sound/dialogue/animalese/placeholder/w.wav.import b/sound/dialogue/animalese/placeholder/w.wav.import
new file mode 100644
index 0000000..36f4e0f
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/w.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://5ml2iuf8cpgt"
+path="res://.godot/imported/w.wav-d3e9035a1fc5e7946be782a0d4f95e64.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/w.wav"
+dest_files=["res://.godot/imported/w.wav-d3e9035a1fc5e7946be782a0d4f95e64.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/x.wav b/sound/dialogue/animalese/placeholder/x.wav
new file mode 100644
index 0000000..e00f085
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/x.wav differ
diff --git a/sound/dialogue/animalese/placeholder/x.wav.import b/sound/dialogue/animalese/placeholder/x.wav.import
new file mode 100644
index 0000000..c91ffb4
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/x.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://b5al0m4de1m5x"
+path="res://.godot/imported/x.wav-b1beaddcc7d6663882ddd8b86e65db56.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/x.wav"
+dest_files=["res://.godot/imported/x.wav-b1beaddcc7d6663882ddd8b86e65db56.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/y.wav b/sound/dialogue/animalese/placeholder/y.wav
new file mode 100644
index 0000000..07b45ef
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/y.wav differ
diff --git a/sound/dialogue/animalese/placeholder/y.wav.import b/sound/dialogue/animalese/placeholder/y.wav.import
new file mode 100644
index 0000000..2c7d8d3
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/y.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://c6hust77yamoe"
+path="res://.godot/imported/y.wav-5ef178912b2921bb7c790b12e2e82337.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/y.wav"
+dest_files=["res://.godot/imported/y.wav-5ef178912b2921bb7c790b12e2e82337.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/animalese/placeholder/z.wav b/sound/dialogue/animalese/placeholder/z.wav
new file mode 100644
index 0000000..8f94654
Binary files /dev/null and b/sound/dialogue/animalese/placeholder/z.wav differ
diff --git a/sound/dialogue/animalese/placeholder/z.wav.import b/sound/dialogue/animalese/placeholder/z.wav.import
new file mode 100644
index 0000000..12b1b54
--- /dev/null
+++ b/sound/dialogue/animalese/placeholder/z.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://djvv63b0jlll"
+path="res://.godot/imported/z.wav-3e6ff24f53b1014b381cc80ee9fb1a84.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/animalese/placeholder/z.wav"
+dest_files=["res://.godot/imported/z.wav-3e6ff24f53b1014b381cc80ee9fb1a84.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/calls/bark.wav b/sound/dialogue/calls/bark.wav
new file mode 100644
index 0000000..2629fe7
Binary files /dev/null and b/sound/dialogue/calls/bark.wav differ
diff --git a/sound/dialogue/calls/bark.wav.import b/sound/dialogue/calls/bark.wav.import
new file mode 100644
index 0000000..702904d
--- /dev/null
+++ b/sound/dialogue/calls/bark.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://c8x46srpghnil"
+path="res://.godot/imported/bark.wav-9da6fd5d720375f4a64b26e364fcd74c.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/calls/bark.wav"
+dest_files=["res://.godot/imported/bark.wav-9da6fd5d720375f4a64b26e364fcd74c.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/sound/dialogue/calls/meow.wav b/sound/dialogue/calls/meow.wav
new file mode 100644
index 0000000..662b416
Binary files /dev/null and b/sound/dialogue/calls/meow.wav differ
diff --git a/sound/dialogue/calls/meow.wav.import b/sound/dialogue/calls/meow.wav.import
new file mode 100644
index 0000000..058b09a
--- /dev/null
+++ b/sound/dialogue/calls/meow.wav.import
@@ -0,0 +1,24 @@
+[remap]
+
+importer="wav"
+type="AudioStreamWAV"
+uid="uid://y86nskf7ewrg"
+path="res://.godot/imported/meow.wav-eb7bd4377946745ae1566c72ca026fae.sample"
+
+[deps]
+
+source_file="res://sound/dialogue/calls/meow.wav"
+dest_files=["res://.godot/imported/meow.wav-eb7bd4377946745ae1566c72ca026fae.sample"]
+
+[params]
+
+force/8_bit=false
+force/mono=false
+force/max_rate=false
+force/max_rate_hz=44100
+edit/trim=false
+edit/normalize=false
+edit/loop_mode=0
+edit/loop_begin=0
+edit/loop_end=-1
+compress/mode=2
diff --git a/tests/android_readiness_validation.gd b/tests/android_readiness_validation.gd
new file mode 100644
index 0000000..b383202
--- /dev/null
+++ b/tests/android_readiness_validation.gd
@@ -0,0 +1,109 @@
+extends SceneTree
+
+const GameUIType = preload("res://ui/game_ui.gd")
+const PlayerType = preload("res://player/player.gd")
+
+
+func _init() -> void:
+ assert(
+ ProjectSettings.get_setting("display/window/handheld/orientation", -1) == 0
+ )
+ assert(
+ ProjectSettings.get_setting(
+ "input_devices/pointing/emulate_mouse_from_touch", false
+ )
+ )
+ assert(_has_joypad_motion(&"move_forward", JOY_AXIS_LEFT_Y, -1.0))
+ assert(_has_joypad_motion(&"move_backward", JOY_AXIS_LEFT_Y, 1.0))
+ assert(_has_joypad_motion(&"move_left", JOY_AXIS_LEFT_X, -1.0))
+ assert(_has_joypad_motion(&"move_right", JOY_AXIS_LEFT_X, 1.0))
+ assert(_has_joypad_button(&"jump", JOY_BUTTON_A))
+ assert(_has_joypad_button(&"ui_accept", JOY_BUTTON_A))
+ assert(_has_joypad_button(&"ui_cancel", JOY_BUTTON_B))
+ assert(_has_joypad_button(&"interact", JOY_BUTTON_Y))
+ assert(_has_joypad_button(&"fish_primary", JOY_BUTTON_RIGHT_SHOULDER))
+ assert(not _has_joypad_motion(
+ &"fish_primary", JOY_AXIS_TRIGGER_LEFT, 1.0
+ ))
+ assert(not _has_joypad_motion(
+ &"fish_primary", JOY_AXIS_TRIGGER_RIGHT, 1.0
+ ))
+ assert(_has_joypad_button(&"sprint", JOY_BUTTON_LEFT_STICK))
+ assert(not _has_joypad_button(
+ &"camera_zoom_out", JOY_BUTTON_LEFT_SHOULDER
+ ))
+ assert(not _has_joypad_button(
+ &"camera_zoom_in", JOY_BUTTON_RIGHT_SHOULDER
+ ))
+ assert(_has_joypad_button(&"hotbar_previous", JOY_BUTTON_DPAD_LEFT))
+ assert(_has_joypad_button(&"hotbar_next", JOY_BUTTON_DPAD_RIGHT))
+ assert(_has_joypad_button(&"open_backpack", JOY_BUTTON_X))
+ assert(_has_joypad_button(&"open_system_menu", JOY_BUTTON_START))
+ assert(_has_joypad_button(&"open_emotes", JOY_BUTTON_DPAD_UP))
+ assert(_has_joypad_button(&"open_quick_actions", JOY_BUTTON_DPAD_DOWN))
+ assert(_has_joypad_button(&"open_chat", JOY_BUTTON_BACK))
+ assert(_has_joypad_button(&"focus_gameplay", JOY_BUTTON_LEFT_SHOULDER))
+ assert(
+ GameUIType.VIRTUAL_MOUSE_TRIGGER_AXIS == JOY_AXIS_TRIGGER_RIGHT
+ )
+ assert(
+ GameUIType.VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS
+ == JOY_AXIS_TRIGGER_LEFT
+ )
+ assert(
+ GameUIType.VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS
+ == JOY_AXIS_TRIGGER_LEFT
+ )
+ assert(PlayerType.CONTROLLER_ZOOM_TRIGGER_AXIS == JOY_AXIS_TRIGGER_LEFT)
+ assert(is_zero_approx(
+ GameUIType.normalized_trigger_strength(-1.0, -1.0)
+ ))
+ assert(is_equal_approx(
+ GameUIType.normalized_trigger_strength(1.0, -1.0),
+ 1.0,
+ ))
+ assert(is_equal_approx(
+ GameUIType.normalized_trigger_strength(-1.0, 0.0),
+ 1.0,
+ ))
+ assert(is_equal_approx(
+ GameUIType.normalized_trigger_strength(-1.0, 1.0),
+ 1.0,
+ ))
+ assert(is_equal_approx(
+ GameUIType.directional_trigger_strength(-1.0, 0.0, -1.0),
+ 1.0,
+ ))
+ assert(is_zero_approx(
+ GameUIType.directional_trigger_strength(1.0, 0.0, -1.0)
+ ))
+ assert(is_equal_approx(
+ GameUIType.directional_trigger_strength(1.0, 0.0, 1.0),
+ 1.0,
+ ))
+ print("android readiness validation passed")
+ quit()
+
+
+func _has_joypad_button(action: StringName, button_index: JoyButton) -> bool:
+ for event: InputEvent in InputMap.action_get_events(action):
+ var button_event: InputEventJoypadButton = event as InputEventJoypadButton
+ if button_event != null and button_event.button_index == button_index:
+ return true
+ return false
+
+
+func _has_joypad_motion(
+ action: StringName,
+ axis: JoyAxis,
+ direction: float,
+) -> bool:
+ for event: InputEvent in InputMap.action_get_events(action):
+ var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion
+ if (
+ motion_event != null
+ and motion_event.axis == axis
+ and is_equal_approx(motion_event.axis_value, direction)
+ ):
+ return true
+ return false
diff --git a/tests/android_readiness_validation.gd.uid b/tests/android_readiness_validation.gd.uid
new file mode 100644
index 0000000..f499a0b
--- /dev/null
+++ b/tests/android_readiness_validation.gd.uid
@@ -0,0 +1 @@
+uid://cbqyx4mntux7l
diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd
new file mode 100644
index 0000000..14e19e7
--- /dev/null
+++ b/tests/art_tools_validation.gd
@@ -0,0 +1,539 @@
+extends SceneTree
+
+const MainScene: PackedScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 18136
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ assert(bool(main.call("_prepare_private_host")))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ await physics_frame
+ await physics_frame
+
+ var player := main.get("_player") as Player
+ var sprint_dust := player.get_node("%SprintDust") as SprintDustTrail
+ assert(sprint_dust != null)
+ assert(sprint_dust.get_active_puff_count() == 0)
+ sprint_dust.update_trail(
+ 0.016,
+ Vector3.ZERO,
+ Vector3(4.5, 0.0, 0.0),
+ true,
+ )
+ assert(sprint_dust.get_active_puff_count() == 0)
+ sprint_dust.update_trail(
+ 0.016,
+ Vector3(0.5, 0.0, 0.0),
+ Vector3(4.5, 0.0, 0.0),
+ true,
+ )
+ assert(sprint_dust.get_active_puff_count() == 1)
+ var planted_puff_position: Vector3 = (
+ sprint_dust.get_first_active_puff_position()
+ )
+ sprint_dust.update_trail(
+ 0.08,
+ Vector3(0.75, 0.0, 0.0),
+ Vector3(4.5, 0.0, 0.0),
+ true,
+ )
+ assert(
+ sprint_dust.get_first_active_puff_position()
+ == planted_puff_position
+ )
+ sprint_dust.update_trail(
+ 0.08,
+ Vector3(0.75, 0.0, 0.0),
+ Vector3.ZERO,
+ false,
+ )
+ assert(sprint_dust.get_active_puff_count() == 1)
+ sprint_dust.clear_trail()
+ assert(sprint_dust.get_active_puff_count() == 0)
+ sprint_dust.emit_landing_burst(Vector3.ZERO, Vector3.FORWARD)
+ assert(sprint_dust.get_active_puff_count() == 5)
+ sprint_dust.update_trail(
+ 0.08,
+ Vector3.ZERO,
+ Vector3.ZERO,
+ false,
+ )
+ assert(sprint_dust.get_active_puff_count() == 5)
+ sprint_dust.clear_trail()
+ var service := main.get_node(
+ "%NetworkSurfaceDrawingService"
+ ) as NetworkSurfaceDrawingService
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var toolbar := game_ui.get_node(
+ "%SurfaceDrawingToolbar"
+ ) as SurfaceDrawingToolbar
+ var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
+ assert(player != null and service != null and toolbar != null)
+ assert(chat_ui != null)
+ assert(bool(game_ui.call("_can_start_virtual_mouse")))
+ game_ui.call("_begin_virtual_mouse", 0)
+ var virtual_cursor := game_ui.get_node(
+ "%ControllerVirtualCursor"
+ ) as ControllerVirtualCursor
+ assert(virtual_cursor.visible)
+ assert(not player.is_camera_input_enabled())
+ game_ui.call("_end_virtual_mouse")
+ assert(not virtual_cursor.visible)
+ assert(player.is_camera_input_enabled())
+ var settings_panel := game_ui.get(
+ "_pause_settings_panel"
+ ) as SettingsPanel
+ var chat_dock_button := settings_panel.get_node("%ChatDock") as BubbleButton
+ var chat_mode_button := settings_panel.get_node("%ChatMode") as BubbleButton
+ var paint_dock_button := settings_panel.get_node("%PaintDock") as BubbleButton
+ assert(chat_dock_button.neutral_size.x == chat_dock_button.neutral_size.y)
+ assert(chat_mode_button.neutral_size.x == chat_mode_button.neutral_size.y)
+ assert(paint_dock_button.neutral_size.x == paint_dock_button.neutral_size.y)
+ assert(
+ chat_dock_button.compact_minimum_size.x
+ == chat_dock_button.compact_minimum_size.y
+ )
+ assert(
+ chat_mode_button.compact_minimum_size.x
+ == chat_mode_button.compact_minimum_size.y
+ )
+ assert(
+ paint_dock_button.compact_minimum_size.x
+ == paint_dock_button.compact_minimum_size.y
+ )
+ assert(not chat_ui.is_docked_right())
+ assert(toolbar.is_docked_right())
+ var settings_manager := main.get_node(
+ "%PlayerSettingsManager"
+ ) as PlayerSettingsManager
+ assert(settings_manager != null)
+ var changed_docks: PlayerSettings = settings_manager.current_settings.copy()
+ changed_docks.chat_dock_right = true
+ changed_docks.chat_mobile_mode = true
+ changed_docks.paint_dock_right = false
+ assert(settings_manager.apply_settings(changed_docks))
+ await process_frame
+ assert(chat_ui.is_docked_right())
+ assert(chat_ui.is_mobile_mode())
+ assert(not toolbar.is_docked_right())
+ var chat_panel := chat_ui.get_node("ChatPanel") as PanelContainer
+ var chat_height_button := chat_ui.get_node("ChatHeightButton") as Button
+ var chat_status := chat_ui.find_child(
+ "ChatStatus", true, false
+ ) as Label
+ assert(chat_status != null)
+ assert(
+ chat_status.autowrap_mode
+ == TextServer.AUTOWRAP_WORD_SMART
+ )
+ assert(is_zero_approx(chat_panel.position.y))
+ assert(is_equal_approx(chat_panel.size.x, ChatUI.MOBILE_COMPACT_WIDTH))
+ assert(not chat_height_button.visible)
+ var reloaded_settings := PlayerSettingsManager.new()
+ root.add_child(reloaded_settings)
+ assert(reloaded_settings.load_settings())
+ assert(reloaded_settings.current_settings.chat_dock_right)
+ assert(reloaded_settings.current_settings.chat_mobile_mode)
+ assert(not reloaded_settings.current_settings.paint_dock_right)
+ reloaded_settings.queue_free()
+ var default_docks: PlayerSettings = settings_manager.current_settings.copy()
+ default_docks.chat_dock_right = false
+ default_docks.chat_mobile_mode = false
+ default_docks.paint_dock_right = true
+ assert(settings_manager.apply_settings(default_docks))
+ await process_frame
+ chat_ui.call(
+ "_set_status",
+ (
+ "Unknown command: /this-command-name-is-intentionally-long-"
+ + "enough-to-require-smart-character-wrapping"
+ ),
+ )
+ await process_frame
+ assert(is_equal_approx(chat_panel.size.x, ChatUI.PANEL_WIDTH))
+ assert(chat_status.size.x <= ChatUI.PANEL_WIDTH)
+ chat_ui.call("_set_status", "")
+ assert(not chat_ui.is_docked_right())
+ assert(not chat_ui.is_mobile_mode())
+ assert(toolbar.is_docked_right())
+ assert(chat_height_button.visible)
+ var select_button := InputEventJoypadButton.new()
+ select_button.button_index = JOY_BUTTON_BACK
+ select_button.pressed = true
+ assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
+ await process_frame
+ assert(chat_ui.is_open())
+ assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP)
+ var typed_chat_entry := chat_ui.find_child(
+ "ChatEntry", true, false
+ ) as LineEdit
+ assert(typed_chat_entry != null)
+ assert(not typed_chat_entry.virtual_keyboard_enabled)
+ var accept_button := InputEventJoypadButton.new()
+ accept_button.button_index = JOY_BUTTON_A
+ accept_button.pressed = true
+ assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button)))
+ assert(typed_chat_entry.virtual_keyboard_enabled)
+ await process_frame
+ assert(typed_chat_entry.has_focus())
+ var left_bumper := InputEventJoypadButton.new()
+ left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER
+ left_bumper.pressed = true
+ assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
+ assert(not chat_ui.is_open())
+ assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE)
+ assert(not typed_chat_entry.virtual_keyboard_enabled)
+ assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
+ assert(not chat_ui.is_open())
+ assert(not typed_chat_entry.virtual_keyboard_enabled)
+ assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
+ assert(not chat_ui.is_open())
+ assert(chat_ui.is_collapsed())
+ assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
+ assert(chat_ui.is_open())
+ chat_ui.refocus_gameplay()
+ var quick_menu := game_ui.get_node(
+ "%QuickRadialMenu"
+ ) as QuickRadialMenu
+ var emote_menu := game_ui.get_node(
+ "%EmoteRadialMenu"
+ ) as EmoteRadialMenu
+ assert(quick_menu != null and emote_menu != null)
+ assert(QuickRadialMenu.ACTIONS.size() == QuickRadialMenu.SECTOR_COUNT)
+ assert(QuickRadialMenu.ACTIONS.has(&"freecam"))
+ assert(QuickRadialMenu.ACTIONS.has(&"hud"))
+ game_ui.call("_on_quick_action_selected", &"freecam")
+ assert(player.is_free_camera_active())
+ assert(player.get_active_gameplay_camera().name == &"FreeCamera")
+ game_ui.call("_on_quick_action_selected", &"freecam")
+ assert(not player.is_free_camera_active())
+ game_ui.call("_on_quick_action_selected", &"hud")
+ await process_frame
+ assert(game_ui.is_gameplay_hud_hidden())
+ assert(chat_ui.is_hud_hidden())
+ assert(not (
+ game_ui.get_node("%GameplayTransientHUD") as Control
+ ).visible)
+ assert(not (
+ game_ui.get_node("%ExperiencePresentation") as Control
+ ).visible)
+ quick_menu.open_menu(true)
+ assert(quick_menu.visible and quick_menu.is_open())
+ quick_menu.close_menu()
+ emote_menu.open_menu(true)
+ assert(emote_menu.visible and emote_menu.is_open())
+ emote_menu.close_menu()
+ chat_ui.open_chat()
+ await process_frame
+ assert(chat_ui.is_open() and chat_panel.visible)
+ chat_ui.refocus_gameplay()
+ assert(chat_ui.is_hud_hidden())
+ var cancel_button := InputEventJoypadButton.new()
+ cancel_button.button_index = JOY_BUTTON_B
+ cancel_button.pressed = true
+ assert(not bool(main.call("_is_pause_open_request", cancel_button)))
+ var start_button := InputEventJoypadButton.new()
+ start_button.button_index = JOY_BUTTON_START
+ start_button.pressed = true
+ assert(bool(main.call("_is_pause_open_request", start_button)))
+ var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
+ player_menu.open_section(PlayerMenu.Section.PROFILE)
+ for _frame: int in 12:
+ await process_frame
+ assert(game_ui.is_gameplay_hud_hidden())
+ assert(player_menu.visible)
+ assert(root.gui_get_focus_owner() is not LineEdit)
+ player_menu.close_menu(PlayerMenu.CloseReason.SESSION_END)
+ await process_frame
+ game_ui.call("_on_quick_action_selected", &"hud")
+ await process_frame
+ assert(not game_ui.is_gameplay_hud_hidden())
+ assert(not chat_ui.is_hud_hidden())
+ assert(not service.can_activate())
+ assert(not service.is_active() and not toolbar.visible)
+ assert(player.bag.add_item(ArtShopStock.ART_KIT_ITEM_ID, 1))
+ player.hotbar.select_slot(1)
+ assert(player.hotbar.assign_item(0, ArtShopStock.ART_KIT_ITEM_ID))
+ assert(not service.can_activate())
+ var paint_key := InputEventKey.new()
+ paint_key.physical_keycode = KEY_P
+ paint_key.pressed = true
+ assert(not service.handle_input(paint_key, true))
+ assert(not service.is_active())
+ player.hotbar.select_slot(0)
+ await process_frame
+ assert(service.is_active() and not service.is_placement_mode())
+ assert(toolbar.visible)
+ var ui_root := game_ui.get_node("%UIRoot") as Control
+ assert(toolbar.get_parent() == ui_root)
+ assert(is_zero_approx(toolbar.position.y))
+ assert(
+ is_equal_approx(
+ toolbar.get_global_rect().end.x,
+ ui_root.get_global_rect().end.x,
+ )
+ )
+ assert(toolbar.get_global_rect().end.x <= 1280.0)
+ assert(toolbar.get_global_rect().end.y <= 720.0)
+ var top_panel := toolbar.get_node("%TopPanel") as PanelContainer
+ var color_panel := toolbar.get_node("%ColorPanel") as PanelContainer
+ assert(is_equal_approx(toolbar.size.x, SurfaceDrawingToolbar.TOOLBAR_WIDTH))
+ assert(is_equal_approx(top_panel.size.x, SurfaceDrawingToolbar.TOOLBAR_WIDTH))
+ assert(top_panel.position.is_equal_approx(Vector2.ZERO))
+ assert(color_panel.position.x > 0.0)
+ assert(is_equal_approx(color_panel.get_rect().end.x, top_panel.get_rect().end.x))
+ assert(color_panel.position.y < top_panel.get_rect().end.y)
+ assert(color_panel.get_rect().end.y > top_panel.get_rect().end.y)
+ var toolbar_origin: Vector2 = toolbar.get_global_rect().position
+ var top_pointer := InputEventMouseMotion.new()
+ top_pointer.position = toolbar_origin + Vector2(200.0, 24.0)
+ assert(toolbar.owns_pointer_event(top_pointer))
+ var rail_pointer := InputEventMouseMotion.new()
+ rail_pointer.position = color_panel.get_global_rect().get_center()
+ assert(toolbar.owns_pointer_event(rail_pointer))
+ var empty_pointer := InputEventMouseMotion.new()
+ empty_pointer.position = toolbar_origin + Vector2(200.0, 180.0)
+ assert(not toolbar.owns_pointer_event(empty_pointer))
+
+ var brush_option := toolbar.get_node("%BrushOption") as OptionButton
+ var grid_option := toolbar.get_node("%GridOption") as OptionButton
+ var mode_button := toolbar.get_node("%ModeButton") as Button
+ var eraser_button := toolbar.get_node("%EraserButton") as Button
+ var undo_button := toolbar.get_node("%UndoButton") as Button
+ var hide_button := toolbar.get_node("%HideGuideButton") as Button
+ var restore_button := toolbar.get_node("%RestoreGuideButton") as Button
+ var finalize_button := toolbar.get_node("%FinalizeGuideButton") as Button
+ assert(
+ mode_button != null
+ and eraser_button != null
+ and undo_button != null
+ and hide_button != null
+ and restore_button != null
+ and finalize_button != null
+ )
+ assert(toolbar.get_node_or_null("%CloseButton") == null)
+ var expected_toolbar_icons: Dictionary[Button, String] = {
+ mode_button: "/art/art_kit_marker.png",
+ eraser_button: "/art/art_kit_eraser.png",
+ hide_button: "/art/art_kit_grid_hidden_light.png",
+ restore_button: "/art/art_kit_grid_restore_light.png",
+ finalize_button: "/art/art_kit_grid_finish_light.png",
+ }
+ for icon_button: Button in expected_toolbar_icons:
+ assert(icon_button.text.is_empty())
+ assert(icon_button.icon != null)
+ assert(
+ icon_button.icon.resource_path.ends_with(
+ expected_toolbar_icons[icon_button]
+ )
+ )
+ for icon_button: Button in [
+ eraser_button,
+ undo_button,
+ hide_button,
+ restore_button,
+ finalize_button,
+ ]:
+ assert(icon_button.custom_minimum_size == Vector2(48, 44))
+ assert(icon_button.get_theme_constant("icon_max_width") == 40)
+ var icon_style := (
+ icon_button.get_theme_stylebox("normal") as StyleBoxFlat
+ )
+ assert(is_equal_approx(icon_style.content_margin_left, 4.0))
+ assert(is_equal_approx(icon_style.content_margin_top, 2.0))
+ assert(mode_button.custom_minimum_size == Vector2(48, 48))
+ assert(mode_button.get_theme_constant("icon_max_width") == 40)
+ assert(mode_button.get_index() > finalize_button.get_index())
+ assert(hide_button.button_group != null)
+ assert(hide_button.button_group == restore_button.button_group)
+ assert(hide_button.button_group == finalize_button.button_group)
+ assert(hide_button.button_group.allow_unpress)
+ assert(brush_option.item_count == 4)
+ assert(grid_option.item_count == 4)
+ assert(not brush_option.get_popup().is_item_disabled(0))
+ assert(brush_option.get_popup().is_item_disabled(1))
+ assert(not grid_option.get_popup().is_item_disabled(0))
+ assert(grid_option.get_popup().is_item_disabled(1))
+ var color_buttons: Dictionary = toolbar.get("_color_buttons")
+ assert(color_buttons.size() == SurfaceDrawingPalette.COLORS.size())
+ assert(not (color_buttons[&"chalk_white"] as Button).disabled)
+ assert((color_buttons[&"ocean_teal"] as Button).disabled)
+
+ for product_id: StringName in [
+ &"marker_ocean_teal", &"brush_4x", &"grid_128x",
+ ]:
+ assert(player.art_unlocks.unlock_product(product_id))
+ await process_frame
+ assert(not (color_buttons[&"ocean_teal"] as Button).disabled)
+ assert(not brush_option.get_popup().is_item_disabled(3))
+ assert(not grid_option.get_popup().is_item_disabled(3))
+ assert(service.set_color_id(&"ocean_teal"))
+ assert(service.set_brush_size(4))
+ assert(service.set_grid_size(128))
+ assert(service.get_color_id() == &"ocean_teal")
+ assert(service.get_brush_size() == 4)
+ assert(service.get_grid_size() == 128)
+ var marker_mode_material := mode_button.material as ShaderMaterial
+ assert(marker_mode_material != null)
+ assert(
+ marker_mode_material.shader.resource_path.ends_with(
+ "/ui/art_kit_marker_icon.gdshader"
+ )
+ )
+ assert(
+ marker_mode_material.get_shader_parameter("marker_color")
+ == SurfaceDrawingPalette.get_color(&"ocean_teal")
+ )
+
+ mode_button.pressed.emit()
+ assert(service.is_placement_mode())
+ assert(
+ mode_button.icon.resource_path.ends_with(
+ "/art/art_kit_grid_light.png"
+ )
+ )
+ eraser_button.pressed.emit()
+ assert(service.is_eraser_mode())
+ assert(eraser_button.button_pressed)
+ hide_button.pressed.emit()
+ assert(service.is_placement_mode())
+ assert(not service.is_eraser_mode())
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.HIDE
+ )
+ var world_click := InputEventMouseButton.new()
+ world_click.button_index = MOUSE_BUTTON_LEFT
+ world_click.pressed = true
+ assert(service.handle_input(world_click, true))
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.HIDE
+ )
+ assert(service.is_placement_mode())
+ assert(not service.is_eraser_mode())
+ assert(hide_button.button_pressed)
+ hide_button.pressed.emit()
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.NONE
+ )
+ assert(not service.is_placement_mode() and service.is_eraser_mode())
+
+ restore_button.pressed.emit()
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.RESTORE
+ )
+ finalize_button.pressed.emit()
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.FINALIZE
+ )
+ assert(not restore_button.button_pressed)
+ assert(finalize_button.button_pressed)
+ assert(service.handle_input(world_click, true))
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.FINALIZE
+ )
+ finalize_button.pressed.emit()
+ assert(
+ service.get_armed_guide_action()
+ == NetworkSurfaceDrawingService.GuideAction.NONE
+ )
+ assert(not service.is_placement_mode() and service.is_eraser_mode())
+ (color_buttons[&"ocean_teal"] as Button).pressed.emit()
+ assert(not service.is_eraser_mode())
+ undo_button.pressed.emit()
+ assert(service.is_active())
+ assert(not service.handle_input(paint_key, true))
+ assert(service.is_active() and toolbar.visible)
+ var fishing_spot := main.get_node("%FishingSpot") as FishingSpot
+ await process_frame
+ var held_art_kit_display := player.get("_held_art_kit_display") as Node3D
+ var held_art_kit_sprite := player.get("_held_art_kit_sprite") as Sprite3D
+ var pocket_animation_player := player.get(
+ "_character_animation_player"
+ ) as AnimationPlayer
+ var pocket_animation_name: StringName = player.get(
+ "_pocket_visual_animation"
+ )
+ if (
+ not held_art_kit_display.visible
+ and pocket_animation_player != null
+ and not pocket_animation_name.is_empty()
+ and pocket_animation_player.has_animation(pocket_animation_name)
+ ):
+ var pocket_animation: Animation = (
+ pocket_animation_player.get_animation(pocket_animation_name)
+ )
+ await create_timer(pocket_animation.length * 0.55).timeout
+ assert(held_art_kit_display.visible)
+ assert(held_art_kit_sprite.visible and held_art_kit_sprite.texture != null)
+ assert(held_art_kit_sprite.texture.resource_path.ends_with("/art/art_kit.png"))
+ assert(is_zero_approx(angle_difference(
+ held_art_kit_sprite.rotation.z,
+ -PI * 0.5,
+ )))
+ assert(held_art_kit_sprite.flip_h)
+ assert(not fishing_spot.has_signal(&"art_ui_toggle_requested"))
+ assert(service.is_active() and toolbar.visible)
+ assert(service.get_grid_size() == 128)
+ assert(service.handle_input(world_click, true))
+ assert(service.is_active() and toolbar.visible)
+ var hotbar_ui := game_ui.get_node("%Hotbar") as HotbarUI
+ var wheel_down := InputEventMouseButton.new()
+ wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN
+ wheel_down.pressed = true
+ assert(not service.handle_input(wheel_down, true))
+ hotbar_ui._unhandled_input(wheel_down)
+ await process_frame
+ assert(player.hotbar.get_selected_slot() == 1)
+ assert(not service.is_active() and not toolbar.visible)
+
+ var number_one := InputEventKey.new()
+ number_one.physical_keycode = KEY_1
+ number_one.pressed = true
+ assert(not service.handle_input(number_one, true))
+ hotbar_ui._unhandled_input(number_one)
+ await process_frame
+ assert(service.is_active() and toolbar.visible)
+ assert(player.hotbar.get_selected_slot() == 0)
+ var number_two := InputEventKey.new()
+ number_two.physical_keycode = KEY_2
+ number_two.pressed = true
+ assert(not service.handle_input(number_two, true))
+ hotbar_ui._unhandled_input(number_two)
+ await process_frame
+ assert(player.hotbar.get_selected_slot() == 1)
+ assert(not service.is_active() and not toolbar.visible)
+ game_ui.call("_on_quick_action_selected", &"paint")
+ await process_frame
+ assert(player.hotbar.get_selected_slot() == 0)
+ assert(service.is_active() and toolbar.visible)
+
+ print("Art tools validation: PASS")
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
diff --git a/tests/art_tools_validation.gd.uid b/tests/art_tools_validation.gd.uid
new file mode 100644
index 0000000..7c7edc5
--- /dev/null
+++ b/tests/art_tools_validation.gd.uid
@@ -0,0 +1 @@
+uid://dr671f64t58wc
diff --git a/tests/character_scale_validation.gd b/tests/character_scale_validation.gd
new file mode 100644
index 0000000..213f64f
--- /dev/null
+++ b/tests/character_scale_validation.gd
@@ -0,0 +1,117 @@
+extends SceneTree
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var legacy_snapshot: Dictionary = (
+ CharacterCustomizationCatalog.default_snapshot()
+ )
+ legacy_snapshot.erase(CharacterCustomizationCatalog.SCALE_CATEGORY_ID)
+ var migrated: Dictionary = (
+ CharacterCustomizationCatalog.sanitized_snapshot(legacy_snapshot)
+ )
+ assert(
+ is_equal_approx(
+ float(migrated[CharacterCustomizationCatalog.SCALE_CATEGORY_ID]),
+ CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
+ )
+ )
+ assert(CharacterCustomizationCatalog.validate_snapshot(migrated))
+
+ var small_snapshot := migrated.duplicate(true)
+ small_snapshot[CharacterCustomizationCatalog.SCALE_CATEGORY_ID] = (
+ CharacterCustomizationCatalog.MIN_CHARACTER_SCALE
+ )
+ assert(CharacterCustomizationCatalog.validate_snapshot(small_snapshot))
+ var large_snapshot := migrated.duplicate(true)
+ large_snapshot[CharacterCustomizationCatalog.SCALE_CATEGORY_ID] = (
+ CharacterCustomizationCatalog.MAX_CHARACTER_SCALE
+ )
+ assert(CharacterCustomizationCatalog.validate_snapshot(large_snapshot))
+ var invalid_snapshot := migrated.duplicate(true)
+ invalid_snapshot[CharacterCustomizationCatalog.SCALE_CATEGORY_ID] = 2.0
+ assert(not CharacterCustomizationCatalog.validate_snapshot(invalid_snapshot))
+
+ var visuals: Node3D = PlayerVisualPresenter.instantiate_visuals()
+ root.add_child(visuals)
+ PlayerVisualPresenter.apply_appearance(visuals, large_snapshot)
+ assert(visuals.scale.is_equal_approx(
+ Vector3.ONE * CharacterCustomizationCatalog.MAX_CHARACTER_SCALE
+ ))
+ var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
+ assert(skeleton != null)
+ var body := skeleton.get_node_or_null("body_main") as MeshInstance3D
+ assert(body != null)
+ var body_material := body.material_override as StandardMaterial3D
+ assert(body_material != null)
+ assert(
+ body_material.texture_filter
+ == BaseMaterial3D.TEXTURE_FILTER_NEAREST
+ )
+ assert(body_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED)
+ assert(is_zero_approx(body_material.metallic))
+ assert(is_zero_approx(body_material.metallic_specular))
+ assert(is_equal_approx(body_material.roughness, 1.0))
+ assert(body_material.diffuse_mode == BaseMaterial3D.DIFFUSE_LAMBERT)
+ assert(not body_material.anisotropy_enabled)
+ assert(not body_material.clearcoat_enabled)
+ assert(not body_material.rim_enabled)
+
+ var signed_small := {"appearance": small_snapshot}
+ var signed_large := {"appearance": large_snapshot}
+ assert(
+ NetworkProfileProtocol.signature_fields(signed_small)
+ != NetworkProfileProtocol.signature_fields(signed_large)
+ )
+
+ assert(
+ ProfilePreview.calculate_pixelated_grid_size(
+ Vector2(260.0, 330.0),
+ Vector2(1280.0, 720.0),
+ 3,
+ ) == Vector2i(130, 165)
+ )
+ assert(
+ ProfilePreview.calculate_pixelated_grid_size(
+ Vector2(260.0, 330.0),
+ Vector2(640.0, 480.0),
+ 3,
+ ) == Vector2i(65, 83)
+ )
+
+ var profile_page := ProfilePage.new()
+ root.add_child(profile_page)
+ await process_frame
+ profile_page.call(
+ "_select_category",
+ CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
+ )
+ await process_frame
+ var slider := profile_page.find_child(
+ "CharacterScaleSlider", true, false
+ ) as HSlider
+ assert(slider != null)
+ assert(is_equal_approx(
+ float(slider.min_value),
+ CharacterCustomizationCatalog.MIN_CHARACTER_SCALE,
+ ))
+ assert(is_equal_approx(
+ float(slider.max_value),
+ CharacterCustomizationCatalog.MAX_CHARACTER_SCALE,
+ ))
+ slider.value = 1.15
+ await process_frame
+ var draft: Dictionary = profile_page.get("_draft_appearance")
+ assert(is_equal_approx(
+ float(draft[CharacterCustomizationCatalog.SCALE_CATEGORY_ID]),
+ 1.15,
+ ))
+
+ print("Character scale validation: PASS")
+ profile_page.queue_free()
+ visuals.queue_free()
+ await process_frame
+ quit()
diff --git a/tests/character_scale_validation.gd.uid b/tests/character_scale_validation.gd.uid
new file mode 100644
index 0000000..c74c5d0
--- /dev/null
+++ b/tests/character_scale_validation.gd.uid
@@ -0,0 +1 @@
+uid://d0w8gv1i4paii
diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd
new file mode 100644
index 0000000..f0d0355
--- /dev/null
+++ b/tests/controller_focus_presentation_validation.gd
@@ -0,0 +1,84 @@
+extends SceneTree
+
+const FocusPresentationType = preload(
+ "res://ui/controller_focus_presentation.gd"
+)
+
+var _failures: Array[String] = []
+
+
+func _initialize() -> void:
+ _run.call_deferred()
+
+
+func _run() -> void:
+ var stage := Control.new()
+ stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ root.add_child(stage)
+ var presentation := FocusPresentationType.new()
+ stage.add_child(presentation)
+ var standard_button := Button.new()
+ standard_button.text = "standard"
+ standard_button.custom_minimum_size = Vector2(120.0, 60.0)
+ stage.add_child(standard_button)
+ var authored_selector := Button.new()
+ authored_selector.text = "authored selector"
+ authored_selector.position = Vector2(140.0, 0.0)
+ authored_selector.custom_minimum_size = Vector2(160.0, 60.0)
+ authored_selector.set_meta(&"controller_focus_inversion_disabled", true)
+ stage.add_child(authored_selector)
+ await process_frame
+
+ var controller_event := InputEventJoypadButton.new()
+ controller_event.button_index = JOY_BUTTON_A
+ controller_event.pressed = true
+ presentation._input(controller_event)
+ standard_button.grab_focus()
+ await process_frame
+ _expect(
+ standard_button.material != null,
+ "ordinary controller focus receives inversion",
+ )
+ var pointer_motion := InputEventMouseMotion.new()
+ pointer_motion.position = Vector2(4.0, 4.0)
+ presentation._input(pointer_motion)
+ await process_frame
+ _expect(
+ standard_button.material == null,
+ "mouse motion clears controller focus presentation",
+ )
+ presentation._input(controller_event)
+
+ authored_selector.grab_focus()
+ await process_frame
+ _expect(
+ standard_button.material == null,
+ "inversion clears when focus moves",
+ )
+ _expect(
+ authored_selector.material == null,
+ "authored selector backgrounds opt out of inversion",
+ )
+
+ standard_button.grab_focus()
+ await process_frame
+ standard_button.focus_mode = Control.FOCUS_NONE
+ await process_frame
+ _expect(
+ standard_button.material == null,
+ "inversion clears when a focused control is defocused",
+ )
+
+ stage.queue_free()
+ if _failures.is_empty():
+ print("Controller focus presentation validation: PASS")
+ quit(0)
+ return
+ for failure: String in _failures:
+ push_error(failure)
+ quit(1)
+
+
+func _expect(condition: bool, message: String) -> void:
+ if not condition:
+ _failures.append(message)
diff --git a/tests/controller_focus_presentation_validation.gd.uid b/tests/controller_focus_presentation_validation.gd.uid
new file mode 100644
index 0000000..0cacf4a
--- /dev/null
+++ b/tests/controller_focus_presentation_validation.gd.uid
@@ -0,0 +1 @@
+uid://bjay8ki3albk6
diff --git a/tests/controller_focus_recovery_validation.gd b/tests/controller_focus_recovery_validation.gd
new file mode 100644
index 0000000..f598511
--- /dev/null
+++ b/tests/controller_focus_recovery_validation.gd
@@ -0,0 +1,184 @@
+extends SceneTree
+
+const ControllerFocusRecoveryType = preload(
+ "res://ui/controller_focus_recovery.gd"
+)
+
+var _failures: Array[String] = []
+
+
+func _initialize() -> void:
+ _run.call_deferred()
+
+
+func _run() -> void:
+ var stage := Control.new()
+ stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ root.add_child(stage)
+ var recovery := ControllerFocusRecoveryType.new()
+ stage.add_child(recovery)
+ var option_list := VBoxContainer.new()
+ option_list.position = Vector2(100.0, 100.0)
+ stage.add_child(option_list)
+ var original := _make_button("cute", "cute (show variants)")
+ option_list.add_child(original)
+ await process_frame
+ original.grab_focus()
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "programmatic focus stays pending outside navigation mode",
+ )
+ var keyboard_navigation := InputEventKey.new()
+ keyboard_navigation.keycode = KEY_DOWN
+ keyboard_navigation.pressed = true
+ recovery._input(keyboard_navigation)
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == original,
+ "keyboard navigation restores pending initial focus",
+ )
+ var menu_shortcut := InputEventKey.new()
+ menu_shortcut.physical_keycode = KEY_TAB
+ menu_shortcut.pressed = true
+ recovery._input(menu_shortcut)
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "the menu shortcut does not leave a pseudo-focused option",
+ )
+
+ var controller_event := InputEventJoypadButton.new()
+ controller_event.button_index = JOY_BUTTON_A
+ controller_event.pressed = true
+ recovery._input(controller_event)
+ await process_frame
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == original,
+ "controller navigation restores pending initial focus",
+ )
+
+ option_list.remove_child(original)
+ original.queue_free()
+ var replacement := _make_button("cute", "cute (hide variants)")
+ option_list.add_child(replacement)
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == replacement,
+ "focus follows a rebuilt semantic option",
+ )
+
+ var explicit_target := _make_button("explicit", "explicit")
+ option_list.add_child(explicit_target)
+ option_list.remove_child(replacement)
+ replacement.queue_free()
+ explicit_target.grab_focus()
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == explicit_target,
+ "explicit focus changes take precedence over recovery",
+ )
+ var pointer_motion := InputEventMouseMotion.new()
+ pointer_motion.position = Vector2(4.0, 4.0)
+ recovery._input(pointer_motion)
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "mouse motion releases controller focus",
+ )
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "mouse motion prevents automatic focus recovery",
+ )
+ recovery._input(controller_event)
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == explicit_target,
+ "controller navigation can resume from pointer mode",
+ )
+ var pointer_press := InputEventMouseButton.new()
+ pointer_press.button_index = MOUSE_BUTTON_LEFT
+ pointer_press.pressed = true
+ recovery._input(pointer_press)
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == explicit_target,
+ "mouse-down preserves focus through button activation",
+ )
+ var pointer_release := InputEventMouseButton.new()
+ pointer_release.button_index = MOUSE_BUTTON_LEFT
+ pointer_release.pressed = false
+ recovery._input(pointer_release)
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "mouse-up clears the completed button focus",
+ )
+ recovery._input(controller_event)
+ await process_frame
+ await process_frame
+
+ var outside_button := _make_button("outside", "outside")
+ stage.add_child(outside_button)
+ option_list.hide()
+ root.gui_release_focus()
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "focus recovery never escapes a hidden menu scope",
+ )
+ option_list.show()
+ explicit_target.grab_focus()
+ await process_frame
+ var leave_world_ui_event := InputEventJoypadButton.new()
+ leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER
+ leave_world_ui_event.pressed = true
+ recovery._input(leave_world_ui_event)
+ root.gui_release_focus()
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "LB intentionally leaving world UI is never recovered",
+ )
+ var accessibility_only := Control.new()
+ accessibility_only.focus_mode = Control.FOCUS_ACCESSIBILITY
+ stage.add_child(accessibility_only)
+ await process_frame
+ _expect(
+ not bool(recovery.call("_is_focusable", accessibility_only)),
+ "accessibility-only controls are not recovery targets",
+ )
+
+ stage.queue_free()
+ if _failures.is_empty():
+ print("Controller focus recovery validation: PASS")
+ quit(0)
+ return
+ for failure: String in _failures:
+ push_error(failure)
+ quit(1)
+
+
+func _make_button(text: String, tooltip: String) -> Button:
+ var button := Button.new()
+ button.text = text
+ button.tooltip_text = tooltip
+ button.custom_minimum_size = Vector2(120.0, 48.0)
+ return button
+
+
+func _expect(condition: bool, message: String) -> void:
+ if not condition:
+ _failures.append(message)
diff --git a/tests/controller_focus_recovery_validation.gd.uid b/tests/controller_focus_recovery_validation.gd.uid
new file mode 100644
index 0000000..7bcb1a2
--- /dev/null
+++ b/tests/controller_focus_recovery_validation.gd.uid
@@ -0,0 +1 @@
+uid://cqk75fxykqrhs
diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd
new file mode 100644
index 0000000..16c9ad0
--- /dev/null
+++ b/tests/controller_mapping_validation.gd
@@ -0,0 +1,388 @@
+extends SceneTree
+
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const ControllerMappingPanelType = preload(
+ "res://ui/controller_mapping_panel.gd"
+)
+const GameUIType = preload("res://ui/game_ui.gd")
+
+
+func _init() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var manager := ControllerMappingManagerType.new()
+ root.add_child(manager)
+ await process_frame
+ var failure: String = _validate_manager(manager)
+ if failure.is_empty():
+ failure = _validate_portmaster_launcher()
+ if failure.is_empty():
+ failure = _validate_auto_map(manager)
+ if failure.is_empty():
+ failure = _validate_virtual_mouse_bounds()
+ if failure.is_empty():
+ failure = _validate_controller_scroll_target()
+ if failure.is_empty():
+ print("controller mapping validation passed")
+ quit(0)
+ return
+ push_error(failure)
+ quit(1)
+
+
+func _validate_manager(manager: ControllerMappingManagerType) -> String:
+ var muos_mapping: String = (
+ ControllerMappingManagerType.build_muos_controller_mapping(
+ "19000000010000000100000000010000",
+ "muOS-Keys",
+ )
+ )
+ for expected_binding: String in [
+ "a:b0",
+ "b:b1",
+ "x:b3",
+ "y:b2",
+ "leftshoulder:b4",
+ "rightshoulder:b5",
+ "lefttrigger:b10",
+ "righttrigger:b11",
+ "back:b6",
+ "start:b7",
+ "guide:b8",
+ "leftstick:b9",
+ "rightstick:b12",
+ "leftx:a0",
+ "righty:a3",
+ ]:
+ if expected_binding not in muos_mapping:
+ return "muOS compatibility mapping omitted " + expected_binding
+ if not muos_mapping.begins_with(
+ "19000000010000000100000000010000,muOS-Keys,"
+ ):
+ return "muOS compatibility mapping does not use the runtime guid"
+ if not ControllerMappingManagerType.is_muos_controller_name(
+ "muOS-Keys"
+ ):
+ return "muOS controller name was not recognized"
+ if ControllerMappingManagerType.is_muos_controller_name(
+ "ordinary controller"
+ ):
+ return "muOS mapping would affect unrelated controllers"
+ if not ControllerMappingManagerType.should_install_muos_compatibility_mapping(
+ "muOS-Keys",
+ false,
+ ):
+ return "unknown legacy muOS controller did not receive fallback mapping"
+ if ControllerMappingManagerType.should_install_muos_compatibility_mapping(
+ "muOS-Keys",
+ true,
+ ):
+ return "recognized muOS controller would be replaced at runtime"
+ if ControllerMappingManagerType.should_install_muos_compatibility_mapping(
+ "ordinary controller",
+ false,
+ ):
+ return "muOS fallback would affect an unrelated unknown controller"
+ var defaults: Dictionary = manager.get_active_bindings()
+ if defaults.size() != ControllerMappingManagerType.ROLE_ORDER.size():
+ return "default mapping covers %d of %d controller roles: %s" % [
+ defaults.size(),
+ ControllerMappingManagerType.ROLE_ORDER.size(),
+ str(defaults.keys()),
+ ]
+ var trigger_button := InputEventJoypadButton.new()
+ trigger_button.device = manager.get_active_device_id()
+ trigger_button.button_index = JOY_BUTTON_MISC1
+ trigger_button.pressed = true
+ var trigger_binding: Dictionary = manager.binding_from_event(
+ ControllerMappingManagerType.ROLE_POINTER_MODIFIER,
+ trigger_button,
+ )
+ if str(trigger_binding.get("kind", "")) != "button":
+ return "trigger role did not accept a button-backed handheld trigger"
+ var trigger_motion := InputEventJoypadMotion.new()
+ trigger_motion.device = manager.get_active_device_id()
+ trigger_motion.axis = JOY_AXIS_TRIGGER_RIGHT
+ trigger_motion.axis_value = 1.0
+ trigger_binding = manager.binding_from_event(
+ ControllerMappingManagerType.ROLE_POINTER_MODIFIER,
+ trigger_motion,
+ )
+ if str(trigger_binding.get("kind", "")) != "axis":
+ return "trigger role did not accept an axis-backed controller trigger"
+ var stick_button := manager.binding_from_event(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X,
+ trigger_button,
+ )
+ if not stick_button.is_empty():
+ return "stick axis role accepted a button"
+ var keyboard_events_before: int = _keyboard_event_count(&"jump")
+ var custom: Dictionary = defaults.duplicate(true)
+ custom[str(ControllerMappingManagerType.ROLE_A)] = {
+ "kind": "button",
+ "button": int(JOY_BUTTON_X),
+ }
+ custom[str(ControllerMappingManagerType.ROLE_POINTER_MODIFIER)] = (
+ trigger_binding
+ )
+ if not manager.replace_active_bindings(custom):
+ return "valid custom mapping could not be saved"
+ if not manager.has_custom_mapping():
+ return "saved custom mapping did not become active"
+ if _keyboard_event_count(&"jump") != keyboard_events_before:
+ return "controller remapping changed keyboard bindings"
+ var active_button := InputEventJoypadButton.new()
+ active_button.device = manager.get_active_device_id()
+ active_button.button_index = JOY_BUTTON_X
+ active_button.pressed = true
+ if not manager.event_matches_role(
+ active_button,
+ ControllerMappingManagerType.ROLE_A,
+ ):
+ return "active controller event did not match its custom role"
+ var inactive_button := active_button.duplicate() as InputEventJoypadButton
+ inactive_button.device = manager.get_active_device_id() + 1
+ if manager.event_matches_role(
+ inactive_button,
+ ControllerMappingManagerType.ROLE_A,
+ ):
+ return "inactive controller event matched the active profile"
+ var inactive_release := InputEventJoypadButton.new()
+ inactive_release.device = manager.get_active_device_id() + 2
+ inactive_release.button_index = JOY_BUTTON_A
+ inactive_release.pressed = false
+ var active_device_before_release: int = manager.get_active_device_id()
+ manager._input(inactive_release)
+ if manager.get_active_device_id() != active_device_before_release:
+ return "inactive controller button release stole ownership"
+ for event: InputEvent in InputMap.action_get_events(&"jump"):
+ if (
+ event is InputEventJoypadButton
+ and event.device != manager.get_active_device_id()
+ ):
+ return "custom InputMap event was not scoped to the active device"
+ manager._axis_rest_by_device["4"] = {str(JOY_AXIS_LEFT_X): 0.0}
+ var drift := InputEventJoypadMotion.new()
+ drift.device = 4
+ drift.axis = JOY_AXIS_LEFT_X
+ drift.axis_value = 0.12
+ if manager._axis_motion_claims_device(drift):
+ return "minor inactive-stick drift claimed controller ownership"
+ manager._input(drift)
+ if manager.get_active_device_id() != active_device_before_release:
+ return "minor inactive-stick drift changed the active controller"
+ drift.axis_value = 0.72
+ if not manager._axis_motion_claims_device(drift):
+ return "intentional inactive-stick motion did not claim ownership"
+ manager._input(drift)
+ if manager.get_active_device_id() != drift.device:
+ return "intentional stick motion did not change controller ownership"
+ for event: InputEvent in InputMap.action_get_events(&"jump"):
+ if event is InputEventJoypadButton and event.device != drift.device:
+ return "active-device change did not rescope InputMap events"
+ manager._set_active_controller(active_device_before_release)
+ manager._axis_rest_by_device["5"] = {str(JOY_AXIS_LEFT_X): 0.0}
+ manager._on_joy_connection_changed(5, false)
+ if manager._axis_rest_by_device.has("5"):
+ return "disconnected controller retained stale axis calibration"
+ if not FileAccess.file_exists(
+ ControllerMappingManagerType.PROFILE_PATH
+ ):
+ return "controller mapping was not stored device-locally"
+ return ""
+
+
+func _validate_portmaster_launcher() -> String:
+ const launcher_path: String = "res://scripts/portmaster/NETfishing.sh"
+ if not FileAccess.file_exists(launcher_path):
+ return "PortMaster launcher template is missing"
+ var launcher: String = FileAccess.get_file_as_string(launcher_path)
+ for expected_fragment: String in [
+ "19004ca6010000000100000000010000",
+ "19000000010000000100000000010000",
+ "a:b0,b:b1,x:b3,y:b2",
+ "leftshoulder:b4,rightshoulder:b5",
+ "lefttrigger:b10,righttrigger:b11",
+ "guide:b8,start:b7,back:b6",
+ "leftstick:b9",
+ "rightstick:b12",
+ "netfishing_controllerconfig=\"$GODOT_MUOS_MAPPING\"",
+ "SDL_GAMECONTROLLERCONFIG=\"$netfishing_controllerconfig\" \\",
+ ]:
+ if expected_fragment not in launcher:
+ return "PortMaster launcher omitted " + expected_fragment
+ if "$'\\n'" in launcher:
+ return "PortMaster launcher appends mappings across a Weston-unsafe newline"
+ return ""
+
+
+func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
+ var panel := ControllerMappingPanelType.new()
+ root.add_child(panel)
+ panel.setup(manager)
+ panel.open_panel()
+ panel._begin_auto_map()
+ for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
+ panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
+ var binding := (
+ ControllerMappingManagerType.default_bindings()[str(role)]
+ as Dictionary
+ )
+ if role == ControllerMappingManagerType.ROLE_B:
+ binding = (
+ ControllerMappingManagerType.default_bindings()[
+ str(ControllerMappingManagerType.ROLE_A)
+ ] as Dictionary
+ )
+ if str(binding.get("kind", "")) == "button":
+ var button := InputEventJoypadButton.new()
+ button.device = manager.get_active_device_id()
+ button.button_index = int(binding.get("button", -1))
+ button.pressed = true
+ manager.controller_input_observed.emit(button)
+ else:
+ var motion := InputEventJoypadMotion.new()
+ motion.device = manager.get_active_device_id()
+ motion.axis = int(binding.get("axis", -1))
+ motion.axis_value = float(binding.get("direction", -1.0))
+ manager.controller_input_observed.emit(motion)
+ if panel._auto_map_active:
+ return "auto-map did not complete after every requested input"
+ if not manager.has_custom_mapping():
+ return "completed auto-map did not activate its profile"
+ if panel._binding_buttons.size() != (
+ ControllerMappingManagerType.ROLE_ORDER.size()
+ ):
+ return "manual override list does not expose every mapped role"
+ var conflict_button := panel._binding_buttons.get(
+ ControllerMappingManagerType.ROLE_A
+ ) as Button
+ var conflict_style := conflict_button.get_theme_stylebox(
+ &"normal"
+ ) as StyleBoxFlat
+ if (
+ conflict_style == null
+ or conflict_style.bg_color
+ != UtilityPageStyle.OCEAN_DANGER
+ ):
+ return "duplicate controller bindings were not marked in red"
+ manager.set_binding(
+ ControllerMappingManagerType.ROLE_B,
+ ControllerMappingManagerType.default_bindings()[
+ str(ControllerMappingManagerType.ROLE_B)
+ ] as Dictionary,
+ )
+ panel._begin_manual_capture(
+ ControllerMappingManagerType.ROLE_POINTER_MODIFIER
+ )
+ panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
+ if "left trigger" in panel._progress_label.text.to_lower():
+ return "manual remapping still dictates a specific physical input"
+ if "any button" not in panel._progress_label.text.to_lower():
+ return "manual remapping does not request a generic controller input"
+ panel._cancel_capture()
+ panel._begin_auto_map()
+ panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
+ var lone_button := InputEventJoypadButton.new()
+ lone_button.device = manager.get_active_device_id()
+ lone_button.button_index = JOY_BUTTON_B
+ lone_button.pressed = true
+ manager.controller_input_observed.emit(lone_button)
+ if not panel._auto_map_active or panel._auto_map_index != 1:
+ return "a single controller button cancelled auto-map"
+ panel._cancel_capture()
+ panel._begin_auto_map()
+ panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
+ var left_bumper := InputEventJoypadButton.new()
+ left_bumper.device = manager.get_active_device_id()
+ left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER
+ left_bumper.pressed = true
+ manager.controller_input_observed.emit(left_bumper)
+ var right_bumper := InputEventJoypadButton.new()
+ right_bumper.device = manager.get_active_device_id()
+ right_bumper.button_index = JOY_BUTTON_RIGHT_SHOULDER
+ right_bumper.pressed = true
+ manager.controller_input_observed.emit(right_bumper)
+ panel._process(ControllerMappingPanelType.CANCEL_COMBO_HOLD_SECONDS - 0.01)
+ if not panel._auto_map_active:
+ return "bumper combo cancelled auto-map before the hold threshold"
+ panel._process(0.02)
+ if panel._auto_map_active or panel.is_capturing():
+ return "sustained bumper combo did not cancel auto-map"
+ panel._begin_auto_map()
+ panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
+ var held_left := InputEventJoypadMotion.new()
+ held_left.device = manager.get_active_device_id()
+ held_left.axis = JOY_AXIS_LEFT_X
+ held_left.axis_value = -1.0
+ for _index: int in 4:
+ manager.controller_input_observed.emit(held_left)
+ if panel._auto_map_index != 0:
+ return "auto-map accepted repeated analog motion without a neutral gate"
+ var first_button := InputEventJoypadButton.new()
+ first_button.device = manager.get_active_device_id()
+ first_button.button_index = JOY_BUTTON_A
+ first_button.pressed = true
+ manager.controller_input_observed.emit(first_button)
+ if panel._auto_map_index != 1 or not panel._waiting_for_neutral:
+ return "auto-map did not gate the next step after a captured input"
+ manager.controller_input_observed.emit(first_button)
+ if panel._auto_map_index != 1:
+ return "held input advanced more than one auto-map step"
+ panel._cancel_capture()
+ return ""
+
+
+func _validate_virtual_mouse_bounds() -> String:
+ var window_size := Vector2(3840.0, 2160.0)
+ var bounds: Rect2 = GameUIType.get_virtual_mouse_window_bounds(window_size)
+ var expected_margin := Vector2(42.0, 42.0)
+ if not bounds.position.is_equal_approx(expected_margin):
+ return "4K virtual cursor minimum bounds are incorrect: %s" % bounds
+ if not bounds.end.is_equal_approx(window_size - expected_margin):
+ return "4K virtual cursor maximum bounds are incorrect: %s" % bounds
+ return ""
+
+
+func _validate_controller_scroll_target() -> String:
+ var game_ui := GameUIType.new()
+ var page := VBoxContainer.new()
+ var scroll := ScrollContainer.new()
+ var content := VBoxContainer.new()
+ var button := Button.new()
+ var footer_button := Button.new()
+ root.add_child(page)
+ page.add_child(scroll)
+ scroll.add_child(content)
+ content.add_child(button)
+ page.add_child(footer_button)
+ var target: ScrollContainer = game_ui._focused_scroll_container(button)
+ if target != scroll:
+ game_ui.free()
+ page.free()
+ return "focused menu content did not resolve its scroll container"
+ target = game_ui._focused_scroll_container(footer_button)
+ if target != scroll:
+ game_ui.free()
+ page.free()
+ return "menu footer focus did not resolve the page's only scroll container"
+ scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ if game_ui._focused_scroll_container(button) != null:
+ game_ui.free()
+ page.free()
+ return "controller scrolling ignored a disabled scroll container"
+ game_ui.free()
+ page.free()
+ return ""
+
+
+func _keyboard_event_count(action: StringName) -> int:
+ var count: int = 0
+ for event: InputEvent in InputMap.action_get_events(action):
+ if event is InputEventKey or event is InputEventMouseButton:
+ count += 1
+ return count
diff --git a/tests/controller_mapping_validation.gd.uid b/tests/controller_mapping_validation.gd.uid
new file mode 100644
index 0000000..7840fe3
--- /dev/null
+++ b/tests/controller_mapping_validation.gd.uid
@@ -0,0 +1 @@
+uid://dhwkjojmpjc13
diff --git a/tests/controller_ui_navigation_validation.gd b/tests/controller_ui_navigation_validation.gd
new file mode 100644
index 0000000..7cecbab
--- /dev/null
+++ b/tests/controller_ui_navigation_validation.gd
@@ -0,0 +1,82 @@
+extends SceneTree
+
+const ControllerFocusNavigationType = preload(
+ "res://ui/controller_focus_navigation.gd"
+)
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_spatial_navigation()
+ _validate_controller_hierarchy_contract()
+ _validate_four_by_three_centering()
+ _validate_low_end_profile_contract()
+ print("Controller UI navigation validation: PASS")
+ quit()
+
+
+func _validate_spatial_navigation() -> void:
+ var host := Control.new()
+ root.add_child(host)
+ var center := _make_button(host, "center", Vector2(200.0, 200.0))
+ var left := _make_button(host, "left", Vector2(50.0, 200.0))
+ var right := _make_button(host, "right", Vector2(350.0, 200.0))
+ var top := _make_button(host, "top", Vector2(200.0, 50.0))
+ var bottom := _make_button(host, "bottom", Vector2(200.0, 350.0))
+ var controls: Array[Control] = [center, left, right, top, bottom]
+ ControllerFocusNavigationType.configure_spatial_neighbors(controls)
+ assert(center.get_node(center.focus_neighbor_left) == left)
+ assert(center.get_node(center.focus_neighbor_right) == right)
+ assert(center.get_node(center.focus_neighbor_top) == top)
+ assert(center.get_node(center.focus_neighbor_bottom) == bottom)
+ host.queue_free()
+
+
+func _validate_controller_hierarchy_contract() -> void:
+ var source: String = FileAccess.get_file_as_string(
+ "res://ui/player_menu.gd"
+ )
+ assert(source.contains("ROLE_POINTER_MODIFIER"))
+ assert(source.contains("ROLE_CAMERA_ZOOM"))
+ assert(source.contains("_handle_controller_secondary_switch"))
+ assert(source.contains("ROLE_LB"))
+ assert(source.contains("ROLE_RB"))
+ assert(source.contains("_reserve_main_navigation_for_page_switching"))
+
+
+func _validate_four_by_three_centering() -> void:
+ var stage_position: Vector2 = (
+ UIReferencePresentationType.get_stage_position(Vector2(640.0, 480.0))
+ )
+ assert(stage_position.is_equal_approx(Vector2(0.0, 120.0)))
+
+
+func _validate_low_end_profile_contract() -> void:
+ var launcher: String = FileAccess.get_file_as_string(
+ "res://scripts/portmaster/NETfishing.sh"
+ )
+ assert(launcher.contains("allwinner,h616"))
+ assert(launcher.contains("sun50iw9p1"))
+ assert(launcher.contains("NETFISHING_LOW_END=1"))
+ assert(launcher.contains("--max-fps 30"))
+ assert(launcher.contains("--audio-output-latency 40"))
+
+
+func _make_button(
+ host: Control,
+ button_name: String,
+ button_position: Vector2,
+) -> Button:
+ var button := Button.new()
+ button.name = button_name
+ button.position = button_position
+ button.size = Vector2(80.0, 80.0)
+ button.focus_mode = Control.FOCUS_ALL
+ host.add_child(button)
+ return button
diff --git a/tests/controller_ui_navigation_validation.gd.uid b/tests/controller_ui_navigation_validation.gd.uid
new file mode 100644
index 0000000..73ef752
--- /dev/null
+++ b/tests/controller_ui_navigation_validation.gd.uid
@@ -0,0 +1 @@
+uid://cbv0ia42rqu41
diff --git a/tests/dedicated_host_session_validation.gd b/tests/dedicated_host_session_validation.gd
new file mode 100644
index 0000000..2d098bd
--- /dev/null
+++ b/tests/dedicated_host_session_validation.gd
@@ -0,0 +1,60 @@
+extends SceneTree
+
+const TEST_PORT: int = 35777
+const OPERATOR_FINGERPRINT: String = (
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+)
+
+
+func _init() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var harness := Node.new()
+ root.add_child(harness)
+ var players := Node3D.new()
+ harness.add_child(players)
+ var spawn_service := PlayerSpawnService.new()
+ harness.add_child(spawn_service)
+ spawn_service.setup(players, null, Transform3D.IDENTITY)
+ var host_identity := HostIdentityStore.new()
+ harness.add_child(host_identity)
+ var known_players := KnownPlayerStore.new()
+ harness.add_child(known_players)
+ var host_bans := HostBanStore.new()
+ harness.add_child(host_bans)
+ var session := NetworkSession.new()
+ harness.add_child(session)
+ await process_frame
+ session.setup(
+ null,
+ null,
+ spawn_service,
+ null,
+ host_identity,
+ known_players,
+ null,
+ host_bans,
+ true,
+ )
+ assert(session.configure_dedicated_operators(
+ PackedStringArray([OPERATOR_FINGERPRINT])
+ ))
+ assert(session.start_dedicated_host(TEST_PORT, 5, "127.0.0.1"))
+ assert(session.is_dedicated_host())
+ assert(session.get_local_peer_id() == 0)
+ assert(session.get_player_count() == 0)
+ assert(session.get_session_max_players() == 5)
+ assert(session.get_host_port() == TEST_PORT)
+ assert(bool(session.call(
+ "_operator_for_fingerprint", OPERATOR_FINGERPRINT
+ )))
+ assert(session.set_host_open(true))
+ assert(session.is_open_host())
+ session.disconnect_session("Dedicated host validation complete.")
+ assert(not session.is_session_active())
+ assert(not session.is_dedicated_host())
+ harness.queue_free()
+ print("DEDICATED_HOST_SESSION_VALIDATION_OK")
+ quit(0)
diff --git a/tests/dedicated_host_session_validation.gd.uid b/tests/dedicated_host_session_validation.gd.uid
new file mode 100644
index 0000000..e50ebb3
--- /dev/null
+++ b/tests/dedicated_host_session_validation.gd.uid
@@ -0,0 +1 @@
+uid://ctky0gkb63nqa
diff --git a/tests/dedicated_server_client_validation.gd b/tests/dedicated_server_client_validation.gd
new file mode 100644
index 0000000..9688af2
--- /dev/null
+++ b/tests/dedicated_server_client_validation.gd
@@ -0,0 +1,49 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const TEST_ENDPOINT: String = "127.0.0.1:7777"
+const EXPECTED_SERVER_NAME: String = "Dedicated-Client-Test"
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ main.call("_on_title_join_game_requested", TEST_ENDPOINT)
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var joined: bool = false
+ var deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ joined = true
+ break
+ assert(joined)
+ assert(session.get_local_peer_id() > 1)
+ assert(session.get_player_count() == 1)
+ var metadata: Dictionary = session.get_last_server_metadata()
+ assert(str(metadata.get("server_display_name", "")) == EXPECTED_SERVER_NAME)
+ var spawn_service := main.get_node(
+ "%PlayerSpawnService"
+ ) as PlayerSpawnService
+ assert(spawn_service.get_avatar(1) == null)
+ assert(spawn_service.get_avatar(session.get_local_peer_id()) != null)
+ print("DEDICATED_SERVER_CLIENT_VALIDATION_OK")
+ session.disconnect_session("Dedicated client validation complete.")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ quit()
diff --git a/tests/dedicated_server_client_validation.gd.uid b/tests/dedicated_server_client_validation.gd.uid
new file mode 100644
index 0000000..ed7a3b7
--- /dev/null
+++ b/tests/dedicated_server_client_validation.gd.uid
@@ -0,0 +1 @@
+uid://cwum3r4puerll
diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd
new file mode 100644
index 0000000..6fef7c6
--- /dev/null
+++ b/tests/dedicated_server_config_validation.gd
@@ -0,0 +1,209 @@
+extends SceneTree
+
+const ConfigType = preload("res://server/dedicated_server_config.gd")
+const OPERATOR_A: String = (
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+)
+const OPERATOR_B: String = (
+ "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var defaults := ConfigType.new()
+ defaults.set("data_directory", "/tmp/netfishing-server")
+ defaults.call("_validate")
+ assert(defaults.is_valid())
+ assert(not bool(defaults.get("public_listing")))
+
+ var path: String = ProjectSettings.globalize_path(
+ "user://dedicated-server-validation.cfg"
+ )
+ var file := ConfigFile.new()
+ file.set_value("server", "name", "Configured Room")
+ file.set_value("server", "bind_address", "127.0.0.1")
+ file.set_value("server", "port", 17777)
+ file.set_value("server", "max_players", 12)
+ file.set_value("server", "public", true)
+ file.set_value("server", "data_directory", "/tmp/configured-server")
+ file.set_value("discovery", "url", "https://discovery.netfishing.org/")
+ file.set_value(
+ "moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
+ )
+ assert(file.save(path) == OK)
+
+ var configured := ConfigType.new()
+ assert(bool(configured.call("_load_file", path)))
+ configured.call("_validate")
+ assert(configured.is_valid())
+ assert(str(configured.get("server_name")) == "Configured Room")
+ assert(str(configured.get("bind_address")) == "127.0.0.1")
+ assert(int(configured.get("port")) == 17777)
+ assert(int(configured.get("max_players")) == 12)
+ assert(bool(configured.get("public_listing")))
+ assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org")
+ assert(
+ configured.get("operator_fingerprints")
+ == PackedStringArray([OPERATOR_A, OPERATOR_B])
+ )
+ assert(DirAccess.remove_absolute(path) == OK)
+
+ var parsed := ConfigType.new()
+ parsed.set(
+ "operator_fingerprints",
+ ConfigType._parse_fingerprint_list(
+ "%s, %s;%s" % [OPERATOR_B.to_upper(), OPERATOR_A, OPERATOR_A]
+ ),
+ )
+ parsed.set("data_directory", "/tmp/parsed-server")
+ parsed.call("_validate")
+ assert(parsed.is_valid())
+ assert(
+ parsed.get("operator_fingerprints")
+ == PackedStringArray([OPERATOR_A, OPERATOR_B])
+ )
+
+ var invalid_operator := ConfigType.new()
+ invalid_operator.set(
+ "operator_fingerprints", PackedStringArray(["not-a-fingerprint"])
+ )
+ invalid_operator.set("data_directory", "/tmp/invalid-operator-server")
+ invalid_operator.call("_validate")
+ assert(not invalid_operator.is_valid())
+
+ var invalid := ConfigType.new()
+ invalid.set("public_listing", true)
+ invalid.set("data_directory", "/tmp/invalid-server")
+ invalid.call("_validate")
+ assert(not invalid.is_valid())
+
+ var discovery := DiscoveryClient.new()
+ assert(
+ str(discovery.call("_default_room_name", "River"))
+ == "River's Server"
+ )
+ assert(
+ DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS
+ < float(DiscoveryClient.UPNP_MAPPING_DURATION_SECONDS)
+ )
+ assert(
+ DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS
+ < DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS
+ )
+ var discovery_settings_path: String = ProjectSettings.globalize_path(
+ DiscoveryClient.SETTINGS_PATH
+ )
+ assert(not FileAccess.file_exists(discovery_settings_path))
+ assert(discovery.set_room_name("Client Room"))
+ var client_settings: PackedByteArray = FileAccess.get_file_as_bytes(
+ discovery_settings_path
+ )
+ assert(not client_settings.is_empty())
+ var dedicated_discovery := DiscoveryClient.new()
+ dedicated_discovery.call("_load_settings")
+ assert(
+ dedicated_discovery.configure_dedicated_runtime("Headless Room")
+ )
+ assert(dedicated_discovery.get_room_name() == "Headless Room")
+ assert(
+ FileAccess.get_file_as_bytes(discovery_settings_path)
+ == client_settings
+ )
+ dedicated_discovery.free()
+ assert(DirAccess.remove_absolute(discovery_settings_path) == OK)
+ assert(
+ discovery.get_host_state()
+ == DiscoveryClient.HostState.CLOSED
+ )
+ assert(
+ discovery.get_public_join_state()
+ == DiscoveryClient.PublicJoinState.IDLE
+ )
+ discovery.set("_lease_room_id", "local-room")
+ assert(discovery.is_own_room({"room_id": "local-room"}))
+ assert(not discovery.is_own_room({"room_id": "another-room"}))
+ discovery.set("_lease_room_id", "")
+ assert(not discovery.is_own_room({"room_id": "local-room"}))
+ var listed_room := {
+ "room_id": "empty-dedicated-room",
+ "room_name": "Empty Dedicated Room",
+ "address": "203.0.113.10",
+ "port": 7777,
+ "current_players": 0,
+ "max_players": 8,
+ "game_version": str(ProjectSettings.get_setting(
+ "application/config/version", "unknown"
+ )),
+ "protocol_version": NetworkProtocol.PROTOCOL_VERSION,
+ }
+ assert(bool(discovery.call("_valid_public_room", listed_room)))
+ var incompatible_room: Dictionary = listed_room.duplicate(true)
+ incompatible_room["game_version"] = "0.0.0-alpha"
+ assert(not bool(discovery.call("_valid_public_room", incompatible_room)))
+ assert(
+ NetworkProtocol.game_version_rejection(
+ "0.6.4-alpha", "0.6.5-alpha"
+ ) == NetworkProtocol.RejectionCode.CLIENT_OUTDATED
+ )
+ assert(
+ NetworkProtocol.game_version_rejection(
+ "0.6.6-alpha", "0.6.5-alpha"
+ ) == NetworkProtocol.RejectionCode.SERVER_OUTDATED
+ )
+ assert(
+ NetworkProtocol.game_version_rejection(
+ "0.6.5-alpha", "0.6.5-alpha"
+ ) == NetworkProtocol.RejectionCode.NONE
+ )
+ assert(
+ NetworkProtocol.game_version_rejection(
+ "0.6.5-beta", "0.6.5-alpha"
+ ) == NetworkProtocol.RejectionCode.SERVER_OUTDATED
+ )
+ var outdated_discovery_error := {
+ "error": {
+ "code": "game_version_mismatch",
+ "message": "generic mismatch",
+ "required_game_version": "99.0.0-alpha",
+ },
+ }
+ var outdated_message: String = str(
+ discovery.call("_request_failure", outdated_discovery_error)
+ )
+ assert("out of date" in outdated_message)
+ assert("99.0.0-alpha" in outdated_message)
+ assert("will not be listed" in outdated_message)
+ discovery.set(
+ "_host_request_kind",
+ DiscoveryClient.HostRequestKind.CREATE,
+ )
+ discovery.set("_host_request_in_flight", true)
+ discovery.call(
+ "_on_host_request_completed",
+ HTTPRequest.RESULT_SUCCESS,
+ HTTPClient.RESPONSE_CONFLICT,
+ PackedStringArray(),
+ JSON.stringify(outdated_discovery_error).to_utf8_buffer(),
+ )
+ assert(discovery.get_host_state() == DiscoveryClient.HostState.ERROR)
+ assert(discovery.host_status_is_error())
+ assert(discovery.get_host_status_message() == outdated_message)
+ var newer_discovery_error := {
+ "error": {
+ "code": "game_version_mismatch",
+ "message": "generic mismatch",
+ "required_game_version": "0.0.0-alpha",
+ },
+ }
+ var newer_message: String = str(
+ discovery.call("_request_failure", newer_discovery_error)
+ )
+ assert("discovery is updated" in newer_message)
+ assert("will not be listed" in newer_message)
+ discovery.free()
+ print("DEDICATED_SERVER_CONFIG_VALIDATION_OK")
+ quit()
diff --git a/tests/dedicated_server_config_validation.gd.uid b/tests/dedicated_server_config_validation.gd.uid
new file mode 100644
index 0000000..4bc60d1
--- /dev/null
+++ b/tests/dedicated_server_config_validation.gd.uid
@@ -0,0 +1 @@
+uid://vm5lql2w6h3e
diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd
new file mode 100644
index 0000000..51ba48d
--- /dev/null
+++ b/tests/economy_regression_validation.gd
@@ -0,0 +1,790 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+
+var _sale_result: Array[Variant] = []
+var _shop_result: Array[Variant] = []
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_multiplayer_host()
+ return
+ if arguments.has("client"):
+ await _run_multiplayer_client()
+ return
+ root.size = Vector2i(1280, 720)
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ # Use the same activation path as the real setup dialog so no modal is
+ # left above the gameplay SubViewport during pointer validation.
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ assert(bool(main.call("_prepare_private_host")))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ for _frame: int in 8:
+ await process_frame
+
+ var player := main.get("_player") as Player
+ var catalog := main.get("fish_catalog") as FishPool
+ var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService
+ var shop_service := main.get_node("%NetworkShopService") as NetworkShopService
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var reservations := (
+ main.get_node("%PlayerAssetReservationService")
+ as PlayerAssetReservationService
+ )
+ assert(player != null)
+ assert(catalog != null and catalog.candidates.size() == 53)
+ assert(sale_service != null)
+ assert(shop_service != null)
+ assert(session != null and session.is_host())
+ assert(reservations != null)
+ sale_service.local_sale_finished.connect(_on_sale_finished)
+ shop_service.local_purchase_finished.connect(_on_shop_finished)
+ await process_frame
+
+ _test_each_species(player, catalog, sale_service)
+ _test_multi_sale(player, catalog, sale_service)
+ _test_reservations(player, catalog, sale_service, reservations)
+ _test_anywhere_sale(player, catalog, sale_service)
+ await _test_player_menu_sale(main, player, catalog, sale_service)
+ await _test_host_shop_sale(main, player, catalog, sale_service)
+
+ assert(session.set_host_open(true))
+ assert(session.state == NetworkSession.State.OPEN_HOST)
+ var open_catch := _make_catch(catalog.candidates.front())
+ player.inventory.add_catch(open_catch)
+ _assert_sale(player, sale_service, [open_catch.catch_id], true)
+ assert(session.set_host_open(false))
+
+ await _test_host_shop_purchase(main, player, shop_service)
+ await _test_host_art_shop_purchase(main, player, shop_service)
+ await _test_fishing_shop_sale_ui(
+ main, player, catalog, sale_service, reservations
+ )
+ assert(not sale_service.is_local_sale_pending())
+ assert(not shop_service.is_local_purchase_pending())
+
+ print("Economy regression validation: PASS")
+ session.disconnect_session("Economy validation complete.")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_multiplayer_host() -> void:
+ root.size = Vector2i(1280, 720)
+ var main: Node = await _create_initialized_main()
+ assert(bool(main.call("_prepare_private_host")))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session.set_host_open(true))
+ var client_connected := false
+ # Loading the full client world can take longer on a cold import. Keep the
+ # host available until the client has completed authentication rather than
+ # timing out while the client is still starting.
+ var connection_deadline: int = Time.get_ticks_msec() + 60000
+ while Time.get_ticks_msec() < connection_deadline:
+ await process_frame
+ if session.get_authenticated_peer_ids().size() >= 2:
+ client_connected = true
+ break
+ assert(client_connected)
+ var remote_peer_id := 0
+ for authenticated_peer_id: int in session.get_authenticated_peer_ids():
+ if authenticated_peer_id != 1:
+ remote_peer_id = authenticated_peer_id
+ break
+ assert(remote_peer_id > 1)
+ var spawn_service := main.get("_player_spawn_service") as PlayerSpawnService
+ var remote_avatar := spawn_service.get_avatar(remote_peer_id)
+ assert(remote_avatar != null)
+ # Pelican sales are available from the Cooler anywhere. The host-owned
+ # avatar only moves when the later Fishing Shop purchase needs proximity.
+ await create_timer(5.0).timeout
+ var interaction := main.get("_shop_interaction") as FishingShopInteraction
+ assert(interaction != null)
+ remote_avatar.global_position = interaction.global_position
+ var completion_deadline: int = Time.get_ticks_msec() + 30000
+ while Time.get_ticks_msec() < completion_deadline:
+ await process_frame
+ if session.get_authenticated_peer_ids().size() < 2:
+ break
+ print("Economy multiplayer host validation: PASS")
+ session.disconnect_session("Economy host validation complete.")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_multiplayer_client() -> void:
+ root.size = Vector2i(1280, 720)
+ var main: Node = await _create_initialized_main()
+ main.call("_on_title_join_game_requested", "127.0.0.1:7777")
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var joined := false
+ var join_deadline: int = Time.get_ticks_msec() + 30000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ joined = true
+ break
+ assert(joined)
+ var player := main.get("_player") as Player
+ var catalog := main.get("fish_catalog") as FishPool
+ var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService
+ var shop_service := main.get_node("%NetworkShopService") as NetworkShopService
+ sale_service.local_sale_finished.connect(_on_sale_finished)
+ shop_service.local_purchase_finished.connect(_on_shop_finished)
+ var catch_ids: Array[StringName] = []
+ for fish: FishData in catalog.candidates:
+ var fish_catch := _make_catch(fish)
+ player.inventory.add_catch(fish_catch)
+ catch_ids.append(fish_catch.catch_id)
+ await create_timer(1.0).timeout
+ _sale_result.clear()
+ assert(not sale_service.request_local_sale(catch_ids).is_empty())
+ var sale_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < sale_deadline:
+ await process_frame
+ if not _sale_result.is_empty():
+ break
+ print("Client sale result: ", _sale_result)
+ assert(not _sale_result.is_empty() and bool(_sale_result[1]))
+ for catch_id: StringName in catch_ids:
+ assert(not player.inventory.contains_catch_id(catch_id))
+ assert(not sale_service.is_local_sale_pending())
+
+ var interaction := main.get("_shop_interaction") as FishingShopInteraction
+ player.global_position = interaction.global_position
+ await create_timer(5.5).timeout
+ var shop_catch := _make_catch(catalog.candidates.front())
+ player.inventory.add_catch(shop_catch)
+ var shop_sale_balance_before: int = player.wallet.get_balance()
+ _sale_result.clear()
+ assert(not sale_service.request_local_sale(
+ [shop_catch.catch_id],
+ NetworkSaleService.MAIN_SHOP_BUYER_ID,
+ ).is_empty())
+ var shop_sale_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < shop_sale_deadline:
+ await process_frame
+ if not _sale_result.is_empty():
+ break
+ print("Client shop sale result: ", _sale_result)
+ assert(not _sale_result.is_empty() and bool(_sale_result[1]))
+ assert(not player.inventory.contains_catch_id(shop_catch.catch_id))
+ assert(
+ player.wallet.get_balance()
+ == shop_sale_balance_before + shop_catch.sale_value
+ )
+ assert(not sale_service.is_local_sale_pending())
+
+ assert(player.wallet.credit(100))
+ _shop_result.clear()
+ assert(not shop_service.request_supply(&"coffee").is_empty())
+ var shop_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < shop_deadline:
+ await process_frame
+ if not _shop_result.is_empty():
+ break
+ print("Client shop result: ", _shop_result)
+ assert(not _shop_result.is_empty() and bool(_shop_result[1]))
+ assert(not shop_service.is_local_purchase_pending())
+ var required_art_balance: int = (
+ ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE
+ )
+ if player.wallet.get_balance() < required_art_balance:
+ assert(player.wallet.credit(
+ required_art_balance - player.wallet.get_balance()
+ ))
+ _shop_result.clear()
+ assert(not shop_service.request_art_kit().is_empty())
+ while _shop_result.is_empty():
+ await process_frame
+ assert(bool(_shop_result[1]))
+ _shop_result.clear()
+ assert(not shop_service.request_art_upgrade(&"grid_32x").is_empty())
+ while _shop_result.is_empty():
+ await process_frame
+ assert(bool(_shop_result[1]))
+ assert(player.bag.owns_item(ArtShopStock.ART_KIT_ITEM_ID))
+ assert(player.art_unlocks.is_grid_size_unlocked(32))
+ print("Economy multiplayer client validation: PASS")
+ session.disconnect_session("Economy client validation complete.")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _create_initialized_main() -> Node:
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
+
+
+func _test_each_species(
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+) -> void:
+ for fish: FishData in catalog.candidates:
+ assert(fish != null)
+ var fish_catch := _make_catch(fish)
+ player.inventory.add_catch(fish_catch)
+ _assert_sale(player, sale_service, [fish_catch.catch_id], true)
+
+
+func _test_multi_sale(
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+) -> void:
+ var catches: Array[FishCatch] = [
+ _make_catch(catalog.candidates[0]),
+ _make_catch(catalog.candidates[1]),
+ _make_catch(catalog.candidates[2]),
+ ]
+ var catch_ids: Array[StringName] = []
+ for fish_catch: FishCatch in catches:
+ player.inventory.add_catch(fish_catch)
+ catch_ids.append(fish_catch.catch_id)
+ _assert_sale(player, sale_service, catch_ids, true)
+
+
+func _test_reservations(
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+ reservations: PlayerAssetReservationService,
+) -> void:
+ var reserved := _make_catch(catalog.candidates[3])
+ var unreserved := _make_catch(catalog.candidates[4])
+ player.inventory.add_catch(reserved)
+ player.inventory.add_catch(unreserved)
+ var reservation_id := "economy-test-reservation"
+ assert(reservations.reserve(reservation_id, {
+ "type": PlayerAssetReservationService.AttachmentType.FISH,
+ "catch_id": str(reserved.catch_id),
+ "catch": reserved.to_network_dict(),
+ }))
+ _assert_sale(player, sale_service, [reserved.catch_id], false, false)
+ _assert_sale(
+ player,
+ sale_service,
+ [reserved.catch_id, unreserved.catch_id],
+ false,
+ false,
+ )
+ assert(player.inventory.contains_catch_id(reserved.catch_id))
+ assert(player.inventory.contains_catch_id(unreserved.catch_id))
+ assert(reservations.release(reservation_id))
+ _assert_sale(
+ player,
+ sale_service,
+ [reserved.catch_id, unreserved.catch_id],
+ true,
+ )
+
+
+func _test_anywhere_sale(
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+) -> void:
+ var fish_catch := _make_catch(catalog.candidates[5])
+ player.inventory.add_catch(fish_catch)
+ # The spawn point is intentionally far from the Pelican landmark. Discounted
+ # Pelican sales are a convenience action and do not require proximity.
+ player.global_position = Vector3(0.0, 3.95, 13.0)
+ _assert_sale(player, sale_service, [fish_catch.catch_id], true)
+
+
+func _test_host_shop_sale(
+ main: Node,
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+) -> void:
+ var interaction := main.get("_shop_interaction") as FishingShopInteraction
+ assert(interaction != null)
+ var out_of_range_catch := _make_catch(catalog.candidates[6])
+ player.inventory.add_catch(out_of_range_catch)
+ player.global_position = Vector3(0.0, 3.95, 13.0)
+ for _frame: int in 4:
+ await physics_frame
+ _assert_sale(
+ player,
+ sale_service,
+ [out_of_range_catch.catch_id],
+ false,
+ true,
+ NetworkSaleService.MAIN_SHOP_BUYER_ID,
+ )
+ player.global_position = interaction.global_position
+ for _frame: int in 4:
+ await physics_frame
+ assert(interaction.is_avatar_in_range(player))
+ var balance_before: int = player.wallet.get_balance()
+ _assert_sale(
+ player,
+ sale_service,
+ [out_of_range_catch.catch_id],
+ true,
+ true,
+ NetworkSaleService.MAIN_SHOP_BUYER_ID,
+ )
+ assert(
+ player.wallet.get_balance()
+ == balance_before + out_of_range_catch.sale_value
+ )
+ var species_ids: Array[StringName] = []
+ var expected_payout: int = 0
+ for fish: FishData in catalog.candidates:
+ var fish_catch := _make_catch(fish)
+ player.inventory.add_catch(fish_catch)
+ species_ids.append(fish_catch.catch_id)
+ expected_payout += fish_catch.sale_value
+ var species_balance_before: int = player.wallet.get_balance()
+ _assert_sale(
+ player,
+ sale_service,
+ species_ids,
+ true,
+ true,
+ NetworkSaleService.MAIN_SHOP_BUYER_ID,
+ )
+ assert(
+ player.wallet.get_balance()
+ == species_balance_before + expected_payout
+ )
+
+
+func _test_player_menu_sale(
+ main: Node,
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+) -> void:
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
+ var fish_catch := _make_catch(catalog.candidates[6])
+ player.inventory.add_catch(fish_catch)
+ player.global_position = Vector3(0.0, 3.95, 13.0)
+ player_menu.open_menu()
+ await create_timer(2.2).timeout
+ player_menu.call("_on_catch_card_pressed", fish_catch.catch_id)
+ await process_frame
+ var sell_action := player_menu.get_node("%SellBubble") as Button
+ var confirmation := player_menu.get_node("%SaleConfirmation") as Control
+ var confirm_button := player_menu.get_node("%ConfirmSaleButton") as Button
+ var ui_viewport := main.get_node(
+ "UIPresentation/UIViewport"
+ ) as SubViewport
+ assert(sell_action.visible and not sell_action.disabled)
+ assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP)
+ assert(sell_action.focus_mode == Control.FOCUS_ALL)
+ assert(ui_viewport != null)
+ await _activate_pointer_control(sell_action, ui_viewport)
+ await process_frame
+ assert(confirmation.visible)
+ assert(confirm_button.visible and not confirm_button.disabled)
+ assert(
+ confirmation.z_index
+ > (player_menu.get_node("%CoolerOuterWall") as Control).z_index
+ )
+ _sale_result.clear()
+ await _activate_pointer_control(confirm_button, ui_viewport)
+ await process_frame
+ assert(not _sale_result.is_empty() and bool(_sale_result[1]))
+ assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
+ assert(not sale_service.is_local_sale_pending())
+ player_menu.close_menu()
+ await create_timer(2.2).timeout
+ assert(not player_menu.visible)
+ player_menu.open_menu()
+ await create_timer(2.2).timeout
+ assert(player_menu.visible)
+ assert(
+ not sell_action.disabled
+ or player.inventory.get_all_catches().is_empty()
+ )
+ player_menu.close_menu()
+ await create_timer(2.2).timeout
+
+
+func _activate_pointer_control(
+ control: Control,
+ ui_viewport: SubViewport,
+) -> void:
+ var presenter := ui_viewport.get_parent() as SubViewportContainer
+ assert(presenter != null)
+ var local_center: Vector2 = control.get_global_transform_with_canvas() * (
+ control.size * 0.5
+ )
+ var center: Vector2 = presenter.position + local_center * presenter.scale
+ var motion := InputEventMouseMotion.new()
+ motion.position = center
+ motion.global_position = center
+ root.push_input(motion, true)
+ await process_frame
+ assert(ui_viewport.gui_get_hovered_control() == control)
+ for is_pressed: bool in [true, false]:
+ var click := InputEventMouseButton.new()
+ click.button_index = MOUSE_BUTTON_LEFT
+ click.position = center
+ click.global_position = center
+ click.pressed = is_pressed
+ root.push_input(click, true)
+ await process_frame
+
+
+func _test_host_shop_purchase(
+ main: Node,
+ player: Player,
+ shop_service: NetworkShopService,
+) -> void:
+ var interaction := main.get("_shop_interaction") as FishingShopInteraction
+ assert(interaction != null)
+ player.global_position = interaction.global_position
+ for _frame: int in 4:
+ await physics_frame
+ assert(interaction.is_avatar_in_range(player))
+ var quantity_before: int = player.bag.get_quantity(&"coffee")
+ _shop_result.clear()
+ var request_id: String = shop_service.request_supply(&"coffee")
+ assert(not request_id.is_empty())
+ assert(not _shop_result.is_empty() and bool(_shop_result[1]))
+ assert(player.bag.get_quantity(&"coffee") == quantity_before + 1)
+ assert(not shop_service.is_local_purchase_pending())
+
+
+func _test_host_art_shop_purchase(
+ main: Node,
+ player: Player,
+ shop_service: NetworkShopService,
+) -> void:
+ var interaction := main.get("_shop_interaction") as FishingShopInteraction
+ assert(interaction != null)
+ player.global_position = interaction.global_position
+ for _frame: int in 4:
+ await physics_frame
+ var required_balance: int = (
+ ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE * 3
+ )
+ if player.wallet.get_balance() < required_balance:
+ assert(player.wallet.credit(required_balance - player.wallet.get_balance()))
+ _shop_result.clear()
+ assert(not shop_service.request_art_kit().is_empty())
+ assert(not _shop_result.is_empty() and bool(_shop_result[1]))
+ assert(player.bag.owns_item(ArtShopStock.ART_KIT_ITEM_ID))
+ var item_catalog := main.get("item_catalog") as ItemCatalog
+ assert(item_catalog != null)
+ var art_item: ItemData = item_catalog.get_item_by_id(
+ ArtShopStock.ART_KIT_ITEM_ID
+ )
+ assert(art_item != null and art_item.hotbar_allowed and art_item.equippable)
+ assert(art_item.icon != null)
+ assert(art_item.icon.resource_path.ends_with("/art/art_kit.png"))
+ assert(player.hotbar.assign_item(0, ArtShopStock.ART_KIT_ITEM_ID))
+ for product_id: StringName in [
+ &"marker_ocean_teal", &"brush_2x", &"grid_32x",
+ ]:
+ _shop_result.clear()
+ assert(not shop_service.request_art_upgrade(product_id).is_empty())
+ assert(not _shop_result.is_empty() and bool(_shop_result[1]))
+ assert(player.art_unlocks.owns_product(product_id))
+ assert(not shop_service.is_local_purchase_pending())
+
+
+func _test_fishing_shop_sale_ui(
+ main: Node,
+ player: Player,
+ catalog: FishPool,
+ sale_service: NetworkSaleService,
+ reservations: PlayerAssetReservationService,
+) -> void:
+ var interaction := main.get("_shop_interaction") as FishingShopInteraction
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var shop := game_ui.get_node("%FishingShop") as FishingShop
+ var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
+ var shop_backdrop := main.get_node("%ShopBackdrop") as ColorRect
+ var ui_viewport := main.get_node(
+ "UIPresentation/UIViewport"
+ ) as SubViewport
+ assert(
+ interaction != null
+ and shop != null
+ and player_menu != null
+ and shop_backdrop != null
+ and ui_viewport != null
+ )
+ player.global_position = interaction.global_position
+ for _frame: int in 4:
+ await physics_frame
+ var fish_catch := _make_catch(catalog.candidates[7])
+ var reserved_catch := _make_catch(catalog.candidates[6])
+ player.inventory.add_catch(fish_catch)
+ player.inventory.add_catch(reserved_catch)
+ var reservation_id := "shop-ui-reservation"
+ assert(reservations.reserve(reservation_id, {
+ "type": PlayerAssetReservationService.AttachmentType.FISH,
+ "catch_id": str(reserved_catch.catch_id),
+ "catch": reserved_catch.to_network_dict(),
+ }))
+ var balance_before: int = player.wallet.get_balance()
+ assert(shop.open_shop())
+ await process_frame
+ assert(shop_backdrop.visible)
+ assert(shop_backdrop.material is ShaderMaterial)
+ assert(shop.has_node("InputBlocker") and not shop.has_node("Dimmer"))
+ var shop_panel := shop.get_node("%ShopPanel") as PanelContainer
+ assert(shop_panel != null and shop_panel.size == Vector2(980.0, 600.0))
+ var shop_panel_style := shop_panel.get_theme_stylebox("panel") as StyleBoxFlat
+ assert(shop_panel_style != null)
+ assert(shop_panel_style.corner_radius_top_left == 24)
+ var upgrade_grid := shop.get_node("%UpgradeGrid") as GridContainer
+ assert(upgrade_grid != null and upgrade_grid.columns == 3)
+ assert((shop.get_node("%Upgrades") as Control).visible)
+ assert(not (shop.get_node("%Supplies") as Control).visible)
+ for upgrade_name: String in [
+ "ReelPurchase", "BarrierPurchase", "CoolerPurchase"
+ ]:
+ var upgrade_button := shop.get_node("%%%s" % upgrade_name) as Button
+ assert(upgrade_button != null)
+ assert(upgrade_button.size == Vector2(144.0, 144.0))
+ assert(upgrade_button.text.is_empty())
+ assert(upgrade_button.icon != null)
+ assert(upgrade_button.tooltip_text.contains("level"))
+ assert(not shop.has_node("%ReelLevel"))
+ assert(not shop.has_node("%BarrierEffect"))
+ assert(not shop.has_node("%CoolerLevel"))
+ var balance_coin := shop.get_node(
+ "ShopPanel/Margin/Layout/Header/BalanceDisplay/CoinIcon"
+ ) as TextureRect
+ assert(balance_coin != null and balance_coin.texture != null)
+ assert(
+ balance_coin.texture.resource_path.ends_with(
+ "/shop/32_currency.png"
+ )
+ )
+ for cost_name: String in ["ReelCost", "BarrierCost", "CoolerCost"]:
+ var cost_display := shop.get_node("%%%s" % cost_name) as CurrencyAmount
+ assert(cost_display != null)
+ var cost_icon := cost_display.get_node("Icon") as TextureRect
+ assert(cost_icon != null and cost_icon.texture != null)
+ assert(
+ cost_icon.texture.resource_path.ends_with(
+ "/shop/32_currency.png"
+ )
+ )
+ assert(not shop.has_node("ShopPanel/Margin/Layout/ModeTabs"))
+ var shop_tabs: Array = shop.get("_shop_tabs") as Array
+ assert(shop_tabs.size() == 6)
+ var art_supplies_tab := shop_tabs[4] as Button
+ assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies")
+ var sell_mode := shop_tabs[5] as Button
+ assert(sell_mode != null and sell_mode.text == "Sell Fish")
+ await _activate_pointer_control(art_supplies_tab, ui_viewport)
+ await process_frame
+ var stock_sections: Array[String] = []
+ for child: Node in shop.get_node("%SuppliesList").get_children():
+ if child is Label:
+ stock_sections.append((child as Label).text)
+ assert(stock_sections == ["art kit", "markers", "brushes", "grids"])
+ var marker_icons := shop.find_children(
+ "MarkerIcon", "TextureRect", true, false
+ )
+ assert(marker_icons.size() == ArtShopStock.MARKER_PRODUCTS.size())
+ for index: int in marker_icons.size():
+ var marker_icon := marker_icons[index] as TextureRect
+ var marker_material := marker_icon.material as ShaderMaterial
+ var product_id: StringName = ArtShopStock.MARKER_PRODUCTS[index]
+ var color_id: StringName = PlayerArtUnlocks.color_id_for_product(
+ product_id
+ )
+ assert(marker_icon.texture.resource_path.ends_with("art_kit_marker.png"))
+ assert(marker_material != null)
+ assert(
+ marker_material.get_shader_parameter("marker_color")
+ == SurfaceDrawingPalette.get_color(color_id)
+ )
+ var price_bubbles := shop.find_children(
+ "PriceBubble", "PanelContainer", true, false
+ )
+ assert(not price_bubbles.is_empty())
+ for price_bubble: Node in price_bubbles:
+ var currency_icon := price_bubble.find_child(
+ "CurrencyIcon", true, false
+ ) as TextureRect
+ assert(currency_icon != null and currency_icon.texture != null)
+ assert(
+ currency_icon.texture.resource_path.ends_with(
+ "/shop/32_currency.png"
+ )
+ )
+ await _activate_pointer_control(sell_mode, ui_viewport)
+ await process_frame
+ assert(shop.visible and not player_menu.visible)
+ assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales"))
+ assert((shop.get_node("%ShopCoolerPage") as Control).visible)
+ assert((shop.get_node("%ShopPanel") as Control).visible)
+ assert(not (shop.get_node("ShopPanel/Margin/Layout/Body") as Control).visible)
+ assert(not (shop.get_node("%Feedback") as Control).visible)
+ var mounted_cooler := player_menu.get("_cooler_page") as Control
+ assert(mounted_cooler != null and mounted_cooler.visible)
+ assert(
+ mounted_cooler.get_parent() == shop.get_node("%ShopCoolerMount")
+ )
+ var cooler_outer_wall := player_menu.get("_cooler_outer_wall") as Control
+ var water_surface := player_menu.get("_cooler_water_surface") as ColorRect
+ assert(cooler_outer_wall != null and cooler_outer_wall.visible)
+ assert(water_surface.visible and water_surface.material is ShaderMaterial)
+ var cooler_sort_option := player_menu.get("_cooler_sort_option") as Control
+ await _activate_pointer_control(cooler_sort_option, ui_viewport)
+ var cooler_choice_panel := cooler_sort_option.get("_choice_panel") as Control
+ assert(cooler_choice_panel.visible)
+ cooler_sort_option.call("close_choices")
+ assert(
+ StringName(
+ (player_menu.get("_sale_buyer_override") as FishBuyerProfile).id
+ ) == NetworkSaleService.MAIN_SHOP_BUYER_ID
+ )
+ var fish_nodes: Dictionary = player_menu.get("_fish_nodes")
+ var fish_button := fish_nodes.get(fish_catch.catch_id) as Button
+ var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button
+ assert(fish_button != null and fish_button.visible)
+ assert(reserved_button != null and reserved_button.visible)
+ await _activate_pointer_control(fish_button, ui_viewport)
+ var sell_button := player_menu.get("_sell_bubble") as Button
+ assert(sell_button.visible and not sell_button.disabled)
+ await _activate_pointer_control(sell_button, ui_viewport)
+ await process_frame
+ var confirmation := player_menu.get("_sale_confirmation") as Control
+ var confirm_button := player_menu.get("_confirm_sale_button") as Button
+ assert(confirmation.visible)
+ assert(
+ confirmation.z_index
+ > cooler_outer_wall.z_index
+ )
+ _sale_result.clear()
+ await _activate_pointer_control(confirm_button, ui_viewport)
+ await process_frame
+ assert(not _sale_result.is_empty() and bool(_sale_result[1]))
+ assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
+ assert(player.wallet.get_balance() == balance_before + fish_catch.sale_value)
+ assert(not sale_service.is_local_sale_pending())
+ assert(reservations.release(reservation_id))
+ await _activate_pointer_control(shop_tabs[0] as Button, ui_viewport)
+ await process_frame
+ assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible)
+ assert(not (shop.get_node("%ShopCoolerPage") as Control).visible)
+ assert(not player_menu.is_shop_cooler_mounted())
+ shop.close_shop()
+ await shop.menu_visibility_changed
+ assert(not shop.visible)
+
+
+func _assert_sale(
+ player: Player,
+ sale_service: NetworkSaleService,
+ catch_ids: Array[StringName],
+ expected_success: bool,
+ expect_request_id: bool = true,
+ buyer_id: StringName = NetworkSaleService.PELICAN_BUYER_ID,
+) -> void:
+ var balance_before: int = player.wallet.get_balance()
+ _sale_result.clear()
+ var request_id: String = sale_service.request_local_sale(
+ catch_ids, buyer_id
+ )
+ assert((not request_id.is_empty()) == expect_request_id)
+ assert(not _sale_result.is_empty())
+ assert(bool(_sale_result[1]) == expected_success)
+ assert(not sale_service.is_local_sale_pending())
+ if expected_success:
+ var payout: int = int(_sale_result[4])
+ assert(payout >= 0)
+ assert(player.wallet.get_balance() == balance_before + payout)
+ for catch_id: StringName in catch_ids:
+ assert(not player.inventory.contains_catch_id(catch_id))
+ else:
+ assert(player.wallet.get_balance() == balance_before)
+ for catch_id: StringName in catch_ids:
+ assert(player.inventory.contains_catch_id(catch_id))
+
+
+func _make_catch(fish: FishData) -> FishCatch:
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.weight_lb = fish.get_minimum_weight()
+ fish_catch.display_scale = fish.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.sale_value = fish.get_sale_value_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.ensure_identity()
+ return fish_catch
+
+
+func _on_sale_finished(
+ request_id: String,
+ accepted: bool,
+ message: String,
+ catch_ids: Array[StringName],
+ payout: int,
+) -> void:
+ _sale_result = [request_id, accepted, message, catch_ids, payout]
+
+
+func _on_shop_finished(
+ request_id: String,
+ accepted: bool,
+ message: String,
+ product_id: StringName,
+ category: int,
+ quantity: int,
+ total_cost: int,
+) -> void:
+ _shop_result = [
+ request_id,
+ accepted,
+ message,
+ product_id,
+ category,
+ quantity,
+ total_cost,
+ ]
diff --git a/tests/economy_regression_validation.gd.uid b/tests/economy_regression_validation.gd.uid
new file mode 100644
index 0000000..f10b0ad
--- /dev/null
+++ b/tests/economy_regression_validation.gd.uid
@@ -0,0 +1 @@
+uid://b41oh3rfoejhw
diff --git a/tests/exported_decal_hotfix_validation.gd b/tests/exported_decal_hotfix_validation.gd
new file mode 100644
index 0000000..fe279b0
--- /dev/null
+++ b/tests/exported_decal_hotfix_validation.gd
@@ -0,0 +1,77 @@
+extends SceneTree
+
+const FEATURE_ASSET_ROOTS: Dictionary = {
+ "eyes": "res://art/exported/characters/faces/eyes",
+ "mouth": "res://art/exported/characters/faces/mouth",
+ "nose": "res://art/exported/characters/faces/noses",
+}
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ assert(
+ CharacterCustomizationCatalog._canonical_feature_filename(
+ "eyes_alligator_eyes.png"
+ ) == "eyes_alligator_eyes.png"
+ )
+ assert(
+ CharacterCustomizationCatalog._canonical_feature_filename(
+ "eyes_alligator_eyes.png.import"
+ ) == "eyes_alligator_eyes.png"
+ )
+ assert(
+ CharacterCustomizationCatalog._canonical_feature_filename(
+ "eyes_alligator_eyes.png.remap"
+ ) == "eyes_alligator_eyes.png"
+ )
+ assert(
+ CharacterCustomizationCatalog._canonical_feature_filename(
+ "eyes_alligator_eyes.jpg"
+ ).is_empty()
+ )
+
+ for category_id: String in FEATURE_ASSET_ROOTS:
+ var options := CharacterCustomizationCatalog.options_for(category_id)
+ var expected_ids: Dictionary = _feature_ids_on_disk(category_id)
+ assert(options.size() == expected_ids.size() + 1)
+ var seen_ids: Dictionary = {}
+ for option: Dictionary in options:
+ var option_id := str(option.get("id", ""))
+ assert(not seen_ids.has(option_id))
+ seen_ids[option_id] = true
+ if option_id != "none":
+ assert(
+ CharacterCustomizationCatalog.texture_for(
+ category_id, option_id
+ ) != null
+ )
+ for expected_id: String in expected_ids:
+ assert(seen_ids.has(expected_id))
+ for mouth_id: String in ["open_ah", "open_oh", "open_smile"]:
+ assert(CharacterCustomizationCatalog.texture_for(
+ "mouth", mouth_id
+ ) != null)
+
+ print("Exported decal hotfix validation: PASS")
+ quit()
+
+
+func _feature_ids_on_disk(category_id: String) -> Dictionary:
+ var result: Dictionary = {}
+ var root_path: String = str(FEATURE_ASSET_ROOTS[category_id])
+ var directory := DirAccess.open(root_path)
+ assert(directory != null)
+ for file_name: String in directory.get_files():
+ if not file_name.to_lower().ends_with(".png"):
+ continue
+ var option_id: String = (
+ CharacterCustomizationCatalog._feature_id_from_filename(
+ category_id, file_name
+ )
+ )
+ if not option_id.is_empty():
+ result[option_id] = true
+ return result
diff --git a/tests/exported_decal_hotfix_validation.gd.uid b/tests/exported_decal_hotfix_validation.gd.uid
new file mode 100644
index 0000000..e75742a
--- /dev/null
+++ b/tests/exported_decal_hotfix_validation.gd.uid
@@ -0,0 +1 @@
+uid://dolsbknm52hsy
diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd
new file mode 100644
index 0000000..6746d70
--- /dev/null
+++ b/tests/fish_catalog_content_validation.gd
@@ -0,0 +1,398 @@
+extends SceneTree
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const CollectionLogType = preload("res://collection/collection_log.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
+const FishSelectorType = preload("res://fish/fish_selector.gd")
+const FishingContextType = preload("res://fishing/fishing_context.gd")
+const NetworkSaleServiceType = preload(
+ "res://network/network_sale_service.gd"
+)
+const NetworkFishingServiceType = preload(
+ "res://network/network_fishing_service.gd"
+)
+const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+
+const Catalog: FishPoolType = preload("res://fish/pools/fish_catalog.tres")
+const PondPool: FishPoolType = preload(
+ "res://fish/pools/starter_pond_pool.tres"
+)
+const OceanPool: FishPoolType = preload(
+ "res://fish/pools/starter_ocean_pool.tres"
+)
+const PelicanBuyer = preload("res://economy/buyers/pelicans.tres")
+const StarterRegionScene = preload(
+ "res://world/regions/starter_island_region.tscn"
+)
+
+const ORIGINAL_IDS: Array[StringName] = [
+ &"bluegill", &"bass", &"carp", &"sunfish",
+]
+const CATFISH_IDS: Array[StringName] = [
+ &"catfish_blue",
+ &"catfish_channel",
+ &"catfish_flathead",
+ &"catfish_white",
+]
+const FRESH_WATER_IDS: Array[StringName] = [
+ &"bluegill", &"carp", &"catfish_blue", &"catfish_channel",
+ &"catfish_flathead", &"catfish_white",
+ &"goby_round",
+]
+const SALT_WATER_IDS: Array[StringName] = [
+ &"bass",
+ &"sunfish",
+ &"tuna_albacore",
+ &"tuna_bigeye",
+ &"tuna_bluefin",
+ &"tuna_skipjack",
+ &"tuna_yellowfin",
+ &"salmon_atlantic",
+ &"salmon_chum",
+ &"salmon_coho",
+ &"salmon_pink",
+ &"salmon_sockeye",
+ &"anchovy_european", &"anchovy_northern",
+ &"grouper_gulf", &"grouper_red",
+ &"mackerel_atlantic", &"mackerel_cero", &"mackerel_chub",
+ &"mackerel_king", &"mackerel_spanish",
+ &"marlin_black", &"marlin_blue", &"marlin_white",
+ &"pomfret_black", &"pomfret_chinese", &"pomfret_golden",
+ &"pomfret_white", &"sailfish",
+ &"snapper_lane", &"snapper_mangrove", &"snapper_mutton",
+ &"snapper_red", &"swordfish",
+]
+const NEW_FRESH_WATER_IDS: Array[StringName] = [
+ &"chub_european", &"chub_flame", &"chub_lake", &"goldfish",
+ &"goldfish_bubbleeye", &"sauger", &"saugeye", &"trout_cutthroat",
+ &"trout_golden", &"trout_rainbow", &"trout_steelhead", &"walleye",
+]
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_catalog_and_pools()
+ _validate_weight_based_display_scale()
+ _validate_starter_water_bodies()
+ _validate_authoritative_water_filter()
+ _validate_catches_and_authoritative_sale()
+ print("Fish catalog content validation: PASS")
+ quit()
+
+
+func _validate_weight_based_display_scale() -> void:
+ var previous_scale: float = 0.0
+ for fish: FishDataType in Catalog.candidates:
+ assert(fish.is_selectable())
+ assert(fish.weight_min_lb > 0.0)
+ assert(fish.weight_max_lb <= 1000.0)
+ var minimum_scale: float = fish.get_display_scale_for_weight(
+ fish.weight_min_lb
+ )
+ var maximum_scale: float = fish.get_display_scale_for_weight(
+ fish.weight_max_lb
+ )
+ assert(minimum_scale >= FishDataType.DISPLAY_MIN_SCALE)
+ assert(maximum_scale <= FishDataType.DISPLAY_MAX_SCALE)
+ assert(maximum_scale >= minimum_scale)
+ var midpoint_weight: float = lerpf(
+ fish.weight_min_lb,
+ fish.weight_max_lb,
+ 0.5,
+ )
+ assert(
+ fish.get_display_scale_for_weight(midpoint_weight)
+ >= minimum_scale
+ )
+ # A one-pound catch is a shared visual reference, not a per-species
+ # texture-size decision.
+ var reference_scale: float = fish.get_display_scale_for_weight(1.0)
+ assert(is_equal_approx(reference_scale, FishDataType.DISPLAY_REFERENCE_SCALE))
+ previous_scale = maxf(previous_scale, maximum_scale)
+ assert(previous_scale <= FishDataType.DISPLAY_MAX_SCALE)
+
+
+func _validate_catalog_and_pools() -> void:
+ assert(Catalog.candidates.size() == 53)
+ assert(PondPool.candidates.size() == 19)
+ assert(OceanPool.candidates.size() == 34)
+ for fish_id: StringName in ORIGINAL_IDS:
+ var original_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(original_fish != null)
+ assert(original_fish.is_selectable())
+ for fish_id: StringName in FRESH_WATER_IDS:
+ var fresh_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(PondPool.get_fish_by_id(fish_id) == fresh_fish)
+ assert(OceanPool.get_fish_by_id(fish_id) == null)
+ assert(fresh_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER))
+ assert(not fresh_fish.is_allowed_in_water(WaterType.Type.SALT_WATER))
+ assert(LogbookCatalog.category_for(fresh_fish) == WaterType.Type.FRESH_WATER)
+ for fish_id: StringName in SALT_WATER_IDS:
+ var salt_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(OceanPool.get_fish_by_id(fish_id) == salt_fish)
+ assert(PondPool.get_fish_by_id(fish_id) == null)
+ assert(salt_fish.is_allowed_in_water(WaterType.Type.SALT_WATER))
+ assert(not salt_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER))
+ assert(LogbookCatalog.category_for(salt_fish) == WaterType.Type.SALT_WATER)
+ for fish_id: StringName in CATFISH_IDS:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(fish != null)
+ assert(fish.is_selectable())
+ assert(PondPool.get_fish_by_id(fish_id) == fish)
+ assert(OceanPool.get_fish_by_id(fish_id) == null)
+ assert(fish.availability.allowed_location_tags == [&"starter_pond"])
+ assert(LogbookCatalog.category_for(fish) == WaterType.Type.FRESH_WATER)
+ for fish_id: StringName in NEW_FRESH_WATER_IDS:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(fish != null and fish.is_selectable())
+ assert(PondPool.get_fish_by_id(fish_id) == fish)
+ assert(OceanPool.get_fish_by_id(fish_id) == null)
+ assert(LogbookCatalog.category_for(fish) == WaterType.Type.FRESH_WATER)
+
+ var expected_values: Dictionary[StringName, Array] = {
+ &"catfish_blue": [1, 1.25, 3.0, 12.0, 6, 9],
+ &"catfish_channel": [0, 2.0, 1.5, 7.0, 4, 7],
+ &"catfish_flathead": [1, 1.0, 3.0, 14.0, 7, 10],
+ &"catfish_white": [0, 2.0, 1.0, 5.0, 4, 6],
+ }
+ for fish_id: StringName in CATFISH_IDS:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ var values: Array = expected_values[fish_id]
+ assert(int(fish.rarity) == int(values[0]))
+ assert(is_equal_approx(fish.base_catch_weight, float(values[1])))
+ assert(is_equal_approx(fish.weight_min_lb, float(values[2])))
+ assert(is_equal_approx(fish.weight_max_lb, float(values[3])))
+ assert(fish.sell_value_min == int(values[4]))
+ assert(fish.sell_value_max == int(values[5]))
+
+ var expected_tuna_values: Dictionary[StringName, Array] = {
+ &"tuna_albacore": [2, 1.5, 10.0, 45.0, 12, 24],
+ &"tuna_bigeye": [3, 0.55, 20.0, 120.0, 22, 48],
+ &"tuna_bluefin": [4, 0.18, 30.0, 250.0, 35, 90],
+ &"tuna_skipjack": [1, 3.0, 5.0, 25.0, 8, 16],
+ &"tuna_yellowfin": [2, 1.0, 15.0, 80.0, 16, 36],
+ }
+ for fish_id: StringName in expected_tuna_values:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ var values: Array = expected_tuna_values[fish_id]
+ assert(fish != null and fish.is_selectable())
+ assert(int(fish.rarity) == int(values[0]))
+ assert(is_equal_approx(fish.base_catch_weight, float(values[1])))
+ assert(is_equal_approx(fish.weight_min_lb, float(values[2])))
+ assert(is_equal_approx(fish.weight_max_lb, float(values[3])))
+ assert(fish.sell_value_min == int(values[4]))
+ assert(fish.sell_value_max == int(values[5]))
+
+ var expected_new_values: Dictionary[StringName, Array] = {
+ &"goby_round": [0, 8.0, 0.1, 0.6, 2, 3],
+ &"salmon_atlantic": [3, 0.65, 8.0, 40.0, 20, 42],
+ &"salmon_chum": [1, 2.5, 6.0, 25.0, 10, 22],
+ &"salmon_coho": [2, 1.4, 5.0, 30.0, 14, 28],
+ &"salmon_pink": [1, 3.0, 3.0, 12.0, 8, 15],
+ &"salmon_sockeye": [2, 1.6, 4.0, 15.0, 12, 24],
+ }
+ for fish_id: StringName in expected_new_values:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ var values: Array = expected_new_values[fish_id]
+ assert(fish != null and fish.is_selectable())
+ assert(int(fish.rarity) == int(values[0]))
+ assert(is_equal_approx(fish.base_catch_weight, float(values[1])))
+ assert(is_equal_approx(fish.weight_min_lb, float(values[2])))
+ assert(is_equal_approx(fish.weight_max_lb, float(values[3])))
+ assert(fish.sell_value_min == int(values[4]))
+ assert(fish.sell_value_max == int(values[5]))
+
+ var pond_context := FishingContextType.new()
+ pond_context.location_tags = [&"starter_pond"]
+ pond_context.water_type = WaterType.Type.FRESH_WATER
+ var night_pond_context := FishingContextType.new()
+ night_pond_context.location_tags = [&"starter_pond"]
+ night_pond_context.water_type = WaterType.Type.FRESH_WATER
+ night_pond_context.is_night = true
+ var ocean_context := FishingContextType.new()
+ ocean_context.location_tags = [&"coast", &"ocean"]
+ ocean_context.water_type = WaterType.Type.SALT_WATER
+ for fish_id: StringName in CATFISH_IDS:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(not fish.availability.is_available(pond_context))
+ assert(fish.availability.is_available(night_pond_context))
+ assert(not fish.availability.is_available(ocean_context))
+ var single_species_pool := FishPoolType.new()
+ single_species_pool.candidates = [fish]
+ var collection := CollectionLogType.new()
+ var selector := FishSelectorType.new()
+ selector.use_deterministic_test_seed = true
+ selector.begin_roll()
+ assert(
+ selector.select_fish(
+ single_species_pool, night_pond_context, collection
+ ) == fish
+ )
+ collection.free()
+ for fish_id: StringName in SALT_WATER_IDS:
+ var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ var single_species_pool := FishPoolType.new()
+ single_species_pool.candidates = [fish]
+ var collection := CollectionLogType.new()
+ var selector := FishSelectorType.new()
+ selector.use_deterministic_test_seed = true
+ selector.begin_roll()
+ assert(
+ selector.select_fish(single_species_pool, pond_context, collection)
+ == null
+ )
+ assert(
+ selector.select_fish(single_species_pool, ocean_context, collection)
+ == fish
+ )
+ collection.free()
+
+
+func _validate_authoritative_water_filter() -> void:
+ var fishing_spot := FishingSpotType.new()
+ var service := NetworkFishingServiceType.new()
+ service.set("_fish_catalog", Catalog)
+ service.set("_fishing_spot", fishing_spot)
+ var pond_region := FishableWaterRegion.new()
+ pond_region.water_type = WaterType.Type.FRESH_WATER
+ pond_region.fish_pool = PondPool
+ pond_region.location_tags = [&"starter_pond"]
+ var ocean_region := FishableWaterRegion.new()
+ ocean_region.water_type = WaterType.Type.SALT_WATER
+ ocean_region.fish_pool = OceanPool
+ ocean_region.location_tags = [&"coast", &"ocean"]
+ var stale_salt_pool_region := FishableWaterRegion.new()
+ stale_salt_pool_region.water_type = WaterType.Type.FRESH_WATER
+ stale_salt_pool_region.fish_pool = OceanPool
+ stale_salt_pool_region.location_tags = [&"starter_pond"]
+ var stale_fresh_pool_region := FishableWaterRegion.new()
+ stale_fresh_pool_region.water_type = WaterType.Type.SALT_WATER
+ stale_fresh_pool_region.fish_pool = PondPool
+ stale_fresh_pool_region.location_tags = [&"coast", &"ocean"]
+ var evidence := {"discovered_fish_ids": []}
+ assert(
+ service.call("_select_authoritative_fish", pond_region, evidence, null)
+ != null
+ )
+ assert(
+ service.call("_select_authoritative_fish", ocean_region, evidence, null)
+ != null
+ )
+ assert(
+ service.call(
+ "_select_authoritative_fish", stale_salt_pool_region, evidence, null
+ ) == null
+ )
+ assert(
+ service.call(
+ "_select_authoritative_fish", stale_fresh_pool_region, evidence, null
+ ) == null
+ )
+ service.free()
+ fishing_spot.free()
+ pond_region.free()
+ ocean_region.free()
+ stale_salt_pool_region.free()
+ stale_fresh_pool_region.free()
+
+
+func _validate_starter_water_bodies() -> void:
+ var region := StarterRegionScene.instantiate()
+ var pond := region.get_node("WaterBodies/Pond/FishingRegion") as FishableWaterRegion
+ var ocean := region.get_node(
+ "WaterBodies/Ocean/FishingRegions/OceanFishingRegion"
+ ) as FishableWaterRegion
+ assert(pond.water_type == WaterType.Type.FRESH_WATER)
+ assert(ocean.water_type == WaterType.Type.SALT_WATER)
+ assert(pond.fish_pool == PondPool)
+ assert(ocean.fish_pool == OceanPool)
+ assert(region.get_node_or_null("ShorelineRibbons/Pond") == null)
+ var shoreline_geometry := (
+ region.get_node_or_null("ShorelineRibbons/Ocean") as MeshInstance3D
+ )
+ assert(shoreline_geometry != null)
+ assert(not shoreline_geometry.visible)
+ assert(shoreline_geometry.material_override == null)
+ var pond_material := (
+ region.get_node("WaterBodies/Pond/VisualWater") as MeshInstance3D
+ ).material_override as ShaderMaterial
+ var ocean_material := (
+ region.get_node("WaterBodies/Ocean/VisualWater") as MeshInstance3D
+ ).material_override as ShaderMaterial
+ assert(not bool(pond_material.get_shader_parameter("tide_effect_enabled")))
+ assert(bool(ocean_material.get_shader_parameter("tide_effect_enabled")))
+ region.free()
+
+
+func _validate_catches_and_authoritative_sale() -> void:
+ var inventory := FishInventoryType.new()
+ root.add_child(inventory)
+ var selector := FishSelectorType.new()
+ selector.use_deterministic_test_seed = true
+ selector.begin_roll()
+ var sale_service := NetworkSaleServiceType.new()
+ var session := NetworkSession.new()
+ root.add_child(session)
+ root.add_child(sale_service)
+ sale_service.set("_session", session)
+ sale_service.set("_fish_catalog", Catalog)
+ var sale_buyers: Dictionary[StringName, FishBuyerProfileType] = {
+ PelicanBuyer.id: PelicanBuyer,
+ }
+ sale_service.set("_buyers", sale_buyers)
+
+ for index: int in Catalog.candidates.size():
+ var fish: FishDataType = Catalog.candidates[index]
+ var fish_catch: FishCatch = selector.create_catch(fish)
+ assert(fish_catch != null)
+ fish_catch.catch_sequence = index + 1
+ assert(fish_catch.fish.display_texture == fish.display_texture)
+
+ var loaded: FishCatch = FishCatchType.from_save_dict(
+ fish_catch.to_save_dict(), Catalog.get_fish_by_id(fish.id)
+ )
+ assert(loaded != null)
+ assert(loaded.fish_id == fish.id)
+ assert(loaded.fish == fish)
+ assert(
+ is_equal_approx(
+ loaded.display_scale,
+ fish.get_display_scale_for_weight(loaded.weight_lb),
+ )
+ )
+
+ var replicated: FishCatch = FishCatchType.from_network_dict(
+ fish_catch.to_network_dict(), Catalog.get_fish_by_id(fish.id)
+ )
+ assert(replicated != null)
+ assert(replicated.fish_id == fish.id)
+ assert(replicated.fish.display_texture == fish.display_texture)
+ assert(
+ is_equal_approx(
+ replicated.display_scale,
+ fish.get_display_scale_for_weight(replicated.weight_lb),
+ )
+ )
+
+ inventory.add_catch(loaded)
+ assert(inventory.contains_catch_id(loaded.catch_id))
+ var sale_result: Dictionary = sale_service.call(
+ "_build_authoritative_result",
+ 1,
+ "catalog_sale_%d" % index,
+ [loaded.to_network_dict()],
+ PelicanBuyer,
+ )
+ assert(bool(sale_result.get("accepted", false)))
+ assert(int(sale_result.get("base_value", -1)) == loaded.sale_value)
+ assert((sale_result.get("catch_ids", []) as Array).size() == 1)
+
+ inventory.queue_free()
+ sale_service.queue_free()
+ session.queue_free()
diff --git a/tests/fish_catalog_content_validation.gd.uid b/tests/fish_catalog_content_validation.gd.uid
new file mode 100644
index 0000000..15244b8
--- /dev/null
+++ b/tests/fish_catalog_content_validation.gd.uid
@@ -0,0 +1 @@
+uid://dmocxxio0u53e
diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd
new file mode 100644
index 0000000..06b2f4d
--- /dev/null
+++ b/tests/fish_hotbar_showcase_validation.gd
@@ -0,0 +1,220 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ root.size = Vector2i(1280, 720)
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session.start_private_host(17977))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ for _frame: int in 8:
+ await process_frame
+
+ var player := main.get("_player") as Player
+ var world_time := main.get_node("%WorldTimeService") as WorldTimeService
+ var world_weather := (
+ main.get_node("%WorldWeatherService") as WorldWeatherService
+ )
+ world_time.synchronize_time(19.75)
+ assert(player.experience.award_experience(125))
+ var fish_catalog := main.get("fish_catalog") as FishPool
+ var service := main.get_node(
+ "%NetworkFishShowcaseService"
+ ) as NetworkFishShowcaseService
+ assert(player != null and fish_catalog != null and service != null)
+ var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill")
+ assert(fish != null)
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.weight_lb = fish.get_minimum_weight()
+ fish_catch.display_scale = fish.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.quality = FishQuality.Tier.EXCEPTIONAL
+ fish_catch.sale_value = FishQuality.apply_sale_value(
+ fish.get_sale_value_for_weight(fish_catch.weight_lb),
+ fish_catch.quality,
+ )
+ fish_catch.ensure_identity()
+ player.inventory.add_catch(fish_catch)
+ player.collection_log.mark_quality_discovered(
+ fish_catch.fish_id,
+ fish_catch.quality,
+ )
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var hotbar_ui := game_ui.get_node("%Hotbar") as HotbarUI
+ assert(hotbar_ui != null)
+ hotbar_ui.set_drag_enabled(true)
+ var slots: Array = hotbar_ui.get("_slots")
+ var fish_payload: Dictionary = {
+ "kind": "cooler_fish",
+ "catch_id": String(fish_catch.catch_id),
+ }
+ assert(slots.size() == PlayerHotbar.SLOT_COUNT)
+ assert(bool(slots[1].call("_can_drop_data", Vector2.ZERO, fish_payload)))
+ slots[1].call("_drop_data", Vector2.ZERO, fish_payload)
+ assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id)
+ assert(player.hotbar.select_slot(1))
+ assert(
+ player.hotbar.get_selected_assignment_kind()
+ == PlayerHotbar.AssignmentKind.FISH
+ )
+ assert(player.hotbar.get_selected_fish_catch_id() == fish_catch.catch_id)
+ assert(player.hotbar.get_selected_item_id().is_empty())
+
+ assert(service.is_local_showcase_visible())
+ assert(service.get_local_showcase_catch_id() == fish_catch.catch_id)
+ await _wait_for_held_fish_visibility(player, true)
+ assert(service.toggle_selected_fish())
+ await process_frame
+ assert(not service.is_local_showcase_visible())
+ await _wait_for_held_fish_visibility(player, false)
+ var saved_weather: WorldWeatherService.Weather = (
+ world_weather.get_weather()
+ )
+ var saved_weather_seconds: float = world_weather.get_seconds_remaining()
+ var saved_time_hours: float = world_time.get_time_hours()
+
+ assert(save_manager.save_now())
+ var save_path: String = str(save_manager.get("_save_path"))
+ var save_file := FileAccess.open(save_path, FileAccess.READ)
+ assert(save_file != null)
+ var parsed: Variant = JSON.parse_string(save_file.get_as_text())
+ save_file.close()
+ assert(typeof(parsed) == TYPE_DICTIONARY)
+ var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"]
+ assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
+ assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
+ assert(int((parsed as Dictionary)["save_version"]) == 7)
+ assert(
+ int((parsed as Dictionary)["experience"]["total_experience"])
+ == 125
+ )
+ assert(absf(
+ float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours
+ ) < 0.01)
+ assert(
+ int((parsed as Dictionary)["world"]["weather"])
+ == int(saved_weather)
+ )
+ assert(absf(
+ float(
+ (parsed as Dictionary)["world"][
+ "weather_seconds_remaining"
+ ]
+ ) - saved_weather_seconds
+ ) < 0.1)
+ var saved_catches: Array = (parsed as Dictionary)["inventory"]["catches"]
+ assert(int((saved_catches[0] as Dictionary)["quality"]) == fish_catch.quality)
+ var saved_masks: Dictionary = (
+ (parsed as Dictionary)["collection"]["discovered_quality_masks"]
+ )
+ assert(
+ int(saved_masks[String(fish.id)])
+ == FishQuality.bit_for(FishQuality.Tier.EXCEPTIONAL)
+ )
+
+ assert(player.hotbar.clear_slot(1))
+ assert(player.experience.restore_total_experience(0))
+ world_time.synchronize_time(8.0)
+ world_weather.clear_daily_plan()
+ world_weather.apply_authoritative_snapshot(
+ WorldWeatherService.Weather.FOGGY,
+ 10.0,
+ )
+ assert(save_manager.load_player_data())
+ assert(player.experience.get_total_experience() == 125)
+ assert(absf(world_time.get_time_hours() - saved_time_hours) < 0.01)
+ assert(world_weather.get_weather() == saved_weather)
+ assert(absf(
+ world_weather.get_seconds_remaining() - saved_weather_seconds
+ ) < 2.0)
+ assert(player.experience.get_level() == 2)
+ assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id)
+ assert(player.hotbar.get_selected_slot() == 1)
+ assert(
+ player.collection_log.has_discovered_quality(
+ fish.id,
+ FishQuality.Tier.EXCEPTIONAL,
+ )
+ )
+ assert(service.is_local_showcase_visible())
+ await _wait_for_held_fish_visibility(player, true)
+ assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null)
+ await process_frame
+ assert(player.hotbar.get_fish_catch_id(1).is_empty())
+ assert(not service.is_local_showcase_visible())
+ await _wait_for_held_fish_visibility(player, false)
+
+ var valid_state: Dictionary = {
+ "session_id": "session",
+ "owner_peer_id": 2,
+ "visible": true,
+ "fish_id": String(fish.id),
+ "weight_lb": fish.get_minimum_weight(),
+ "display_scale": fish.get_display_scale_for_weight(
+ fish.get_minimum_weight()
+ ),
+ "quality": FishQuality.Tier.BORING,
+ "revision": 1,
+ }
+ assert(NetworkFishShowcaseProtocol.validate_state(valid_state))
+ var invalid_state: Dictionary = valid_state.duplicate(true)
+ invalid_state["display_scale"] = 1000.0
+ assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
+ assert(NetworkProtocol.PROTOCOL_VERSION == 4)
+ assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
+ assert(
+ NetworkProtocol.FISH_QUALITY_CAPABILITY
+ == "fish_quality_v1"
+ )
+ assert(
+ NetworkFishShowcaseProtocol.CAPABILITY
+ == &"fish_showcase_v1"
+ )
+
+ print("Fish hotbar showcase validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _wait_for_held_fish_visibility(
+ player: Player,
+ expected_visible: bool,
+) -> void:
+ var held_fish_display := player.get("_held_fish_display") as Node3D
+ assert(held_fish_display != null)
+ var deadline_msec: int = Time.get_ticks_msec() + 2000
+ while (
+ held_fish_display.visible != expected_visible
+ and Time.get_ticks_msec() < deadline_msec
+ ):
+ await process_frame
+ assert(
+ held_fish_display.visible == expected_visible,
+ "Held fish display did not become %s within the timeout."
+ % expected_visible,
+ )
diff --git a/tests/fish_hotbar_showcase_validation.gd.uid b/tests/fish_hotbar_showcase_validation.gd.uid
new file mode 100644
index 0000000..3fe842e
--- /dev/null
+++ b/tests/fish_hotbar_showcase_validation.gd.uid
@@ -0,0 +1 @@
+uid://cpnfrron1g65q
diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd
new file mode 100644
index 0000000..555fb3c
--- /dev/null
+++ b/tests/fish_quality_validation.gd
@@ -0,0 +1,428 @@
+extends SceneTree
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const FishSelectorType = preload("res://fish/fish_selector.gd")
+const CollectionLogType = preload("res://collection/collection_log.gd")
+const NetworkSaleServiceType = preload(
+ "res://network/network_sale_service.gd"
+)
+const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
+const PelicanBuyer: FishBuyerProfile = preload(
+ "res://economy/buyers/pelicans.tres"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_tiers_and_distribution()
+ _validate_barrier_challenge_curve()
+ _validate_fight_pacing_and_reel_upgrades()
+ _validate_catch_round_trip_and_sale()
+ _validate_mail_round_trip()
+ _validate_collection_mastery()
+ _validate_version_four_migration()
+ assert(NetworkProtocol.PROTOCOL_VERSION == 4)
+ assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
+ assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
+ print("Fish quality validation: PASS")
+ quit()
+
+
+func _validate_tiers_and_distribution() -> void:
+ assert(FishQualityType.TIER_COUNT == 5)
+ assert(FishQualityType.display_name(0) == "boring")
+ assert(FishQualityType.display_name(4) == "shiny")
+ assert(UIPalette.get_quality_color(0) == UIPalette.QUALITY_BORING)
+ assert(UIPalette.get_quality_color(4) == UIPalette.QUALITY_SHINY)
+ assert(
+ UIPalette.get_quality_color(0)
+ != UIPalette.get_quality_color(1)
+ )
+ assert(FishQualityType.apply_sale_value(3, 0) == 3)
+ assert(FishQualityType.apply_sale_value(3, 1) == 4)
+ assert(FishQualityType.apply_sale_value(3, 2) == 5)
+ assert(FishQualityType.apply_sale_value(3, 3) == 6)
+ assert(FishQualityType.apply_sale_value(3, 4) == 7)
+ var previous_offer: int = -1
+ for quality: int in FishQualityType.TIER_COUNT:
+ var offer: int = PelicanBuyer.get_quality_offer(3, quality)
+ assert(offer > previous_offer)
+ previous_offer = offer
+ assert(
+ FishQualityType.qualified_name_with_article("bluegill", 0)
+ == "a boring bluegill"
+ )
+ assert(
+ FishQualityType.qualified_name_with_article("bluegill", 1)
+ == "an average bluegill"
+ )
+ var rng := RandomNumberGenerator.new()
+ rng.seed = 727272
+ var counts: Array[int] = [0, 0, 0, 0, 0]
+ const SAMPLE_COUNT: int = 50000
+ for _sample: int in SAMPLE_COUNT:
+ counts[FishQualityType.roll(rng)] += 1
+ for quality: int in FishQualityType.TIER_COUNT:
+ var observed: float = float(counts[quality]) / float(SAMPLE_COUNT)
+ var expected: float = (
+ FishQualityType.BASE_ROLL_WEIGHTS[quality] / 100.0
+ )
+ assert(absf(observed - expected) < 0.015)
+
+
+func _validate_barrier_challenge_curve() -> void:
+ assert(
+ FishQualityType.BARRIER_HEALTH_MULTIPLIERS.size()
+ == FishQualityType.TIER_COUNT
+ )
+ var expected_minimums: Array[int] = [1, 10, 50, 100, 200]
+ var expected_maximums: Array[int] = [9, 50, 100, 200, 400]
+ var previous_health: int = 0
+ for quality: int in FishQualityType.TIER_COUNT:
+ var expected_range := Vector2i(
+ expected_minimums[quality], expected_maximums[quality]
+ )
+ assert(
+ FishQualityType.barrier_health_range(quality)
+ == expected_range
+ )
+ assert(
+ FishQualityType.roll_barrier_health(null, quality)
+ == expected_range.x
+ )
+ var rng := RandomNumberGenerator.new()
+ rng.seed = 73013 + quality
+ var observed_counts: Dictionary[int, int] = {}
+ var observed_minimum: int = expected_range.y
+ var observed_maximum: int = expected_range.x
+ var observed_total: int = 0
+ var band_size: int = expected_range.y - expected_range.x + 1
+ var sample_count: int = maxi(10000, band_size * 250)
+ for _sample: int in sample_count:
+ var rolled_health: int = FishQualityType.roll_barrier_health(
+ rng, quality
+ )
+ assert(
+ rolled_health >= expected_range.x
+ and rolled_health <= expected_range.y
+ )
+ observed_counts[rolled_health] = (
+ int(observed_counts.get(rolled_health, 0)) + 1
+ )
+ observed_minimum = mini(observed_minimum, rolled_health)
+ observed_maximum = maxi(observed_maximum, rolled_health)
+ observed_total += rolled_health
+ assert(observed_minimum == expected_range.x)
+ assert(observed_maximum == expected_range.y)
+ for authored_health: int in range(
+ expected_range.x, expected_range.y + 1
+ ):
+ assert(observed_counts.has(authored_health))
+ var observed_average: float = (
+ float(observed_total) / float(sample_count)
+ )
+ var authored_midpoint: float = (
+ float(expected_range.x + expected_range.y) / 2.0
+ )
+ assert(
+ absf(observed_average - authored_midpoint)
+ < maxf(0.15, float(band_size) * 0.01)
+ )
+ var health: int = FishQualityType.apply_barrier_health(8, quality)
+ assert(health > previous_health)
+ previous_health = health
+ assert(FishQualityType.apply_barrier_health(8, -1) == 8)
+ assert(FishQualityType.apply_barrier_health(0, FishQualityType.Tier.SHINY) == 4)
+ assert(
+ FishQualityType.barrier_health_range(-1)
+ == Vector2i(1, 9)
+ )
+
+ var profile := CatchDifficultyProfile.new()
+ profile.barrier_count_min = 1
+ profile.barrier_count_max = 1
+ profile.first_barrier_margin = 0.2
+ profile.final_barrier_margin = 0.2
+ profile.minimum_barrier_spacing = 0.1
+ var controller := CatchController.new()
+ root.add_child(controller)
+ for quality: int in FishQualityType.TIER_COUNT:
+ controller.start_authoritative_encounter(
+ profile,
+ 0.1,
+ 1,
+ 818181,
+ quality,
+ )
+ var barriers_value: Variant = controller.get("_barriers")
+ assert(barriers_value is Array)
+ var barriers: Array = barriers_value as Array
+ assert(barriers.size() == 1)
+ var barrier := barriers[0] as RefCounted
+ assert(barrier != null)
+ var health: int = int(barrier.get("maximum_health"))
+ assert(health >= expected_minimums[quality])
+ assert(health <= expected_maximums[quality])
+ controller.queue_free()
+
+ var shiny_health: int = FishQualityType.barrier_health_range(
+ FishQualityType.Tier.SHINY
+ ).y
+ var base_power_clicks: int = ceili(float(shiny_health) / 1.0)
+ var max_power_clicks: int = ceili(
+ float(shiny_health)
+ / float(PlayerFishingUpgrades.MAX_BARRIER_POWER_LEVEL + 1)
+ )
+ assert(base_power_clicks == 400)
+ assert(max_power_clicks == 100)
+ assert(max_power_clicks < base_power_clicks)
+
+
+func _validate_fight_pacing_and_reel_upgrades() -> void:
+ assert(is_equal_approx(CatchController.CHASE_SPEED, 0.07))
+ assert(is_equal_approx(CatchController.CHASE_START_DELAY, 1.0))
+ assert(is_equal_approx(CatchController.CHASE_START_OFFSET, 0.04))
+ assert(is_equal_approx(Player.BASE_REEL_SPEED, 0.16))
+
+ var upgrades := PlayerFishingUpgrades.new()
+ assert(is_equal_approx(upgrades.get_reel_speed_multiplier(), 1.0))
+ assert(
+ upgrades.restore_levels(
+ PlayerFishingUpgrades.MAX_REEL_SPEED_LEVEL,
+ 0,
+ )
+ )
+ var upgraded_multiplier: float = upgrades.get_reel_speed_multiplier()
+ assert(is_equal_approx(upgraded_multiplier, 1.5))
+
+ var profile := CatchDifficultyProfile.new()
+ profile.barrier_count_min = 0
+ profile.barrier_count_max = 0
+ var controller := CatchController.new()
+ root.add_child(controller)
+ controller.start_authoritative_encounter(
+ profile,
+ Player.BASE_REEL_SPEED,
+ 1,
+ 919191,
+ )
+ controller.set_reel_input(true)
+ controller.call("_update_free_reeling", 1.0)
+ var base_progress: float = controller.progress
+ assert(is_equal_approx(base_progress, Player.BASE_REEL_SPEED))
+
+ controller.reset()
+ controller.start_authoritative_encounter(
+ profile,
+ Player.BASE_REEL_SPEED * upgraded_multiplier,
+ 1,
+ 919191,
+ )
+ controller.set_reel_input(true)
+ controller.call("_update_free_reeling", 1.0)
+ assert(
+ is_equal_approx(
+ controller.progress,
+ Player.BASE_REEL_SPEED * upgraded_multiplier,
+ )
+ )
+ assert(controller.progress > base_progress)
+ controller.queue_free()
+ upgrades.queue_free()
+
+
+func _validate_catch_round_trip_and_sale() -> void:
+ var fish: FishData = Catalog.get_fish_by_id(&"bluegill")
+ assert(fish != null)
+ var selector := FishSelectorType.new()
+ selector.use_deterministic_test_seed = true
+ selector.deterministic_test_seed = 9911
+ selector.quality_weight_multipliers = [0.0, 0.0, 0.0, 0.0, 1.0]
+ selector.begin_roll()
+ var fish_catch: FishCatch = selector.create_catch(fish, [&"worm"])
+ assert(fish_catch != null)
+ assert(fish_catch.quality == FishQualityType.Tier.SHINY)
+ assert(
+ fish_catch.sale_value
+ == FishQualityType.apply_sale_value(
+ fish.get_sale_value_for_weight(fish_catch.weight_lb),
+ FishQualityType.Tier.SHINY,
+ )
+ )
+ var save_data: Dictionary = fish_catch.to_save_dict()
+ fish_catch.catch_sequence = 1
+ save_data = fish_catch.to_save_dict()
+ var restored: FishCatch = FishCatchType.from_save_dict(save_data, fish)
+ assert(restored != null and restored.quality == fish_catch.quality)
+ var network_data: Dictionary = fish_catch.to_network_dict()
+ var replicated: FishCatch = FishCatchType.from_network_dict(
+ network_data,
+ fish,
+ )
+ assert(replicated != null and replicated.quality == fish_catch.quality)
+ var missing_quality: Dictionary = network_data.duplicate(true)
+ missing_quality.erase("quality")
+ assert(FishCatchType.from_network_dict(missing_quality, fish) == null)
+
+ var session := NetworkSession.new()
+ var sale_service := NetworkSaleServiceType.new()
+ root.add_child(session)
+ root.add_child(sale_service)
+ sale_service.set("_session", session)
+ sale_service.set("_fish_catalog", Catalog)
+ var accepted: Dictionary = sale_service.call(
+ "_build_authoritative_result",
+ 1,
+ "quality_sale",
+ [network_data],
+ PelicanBuyer,
+ )
+ assert(bool(accepted.get("accepted", false)))
+ assert(
+ int(accepted["payout"])
+ == PelicanBuyer.get_quality_offer(
+ fish.get_sale_value_for_weight(fish_catch.weight_lb),
+ fish_catch.quality,
+ )
+ )
+ var inventory := FishInventory.new()
+ var wallet := PlayerWallet.new()
+ var local_sale := FishSaleService.new()
+ root.add_child(inventory)
+ root.add_child(wallet)
+ root.add_child(local_sale)
+ local_sale.setup(inventory, wallet)
+ inventory.add_catch(fish_catch)
+ var local_preview: FishSaleResult = local_sale.preview_batch(
+ [fish_catch.catch_id],
+ PelicanBuyer,
+ )
+ assert(local_preview.is_success())
+ assert(local_preview.payout == int(accepted["payout"]))
+ var forged: Dictionary = network_data.duplicate(true)
+ forged["quality"] = FishQualityType.Tier.BORING
+ var rejected: Dictionary = sale_service.call(
+ "_build_authoritative_result",
+ 1,
+ "quality_sale_forged",
+ [forged],
+ PelicanBuyer,
+ )
+ assert(not bool(rejected.get("accepted", false)))
+ local_sale.queue_free()
+ wallet.queue_free()
+ inventory.queue_free()
+ sale_service.queue_free()
+ session.queue_free()
+
+
+func _validate_mail_round_trip() -> void:
+ var fish: FishData = Catalog.get_fish_by_id(&"bluegill")
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.weight_lb = fish.get_minimum_weight()
+ fish_catch.display_scale = fish.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.quality = FishQualityType.Tier.IMPRESSIVE
+ fish_catch.sale_value = FishQualityType.apply_sale_value(
+ fish.get_sale_value_for_weight(fish_catch.weight_lb),
+ fish_catch.quality,
+ )
+ fish_catch.ensure_identity()
+ var attachment: Dictionary = {
+ "type": PlayerAssetReservationService.AttachmentType.FISH,
+ "catch_id": String(fish_catch.catch_id),
+ "catch": fish_catch.to_network_dict(),
+ }
+ var service := NetworkMailService.new()
+ root.add_child(service)
+ service.set("_fish_catalog", Catalog)
+ var restored: FishCatch = service.call("_decode_catch", attachment)
+ assert(restored != null)
+ assert(restored.quality == FishQualityType.Tier.IMPRESSIVE)
+ var signature: Array = NetworkMailProtocol.attachment_signature_fields(
+ attachment
+ )
+ assert(int(signature[5]) == FishQualityType.Tier.IMPRESSIVE)
+ var altered: Dictionary = attachment.duplicate(true)
+ (altered["catch"] as Dictionary)["quality"] = (
+ FishQualityType.Tier.BORING
+ )
+ assert(
+ NetworkMailProtocol.attachment_signature_fields(altered)
+ != signature
+ )
+ service.queue_free()
+
+
+func _validate_collection_mastery() -> void:
+ var collection := CollectionLogType.new()
+ root.add_child(collection)
+ for quality: int in FishQualityType.TIER_COUNT:
+ collection.mark_quality_discovered(&"bluegill", quality)
+ assert(collection.has_discovered_quality(&"bluegill", quality))
+ assert(collection.has_discovered(&"bluegill"))
+ assert(collection.has_mastered(&"bluegill"))
+ assert(
+ collection.get_quality_mask(&"bluegill")
+ == FishQualityType.ALL_TIERS_MASK
+ )
+ collection.queue_free()
+
+
+func _validate_version_four_migration() -> void:
+ var manager := PlayerSaveManager.new()
+ root.add_child(manager)
+ var version_four: Dictionary = {
+ "save_version": 4,
+ "wallet": {"balance": 12},
+ "collection": {"discovered_fish_ids": ["bluegill"]},
+ "inventory": {
+ "next_catch_sequence": 2,
+ "catches": [{
+ "catch_id": "carp:legacy",
+ "catch_sequence": 1,
+ "fish_id": "carp",
+ "weight_lb": 2.0,
+ "display_scale": 1.0,
+ "sale_value": 4,
+ "is_favorited": false,
+ }],
+ },
+ "bag": {"items": []},
+ "hotbar": {"selected_slot": 0, "slots": []},
+ "upgrades": {"reel_speed_level": 0, "barrier_power_level": 0},
+ "cooler": {"capacity_level": 0},
+ }
+ var migrated: Dictionary = manager.call(
+ "_migrate_save",
+ version_four,
+ 4,
+ )
+ assert(int(migrated.get("save_version", -1)) == 7)
+ assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
+ assert(
+ is_equal_approx(
+ float((migrated["world"] as Dictionary)["time_hours"]),
+ 8.0,
+ )
+ )
+ var catches: Array = migrated["inventory"]["catches"]
+ assert(int((catches[0] as Dictionary)["quality"]) == 0)
+ var collection: Dictionary = migrated["collection"]
+ var ids: Array = collection["discovered_fish_ids"]
+ assert("bluegill" in ids and "carp" in ids)
+ var masks: Dictionary = collection["discovered_quality_masks"]
+ var boring_bit: int = FishQualityType.bit_for(FishQualityType.Tier.BORING)
+ assert(int(masks["bluegill"]) == boring_bit)
+ assert(int(masks["carp"]) == boring_bit)
+ assert(
+ PlayerJobService.validate_save_data(migrated.get("jobs", {}))
+ )
+ manager.queue_free()
diff --git a/tests/fish_quality_validation.gd.uid b/tests/fish_quality_validation.gd.uid
new file mode 100644
index 0000000..e129478
--- /dev/null
+++ b/tests/fish_quality_validation.gd.uid
@@ -0,0 +1 @@
+uid://bffg3t4du01cu
diff --git a/tests/fish_showcase_multiplayer_validation.gd b/tests/fish_showcase_multiplayer_validation.gd
new file mode 100644
index 0000000..72f4a59
--- /dev/null
+++ b/tests/fish_showcase_multiplayer_validation.gd
@@ -0,0 +1,187 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const TEST_PORT: int = 17978
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("Fish showcase multiplayer validation needs host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session.start_private_host(TEST_PORT))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ var player := main.get("_player") as Player
+ var service := main.get_node(
+ "%NetworkFishShowcaseService"
+ ) as NetworkFishShowcaseService
+ var fish_catch: FishCatch = _add_bluegill(main, player)
+ assert(player.hotbar.assign_fish(1, fish_catch.catch_id))
+ assert(player.hotbar.select_slot(1))
+ assert(service.is_local_showcase_visible())
+ await _wait_for_held_fish_visibility(player, true)
+ assert(session.set_host_open(true))
+
+ var remote_peer_id: int = 0
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0:
+ await process_frame
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ remote_peer_id = peer_id
+ break
+ assert(remote_peer_id > 1)
+ var spawn_service := main.get_node(
+ "%PlayerSpawnService"
+ ) as PlayerSpawnService
+ var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
+ assert(remote_avatar != null)
+ var remote_display := remote_avatar.get("_held_fish_display") as Node3D
+ var visible_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < visible_deadline and not remote_display.visible:
+ await process_frame
+ assert(remote_display.visible)
+ var hidden_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < hidden_deadline and remote_display.visible:
+ await process_frame
+ assert(not remote_display.visible)
+ service.toggle_selected_fish()
+ print("Fish showcase multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call(
+ "_on_title_join_game_requested",
+ "127.0.0.1:%d" % TEST_PORT,
+ )
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ break
+ assert(session.is_joined_client())
+ assert(
+ session.supports_server_capability(
+ NetworkFishShowcaseProtocol.CAPABILITY
+ )
+ )
+ var spawn_service := main.get_node(
+ "%PlayerSpawnService"
+ ) as PlayerSpawnService
+ var host_avatar: Player = spawn_service.get_avatar(1)
+ assert(host_avatar != null)
+ var host_display := host_avatar.get("_held_fish_display") as Node3D
+ var snapshot_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < snapshot_deadline and not host_display.visible:
+ await process_frame
+ assert(host_display.visible)
+
+ var player := main.get("_player") as Player
+ var animation_player := player.get_node(
+ "Visuals/CharacterRig/AnimationPlayer"
+ ) as AnimationPlayer
+ for animation_name: StringName in [
+ &"pocket_idle_idle",
+ &"pocket_idle_show",
+ &"pocket_show_show",
+ &"pocket_show_idle",
+ ]:
+ assert(animation_player.has_animation(animation_name))
+ var service := main.get_node(
+ "%NetworkFishShowcaseService"
+ ) as NetworkFishShowcaseService
+ var fish_catch: FishCatch = _add_bluegill(main, player)
+ assert(player.hotbar.assign_fish(1, fish_catch.catch_id))
+ assert(player.hotbar.select_slot(1))
+ assert(service.is_local_showcase_visible())
+ await _wait_for_held_fish_visibility(player, true)
+ await create_timer(2.0).timeout
+ assert(service.toggle_selected_fish())
+ var held_display := player.get("_held_fish_display") as Node3D
+ assert(held_display.visible)
+ assert(animation_player.current_animation == &"pocket_show_idle")
+ var pocket_deadline: int = Time.get_ticks_msec() + 5000
+ while Time.get_ticks_msec() < pocket_deadline and held_display.visible:
+ await process_frame
+ assert(not held_display.visible)
+ print("Fish showcase multiplayer client validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _add_bluegill(main: Node, player: Player) -> FishCatch:
+ var catalog := main.get("fish_catalog") as FishPool
+ var fish: FishData = catalog.get_fish_by_id(&"bluegill")
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.weight_lb = fish.get_minimum_weight()
+ fish_catch.display_scale = fish.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.sale_value = fish.get_sale_value_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.ensure_identity()
+ player.inventory.add_catch(fish_catch)
+ return fish_catch
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
+
+
+func _wait_for_held_fish_visibility(
+ player: Player,
+ expected_visible: bool,
+) -> void:
+ var held_fish_display := player.get("_held_fish_display") as Node3D
+ assert(held_fish_display != null)
+ var deadline_msec: int = Time.get_ticks_msec() + 2000
+ while (
+ held_fish_display.visible != expected_visible
+ and Time.get_ticks_msec() < deadline_msec
+ ):
+ await process_frame
+ assert(held_fish_display.visible == expected_visible)
diff --git a/tests/fish_showcase_multiplayer_validation.gd.uid b/tests/fish_showcase_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..fb1bfeb
--- /dev/null
+++ b/tests/fish_showcase_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://ckta4mcodq0hg
diff --git a/tests/fishing_audio_validation.gd b/tests/fishing_audio_validation.gd
new file mode 100644
index 0000000..02a59eb
--- /dev/null
+++ b/tests/fishing_audio_validation.gd
@@ -0,0 +1,54 @@
+extends SceneTree
+
+const FishingSpotScene := preload("res://fishing/fishing_spot.tscn")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var fishing_spot := FishingSpotScene.instantiate() as FishingSpot
+ root.add_child(fishing_spot)
+ await process_frame
+
+ var fight_audio := fishing_spot.get_node("%FightAudio") as AudioStreamPlayer
+ var bobber_audio := fishing_spot.get_node(
+ "%BobberAudio"
+ ) as AudioStreamPlayer
+ assert(bobber_audio != null)
+ assert(bobber_audio.stream != null)
+ assert(bobber_audio.bus == &"SFX")
+ assert(is_equal_approx(bobber_audio.volume_db, -14.0))
+ var bobber_stream := bobber_audio.stream as AudioStreamWAV
+ assert(bobber_stream != null)
+ assert(bobber_stream.loop_mode == AudioStreamWAV.LOOP_DISABLED)
+ assert(fight_audio != null)
+ assert(fight_audio.stream != null)
+ assert(fight_audio.bus == &"SFX")
+ assert(is_equal_approx(fight_audio.volume_db, -10.0))
+ var fight_stream := fight_audio.stream as AudioStreamWAV
+ assert(fight_stream != null)
+ assert(fight_stream.loop_mode == AudioStreamWAV.LOOP_FORWARD)
+ assert(fight_stream.loop_end > fight_stream.loop_begin)
+ var reeling_audio := fishing_spot.get_node(
+ "%ReelingAudio"
+ ) as AudioStreamPlayer
+ assert(reeling_audio != null)
+ assert(reeling_audio.stream != null)
+ assert(reeling_audio.bus == &"SFX")
+ assert(is_equal_approx(reeling_audio.volume_db, -10.0))
+ var reeling_stream := reeling_audio.stream as AudioStreamWAV
+ assert(reeling_stream != null)
+ assert(reeling_stream.loop_mode == AudioStreamWAV.LOOP_FORWARD)
+ assert(reeling_stream.loop_end > reeling_stream.loop_begin)
+
+ assert(fishing_spot.has_method("_start_fight_audio"))
+ assert(fishing_spot.has_method("_play_bobber_audio"))
+ assert(fishing_spot.has_method("_stop_fight_audio"))
+ assert(fishing_spot.has_method("_start_reeling_audio"))
+ assert(fishing_spot.has_method("_stop_reeling_audio"))
+ fishing_spot.free()
+ await process_frame
+ print("Fishing audio validation: PASS")
+ quit()
diff --git a/tests/fishing_audio_validation.gd.uid b/tests/fishing_audio_validation.gd.uid
new file mode 100644
index 0000000..e9cd6d7
--- /dev/null
+++ b/tests/fishing_audio_validation.gd.uid
@@ -0,0 +1 @@
+uid://bby8mv7bsjd3w
diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd
new file mode 100644
index 0000000..6b0429d
--- /dev/null
+++ b/tests/fishing_authority_validation.gd
@@ -0,0 +1,186 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ root.size = Vector2i(1280, 720)
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ assert(bool(main.call("_prepare_private_host")))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ for _frame: int in 8:
+ await process_frame
+
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var service := main.get_node(
+ "%NetworkFishingService"
+ ) as NetworkFishingService
+ var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
+ var game_ui := main.get_node("UIPresentation/UIViewport/GameUI")
+ var fishing_status := game_ui.get_node("%StatusLabel") as Label
+ var fishing_panel := game_ui.get_node("%FishingPanel") as PanelContainer
+ var player := main.get("_player") as Player
+ assert(session.is_host())
+ assert(not session.is_open_host())
+ assert(service != null and fishing_spot != null and player != null)
+ assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod")
+ var pond := main.get_node(
+ "TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond"
+ ) as Node3D
+ var pond_region := pond.get_node("FishingRegion") as FishableWaterRegion
+ assert(pond != null and pond_region != null)
+ var pond_surface_y: float = pond_region.get_surface_height()
+
+ player.global_position = pond.global_position + Vector3(8.9, 1.44, 0.0)
+ var visuals := player.get_node("Visuals") as Node3D
+ visuals.rotation.y = PI * 0.5
+ for _frame: int in 4:
+ await physics_frame
+ assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99)
+
+ fishing_spot.call("_begin_aiming", player)
+ assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST)
+ assert(player.is_movement_enabled())
+ fishing_spot.set("_cast_charge", 0.32)
+ fishing_spot.call("_update_cast_charge", 0.0)
+ var aimed_target: Vector3 = fishing_spot.get("_cast_target")
+ assert(aimed_target.x < player.global_position.x - 0.85)
+ assert(is_equal_approx(aimed_target.y, pond_surface_y))
+ assert(fishing_spot.is_target_fishable(aimed_target))
+ fishing_spot.call("_confirm_cast")
+ assert(fishing_spot.state == FishingSpotType.FishingState.CASTING)
+ assert(not player.is_movement_enabled())
+
+ var wait_deadline: int = Time.get_ticks_msec() + 4000
+ while (
+ Time.get_ticks_msec() < wait_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE
+ ):
+ await process_frame
+ assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE)
+ assert(service.has_local_attempt())
+ assert(fishing_status.text.is_empty())
+ assert(not fishing_status.visible)
+ assert(not fishing_panel.visible)
+ var attempts: Dictionary = service.get("_attempts")
+ var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id())
+ assert(attempt != null)
+ assert(is_equal_approx(attempt.target.y, pond_surface_y))
+ assert(attempt.bobber_position.is_equal_approx(attempt.target))
+
+ fishing_spot.set("_withdrawal_input_held", true)
+ fishing_spot.set("_network_primary_input_held", true)
+ service.submit_local_input(true, true)
+ var return_deadline: int = Time.get_ticks_msec() + 4000
+ while (
+ Time.get_ticks_msec() < return_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.RETURNING
+ ):
+ await process_frame
+ assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING)
+ assert(not service.has_local_attempt())
+ var bobber := fishing_spot.get_node(
+ "FishingPresentation/Bobber"
+ ) as MeshInstance3D
+ assert(bobber.visible)
+
+ var ready_deadline: int = Time.get_ticks_msec() + 3000
+ while (
+ Time.get_ticks_msec() < ready_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.READY
+ ):
+ await process_frame
+ assert(fishing_spot.state == FishingSpotType.FishingState.READY)
+ assert(player.is_movement_enabled())
+ assert(not bobber.visible)
+
+ # A private host rolls and retains the authoritative catch before the fight
+ # so its quality selects the barrier band clients receive in snapshots.
+ fishing_spot.call("_begin_aiming", player)
+ fishing_spot.set("_cast_charge", 0.32)
+ fishing_spot.call("_update_cast_charge", 0.0)
+ fishing_spot.call("_confirm_cast")
+ var second_wait_deadline: int = Time.get_ticks_msec() + 4000
+ while (
+ Time.get_ticks_msec() < second_wait_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE
+ ):
+ await process_frame
+ assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE)
+ attempts = service.get("_attempts")
+ attempt = attempts.get(session.get_local_peer_id())
+ assert(attempt != null)
+ service.call("_start_bite", attempt)
+ await process_frame
+ assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING)
+ var catalog: FishPool = main.get("fish_catalog") as FishPool
+ assert(catalog != null)
+ var fish: FishData = catalog.get_fish_by_id(attempt.fish_id)
+ var fish_catch := FishCatch.from_network_dict(
+ attempt.catch_payload,
+ fish,
+ )
+ assert(fish_catch != null and fish_catch.is_valid())
+ var reference_controller := CatchController.new()
+ root.add_child(reference_controller)
+ reference_controller.start_authoritative_encounter(
+ fish.catch_profile,
+ attempt.reel_speed,
+ attempt.barrier_damage,
+ attempt.encounter_seed,
+ fish_catch.quality,
+ )
+ var quality_barriers: Array = attempt.controller.get("_barriers")
+ var reference_barriers: Array = reference_controller.get("_barriers")
+ assert(quality_barriers.size() == reference_barriers.size())
+ for barrier_index: int in quality_barriers.size():
+ var quality_barrier: RefCounted = quality_barriers[barrier_index]
+ var reference_barrier: RefCounted = reference_barriers[barrier_index]
+ assert(
+ is_equal_approx(
+ float(quality_barrier.get("position")),
+ float(reference_barrier.get("position")),
+ )
+ )
+ assert(
+ int(quality_barrier.get("maximum_health"))
+ == int(reference_barrier.get("maximum_health"))
+ )
+ assert(
+ int(quality_barrier.get("maximum_health"))
+ >= FishQuality.BARRIER_HEALTH_MINIMUMS[fish_catch.quality]
+ )
+ assert(
+ int(quality_barrier.get("maximum_health"))
+ <= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality]
+ )
+ reference_controller.queue_free()
+ service.call("_on_attempt_escaped", session.get_local_peer_id())
+ await process_frame
+ assert(not service.has_local_attempt())
+ assert(fishing_status.text.is_empty())
+ assert(not fishing_status.visible)
+ assert(not fishing_panel.visible)
+
+ print("Fishing authority validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
diff --git a/tests/fishing_authority_validation.gd.uid b/tests/fishing_authority_validation.gd.uid
new file mode 100644
index 0000000..650669c
--- /dev/null
+++ b/tests/fishing_authority_validation.gd.uid
@@ -0,0 +1 @@
+uid://cgqcyldad6dsj
diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd
new file mode 100644
index 0000000..c40e492
--- /dev/null
+++ b/tests/fishing_multiplayer_validation.gd
@@ -0,0 +1,360 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("Fishing multiplayer validation requires host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ assert(bool(main.call("_prepare_private_host")))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session.set_host_open(true))
+ var service := main.get_node(
+ "%NetworkFishingService"
+ ) as NetworkFishingService
+
+ var join_deadline: int = Time.get_ticks_msec() + 60000
+ while (
+ Time.get_ticks_msec() < join_deadline
+ and session.get_authenticated_peer_ids().size() < 2
+ ):
+ await process_frame
+ assert(session.get_authenticated_peer_ids().size() == 2)
+ var remote_peer_id: int = 0
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ remote_peer_id = peer_id
+ break
+ assert(remote_peer_id > 1)
+ var spawn_service := main.get_node(
+ "%PlayerSpawnService"
+ ) as PlayerSpawnService
+ var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
+ assert(remote_avatar != null)
+ assert(remote_avatar.bag.get_quantity(&"worms") == 0)
+ remote_avatar.global_position = Vector3(-0.5, 3.95, 2.1)
+ var remote_visuals := remote_avatar.get_node("Visuals") as Node3D
+ remote_visuals.rotation.y = PI * 0.5
+ session.publish_authoritative_teleport(remote_peer_id)
+ var aiming_deadline: int = Time.get_ticks_msec() + 15000
+ while (
+ Time.get_ticks_msec() < aiming_deadline
+ and int(remote_avatar.get("_fishing_visual_phase"))
+ != Player.FishingVisualPhase.CASTING
+ ):
+ await process_frame
+ assert(
+ int(remote_avatar.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.CASTING
+ )
+ var remote_position_error := (
+ remote_avatar.global_position - Vector3(-0.5, 3.95, 2.1)
+ )
+ remote_position_error.y = 0.0
+ assert(
+ remote_position_error.length() <= 0.25,
+ "remote casting position drifted: %s" % remote_avatar.global_position
+ )
+ assert(
+ remote_avatar.get_facing_direction().dot(Vector3.LEFT) > 0.99,
+ "remote casting facing drifted: %s"
+ % remote_avatar.get_facing_direction()
+ )
+
+ var saw_remote_attempt: bool = false
+ var remote_presentation: RemoteFishingPresentation
+ var attempt_deadline: int = Time.get_ticks_msec() + 12000
+ while Time.get_ticks_msec() < attempt_deadline:
+ await process_frame
+ var attempts: Dictionary = service.get("_attempts")
+ for peer_id: int in attempts:
+ if peer_id == session.get_local_peer_id():
+ continue
+ var attempt: NetworkFishingAttempt = attempts[peer_id]
+ assert(attempt.target.is_finite())
+ assert(attempt.bobber_position.distance_to(attempt.target) <= 0.001)
+ var presentations: Dictionary = service.get("_remote_presentations")
+ remote_presentation = presentations.get(peer_id)
+ assert(remote_presentation != null)
+ var remote_bobber := remote_presentation.get(
+ "_bobber"
+ ) as MeshInstance3D
+ assert(remote_bobber.visible)
+ saw_remote_attempt = true
+ if saw_remote_attempt:
+ break
+ assert(saw_remote_attempt)
+ assert(
+ int(remote_avatar.get("_fishing_visual_phase"))
+ in [
+ Player.FishingVisualPhase.RELEASE,
+ Player.FishingVisualPhase.FISHING,
+ ]
+ )
+ var sitting_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < sitting_deadline and not remote_avatar.is_sitting():
+ await process_frame
+ assert(remote_avatar.is_sitting())
+ var fishing_deadline: int = Time.get_ticks_msec() + 5000
+ while (
+ Time.get_ticks_msec() < fishing_deadline
+ and int(remote_avatar.get("_fishing_visual_phase"))
+ != Player.FishingVisualPhase.FISHING
+ ):
+ await process_frame
+ assert(
+ int(remote_avatar.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.FISHING
+ )
+ var remote_animation_player := remote_avatar.get_node(
+ "Visuals/CharacterRig/AnimationPlayer"
+ ) as AnimationPlayer
+ var return_deadline: int = Time.get_ticks_msec() + 5000
+ while (
+ Time.get_ticks_msec() < return_deadline
+ and service.has_peer_attempt(remote_peer_id)
+ ):
+ await process_frame
+ assert(not service.has_peer_attempt(remote_peer_id))
+ assert(
+ int(remote_avatar.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.RETRACT
+ )
+ var retract_animation_deadline: int = Time.get_ticks_msec() + 3000
+ while (
+ Time.get_ticks_msec() < retract_animation_deadline
+ and remote_animation_player.current_animation != &"retract_sit"
+ ):
+ await process_frame
+ assert(remote_animation_player.current_animation == &"retract_sit")
+
+ print("Fishing multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call("_on_title_join_game_requested", "127.0.0.1:7777")
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var joined: bool = false
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ joined = true
+ break
+ assert(joined)
+
+ var service := main.get_node(
+ "%NetworkFishingService"
+ ) as NetworkFishingService
+ var cast_rejections: Array[String] = []
+ service.local_cast_rejected.connect(
+ func(message: String) -> void:
+ cast_rejections.append(message)
+ )
+ var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
+ var player := main.get("_player") as Player
+ var item_catalog := main.get("item_catalog") as ItemCatalog
+ var worms: ItemData = item_catalog.get_item_by_id(&"worms")
+ if player.bag.get_quantity(&"worms") == 0:
+ assert(player.bag.add_item(&"worms", 1))
+ assert(player.equip_bait(worms))
+ var worms_before_cast: int = player.bag.get_quantity(&"worms")
+ var character_animation_player := player.get_node(
+ "Visuals/CharacterRig/AnimationPlayer"
+ ) as AnimationPlayer
+ assert(character_animation_player.has_animation(&"retract_sit"))
+ var placement_deadline: int = Time.get_ticks_msec() + 5000
+ while (
+ Time.get_ticks_msec() < placement_deadline
+ and player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1))
+ > 0.25
+ ):
+ await physics_frame
+ assert(player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) <= 0.25)
+ assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99)
+
+ player.set_casting_visual()
+ assert(
+ int(player.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.CASTING
+ )
+ for _frame: int in 30:
+ await physics_frame
+ player.set_fishing_visual(false)
+ player.call("_set_sitting", true, true)
+ assert(player.is_sitting())
+ for _frame: int in 15:
+ await physics_frame
+ fishing_spot.call("_begin_aiming", player)
+ assert(player.is_movement_enabled())
+ assert(
+ int(player.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.CASTING
+ )
+ var cast_charge: float = 0.0
+ var target: Vector3 = Vector3.ZERO
+ var cast_origin: Vector3 = fishing_spot.get("_cast_origin_position")
+ for charge_step: int in range(1, 21):
+ var candidate_charge := float(charge_step) / 20.0
+ fishing_spot.set("_cast_charge", candidate_charge)
+ fishing_spot.call("_update_cast_charge", 0.0)
+ var candidate_target: Vector3 = fishing_spot.get("_cast_target")
+ var candidate_surface := fishing_spot.get(
+ "_aim_surface_sample"
+ ) as FishingSurfaceSample
+ if (
+ candidate_surface.is_fishable()
+ and fishing_spot.is_cast_path_clear(cast_origin, candidate_target)
+ ):
+ cast_charge = candidate_charge
+ target = candidate_target
+ break
+ assert(cast_charge > 0.0, "no clear fishable test target was found")
+ var evidence: Dictionary = fishing_spot.call("_build_network_evidence")
+ fishing_spot.set("state", FishingSpotType.FishingState.CASTING)
+ player.set_movement_enabled(false)
+ player.set_release_visual()
+ assert(
+ int(player.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.RELEASE
+ )
+ assert(
+ not service.request_local_cast(
+ cast_origin,
+ target,
+ cast_charge,
+ evidence,
+ ).is_empty()
+ )
+
+ var accepted_deadline: int = Time.get_ticks_msec() + 6000
+ while (
+ Time.get_ticks_msec() < accepted_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE
+ ):
+ await process_frame
+ assert(
+ fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE,
+ (
+ "cast acceptance timed out: state=%s local_attempt=%s target=%s "
+ + "rejections=%s"
+ ) % [
+ fishing_spot.state,
+ service.has_local_attempt(),
+ target,
+ cast_rejections,
+ ]
+ )
+ assert(service.has_local_attempt())
+ assert(player.bag.get_quantity(&"worms") == worms_before_cast - 1)
+ var fishing_deadline: int = Time.get_ticks_msec() + 5000
+ while (
+ Time.get_ticks_msec() < fishing_deadline
+ and int(player.get("_fishing_visual_phase"))
+ != Player.FishingVisualPhase.FISHING
+ ):
+ await process_frame
+ assert(
+ int(player.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.FISHING
+ )
+ for _frame: int in 30:
+ await physics_frame
+ fishing_spot.set("_withdrawal_input_held", true)
+ fishing_spot.set("_network_primary_input_held", true)
+ service.submit_local_input(true, true)
+
+ var return_deadline: int = Time.get_ticks_msec() + 5000
+ while (
+ Time.get_ticks_msec() < return_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.RETURNING
+ ):
+ await process_frame
+ assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING)
+ assert(
+ int(player.get("_fishing_visual_phase"))
+ == Player.FishingVisualPhase.RETRACT
+ )
+ var retract_animation_deadline: int = Time.get_ticks_msec() + 3000
+ while (
+ Time.get_ticks_msec() < retract_animation_deadline
+ and character_animation_player.current_animation != &"retract_sit"
+ ):
+ await process_frame
+ assert(character_animation_player.current_animation == &"retract_sit")
+ var bobber := fishing_spot.get_node(
+ "FishingPresentation/Bobber"
+ ) as MeshInstance3D
+ assert(bobber.visible)
+ while not player.is_retract_visual_complete():
+ assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING)
+ await process_frame
+
+ var ready_deadline: int = Time.get_ticks_msec() + 3000
+ while (
+ Time.get_ticks_msec() < ready_deadline
+ and fishing_spot.state != FishingSpotType.FishingState.READY
+ ):
+ await process_frame
+ assert(fishing_spot.state == FishingSpotType.FishingState.READY)
+ assert(player.is_movement_enabled())
+ assert(not bobber.visible)
+ var host_observation_deadline: int = Time.get_ticks_msec() + 2000
+ while Time.get_ticks_msec() < host_observation_deadline:
+ await process_frame
+ print("Fishing multiplayer client validation: PASS")
+ var host_completion_deadline: int = Time.get_ticks_msec() + 10000
+ while (
+ Time.get_ticks_msec() < host_completion_deadline
+ and session.is_joined_client()
+ ):
+ await process_frame
+ if session.is_joined_client():
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
diff --git a/tests/fishing_multiplayer_validation.gd.uid b/tests/fishing_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..c8fcbd8
--- /dev/null
+++ b/tests/fishing_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://chyq7wjhpko7d
diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd
new file mode 100644
index 0000000..5b52b41
--- /dev/null
+++ b/tests/fishing_surface_validation.gd
@@ -0,0 +1,458 @@
+extends SceneTree
+
+const FishingSpotScene = preload("res://fishing/fishing_spot.tscn")
+const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+const FishingPresentationType = preload(
+ "res://fishing/fishing_presentation.gd"
+)
+const RemoteFishingPresentationType = preload(
+ "res://fishing/remote_fishing_presentation.gd"
+)
+const PlayerScene = preload("res://player/player.tscn")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishingSurfaceResolverType = preload(
+ "res://fishing/fishing_surface_resolver.gd"
+)
+const FishingSurfaceSampleType = preload(
+ "res://fishing/fishing_surface_sample.gd"
+)
+const FishableWaterRegionType = preload(
+ "res://world/fishable_water_region.gd"
+)
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const WaterBodyScene = preload("res://world/water_body.tscn")
+const StarterRegionScene = preload(
+ "res://world/regions/starter_island_region.tscn"
+)
+const PondPool: FishPoolType = preload(
+ "res://fish/pools/starter_pond_pool.tres"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ await _validate_resolver_layers()
+ await _validate_portable_water_body()
+ await _validate_starter_region_surfaces()
+ await _validate_presentation()
+ await _validate_remote_presentation()
+ await _validate_bite_wait_distribution()
+ print("Fishing surface validation: PASS")
+ quit()
+
+
+func _validate_resolver_layers() -> void:
+ var world := Node3D.new()
+ root.add_child(world)
+ _add_solid_box(world, Vector3(40.0, 2.0, 12.0), Vector3(10.0, -1.0, 0.0))
+ _add_water_region(world, Vector3(0.0, 2.0, 0.0), Vector2(8.0, 8.0), PondPool)
+ _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(2.0, 2.25, 0.0))
+ _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(-2.0, 6.0, 0.0))
+ _add_water_region(world, Vector3(10.0, 5.0, 0.0), Vector2(6.0, 6.0), PondPool)
+ _add_water_region(world, Vector3(20.0, 3.0, 0.0), Vector2(6.0, 6.0), null)
+ await physics_frame
+ await physics_frame
+
+ var resolver := FishingSurfaceResolverType.new()
+ var space_state: PhysicsDirectSpaceState3D = world.get_world_3d().direct_space_state
+ var water: FishingSurfaceSampleType = resolver.resolve_surface(
+ space_state,
+ Vector3.ZERO,
+ 4.0,
+ )
+ assert(water.is_fishable())
+ assert(is_equal_approx(water.position.y, 2.0))
+ var water_under_overhang: FishingSurfaceSampleType = resolver.resolve_surface(
+ space_state,
+ Vector3(-2.0, 4.0, 0.0),
+ 4.0,
+ 0.08,
+ )
+ assert(water_under_overhang.is_fishable())
+ assert(is_equal_approx(water_under_overhang.position.y, 2.0))
+
+ var covered_water: FishingSurfaceSampleType = resolver.resolve_surface(
+ space_state,
+ Vector3(2.0, 2.0, 0.0),
+ 4.0,
+ )
+ assert(covered_water.has_surface)
+ assert(not covered_water.is_water_surface)
+ assert(is_equal_approx(covered_water.position.y, 2.75))
+
+ var elevated_water: FishingSurfaceSampleType = resolver.resolve_surface(
+ space_state,
+ Vector3(10.0, 0.0, 0.0),
+ 8.0,
+ )
+ assert(elevated_water.is_fishable())
+ assert(is_equal_approx(elevated_water.position.y, 5.0))
+
+ var unstocked_water: FishingSurfaceSampleType = resolver.resolve_surface(
+ space_state,
+ Vector3(20.0, 0.0, 0.0),
+ 6.0,
+ )
+ assert(unstocked_water.has_surface)
+ assert(unstocked_water.is_water_surface)
+ assert(not unstocked_water.is_fishable())
+ assert(is_equal_approx(unstocked_water.position.y, 3.0))
+
+ var safe_reel: FishingSurfaceSampleType = resolver.resolve_withdrawal_surface(
+ space_state,
+ Vector3(0.0, 2.0, 0.0),
+ Vector3(3.0, 2.0, 0.0),
+ Vector3.RIGHT,
+ 4.0,
+ 0.4,
+ )
+ assert(safe_reel.is_fishable())
+ assert(is_equal_approx(safe_reel.position.y, 2.0))
+ var shoreline_reel: FishingSurfaceSampleType = (
+ resolver.resolve_withdrawal_surface(
+ space_state,
+ Vector3(3.0, 2.0, 0.0),
+ Vector3(3.7, 2.0, 0.0),
+ Vector3.RIGHT,
+ 4.0,
+ 0.4,
+ )
+ )
+ assert(not shoreline_reel.is_fishable())
+ assert(shoreline_reel.position.is_equal_approx(Vector3(3.0, 2.0, 0.0)))
+
+ world.queue_free()
+ await process_frame
+
+
+func _validate_portable_water_body() -> void:
+ var water_body := WaterBodyScene.instantiate() as Node3D
+ water_body.position = Vector3(3.0, 7.0, -2.0)
+ water_body.set("surface_size", Vector2(12.0, 8.0))
+ water_body.set("fishing_depth", 6.0)
+ water_body.set("recovery_depth", 7.0)
+ water_body.set("fish_pool", PondPool)
+ root.add_child(water_body)
+ await process_frame
+
+ var visual := water_body.get_node("VisualWater") as MeshInstance3D
+ var visual_mesh := visual.mesh as PlaneMesh
+ assert(visual_mesh.size.is_equal_approx(Vector2(12.0, 8.0)))
+ var fishing_region := water_body.get_node(
+ "FishingRegion"
+ ) as FishableWaterRegionType
+ var fishing_shape_node := water_body.get_node(
+ "FishingRegion/Shape"
+ ) as CollisionShape3D
+ var fishing_shape := fishing_shape_node.shape as BoxShape3D
+ assert(fishing_region.fish_pool == PondPool)
+ assert(is_equal_approx(fishing_region.get_surface_height(), 7.0))
+ assert(is_equal_approx(fishing_shape_node.position.y, -3.0))
+ assert(fishing_shape.size.is_equal_approx(Vector3(12.0, 6.0, 8.0)))
+ var recovery_region := water_body.get_node("RecoveryRegion") as Area3D
+ assert(is_equal_approx(recovery_region.position.y, -3.5))
+
+ water_body.queue_free()
+ await process_frame
+
+
+func _validate_starter_region_surfaces() -> void:
+ var region := StarterRegionScene.instantiate() as Node3D
+ root.add_child(region)
+ var ocean_region := region.get_node(
+ "WaterBodies/Ocean/FishingRegions/OceanFishingRegion"
+ ) as FishableWaterRegionType
+ var minimum_x: float = INF
+ var maximum_x: float = -INF
+ var minimum_z: float = INF
+ var maximum_z: float = -INF
+ for child: Node in ocean_region.get_children():
+ var shape_node := child as CollisionShape3D
+ assert(shape_node != null and shape_node.shape is BoxShape3D)
+ var box := shape_node.shape as BoxShape3D
+ assert(is_equal_approx(box.size.y, 2.0))
+ minimum_x = minf(minimum_x, shape_node.position.x - box.size.x * 0.5)
+ maximum_x = maxf(maximum_x, shape_node.position.x + box.size.x * 0.5)
+ minimum_z = minf(minimum_z, shape_node.position.z - box.size.z * 0.5)
+ maximum_z = maxf(maximum_z, shape_node.position.z + box.size.z * 0.5)
+ var ocean_footprint := Vector2(
+ maximum_x - minimum_x,
+ maximum_z - minimum_z,
+ )
+ assert(is_equal_approx(
+ ocean_footprint.x * ocean_footprint.y,
+ 12840.0,
+ ))
+ assert(is_equal_approx(
+ ocean_footprint.x / 107.0,
+ ocean_footprint.y / 80.0,
+ ))
+ var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType
+ root.add_child(fishing_spot)
+ await physics_frame
+ await physics_frame
+
+ var pond: FishingSurfaceSampleType = fishing_spot.resolve_fishing_surface(
+ Vector3(-9.4, 2.51, 2.1),
+ 4.0,
+ )
+ assert(pond.is_fishable())
+ assert(is_equal_approx(pond.position.y, 2.51))
+ var ocean: FishingSurfaceSampleType = fishing_spot.resolve_fishing_surface(
+ Vector3(50.0, -0.45, 0.0),
+ 4.0,
+ )
+ assert(ocean.is_fishable())
+ assert(is_equal_approx(ocean.position.y, -0.45))
+ var expanded_ocean: FishingSurfaceSampleType = (
+ fishing_spot.resolve_fishing_surface(
+ Vector3(72.0, -0.45, 0.0),
+ 4.0,
+ )
+ )
+ assert(expanded_ocean.is_fishable())
+ assert(is_equal_approx(expanded_ocean.position.y, -0.45))
+ var covered_ocean: FishingSurfaceSampleType = (
+ fishing_spot.resolve_fishing_surface(
+ Vector3(20.0, -0.45, 0.0),
+ 4.0,
+ )
+ )
+ assert(covered_ocean.has_surface)
+ assert(not covered_ocean.is_fishable())
+ assert(covered_ocean.position.y > -0.45)
+
+ # The ocean fishing layer covers the island's complete editable footprint.
+ # Terrain at or above the waterline remains authoritative, while shoreline
+ # terrain lowered below the surface becomes fishable without reshaping a
+ # manually authored exclusion ring.
+ var editable_shallows_found := 0
+ for x_position: int in range(-24, 24, 2):
+ for z_position: int in range(-21, 22, 2):
+ var candidate: FishingSurfaceSampleType = (
+ fishing_spot.resolve_fishing_surface(
+ Vector3(x_position, -0.45, z_position),
+ 4.0,
+ )
+ )
+ if (
+ candidate.is_fishable()
+ and candidate.water_region.water_type
+ == WaterType.Type.SALT_WATER
+ ):
+ editable_shallows_found += 1
+ assert(editable_shallows_found > 0)
+
+ fishing_spot.queue_free()
+ region.queue_free()
+ await process_frame
+
+
+func _validate_presentation() -> void:
+ var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType
+ root.add_child(fishing_spot)
+ var presentation := fishing_spot.get_node(
+ "FishingPresentation"
+ ) as FishingPresentationType
+ var rod := Node3D.new()
+ var rod_tip := Marker3D.new()
+ rod.add_child(rod_tip)
+ root.add_child(rod)
+ rod_tip.position = Vector3(0.0, 1.0, 0.0)
+ await process_frame
+
+ var slope_normal := Vector3(0.0, 1.0, 1.0).normalized()
+ presentation.begin_aim(
+ rod_tip,
+ rod,
+ Vector3(0.0, 0.1, -1.0),
+ slope_normal,
+ false,
+ )
+ var marker := presentation.get_node("CastTargetMarker") as Node3D
+ var valid_marker := presentation.get_node(
+ "CastTargetMarker/ValidTargetMarker"
+ ) as MeshInstance3D
+ var invalid_marker := presentation.get_node(
+ "CastTargetMarker/InvalidTargetMarker"
+ ) as Node3D
+ assert(not valid_marker.visible)
+ assert(invalid_marker.visible)
+ assert(invalid_marker.get_child_count() == 2)
+ assert(marker.global_basis.y.normalized().dot(slope_normal) > 0.999)
+
+ var landing := Vector3(0.0, 0.13, -4.0)
+ presentation.begin_cast(landing, landing, true)
+ await presentation.cast_completed
+ var bobber := presentation.get_node("Bobber") as MeshInstance3D
+ assert(bobber.visible)
+ assert(bobber.global_position.is_equal_approx(landing))
+ presentation.play_outcome(&"invalid")
+ await process_frame
+ assert(bobber.visible)
+ await presentation.outcome_completed
+ assert(not bobber.visible)
+
+ rod.queue_free()
+ fishing_spot.queue_free()
+ await process_frame
+
+
+func _validate_bite_wait_distribution() -> void:
+ var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType
+ root.add_child(fishing_spot)
+ await process_frame
+ var bite_rng := fishing_spot.get("_bite_rng") as RandomNumberGenerator
+ bite_rng.seed = 84219
+ var quick_count: int = 0
+ var typical_or_long_count: int = 0
+ var very_long_count: int = 0
+ var total_wait_seconds: float = 0.0
+ for _sample_index: int in 10000:
+ var wait_seconds: float = fishing_spot.roll_bite_wait_time()
+ assert(wait_seconds >= FishingSpotType.BITE_QUICK_MIN_SECONDS)
+ assert(wait_seconds <= FishingSpotType.BITE_MAX_SECONDS)
+ total_wait_seconds += wait_seconds
+ if wait_seconds < FishingSpotType.BITE_QUICK_MAX_SECONDS:
+ quick_count += 1
+ else:
+ typical_or_long_count += 1
+ if wait_seconds >= FishingSpotType.BITE_LONG_MAX_SECONDS:
+ very_long_count += 1
+ var average_wait_seconds: float = total_wait_seconds / 10000.0
+ var quick_ratio: float = float(quick_count) / 10000.0
+ var very_long_ratio: float = float(very_long_count) / 10000.0
+ var very_long_probability: float = 1.0 - (
+ FishingSpotType.BITE_QUICK_PROBABILITY
+ + FishingSpotType.BITE_TYPICAL_PROBABILITY
+ + FishingSpotType.BITE_LONG_PROBABILITY
+ )
+ var expected_average_wait_seconds: float = (
+ FishingSpotType.BITE_QUICK_PROBABILITY
+ * (
+ FishingSpotType.BITE_QUICK_MIN_SECONDS
+ + FishingSpotType.BITE_QUICK_MAX_SECONDS
+ )
+ * 0.5
+ + FishingSpotType.BITE_TYPICAL_PROBABILITY
+ * (
+ FishingSpotType.BITE_QUICK_MAX_SECONDS
+ + FishingSpotType.BITE_TYPICAL_MAX_SECONDS
+ )
+ * 0.5
+ + FishingSpotType.BITE_LONG_PROBABILITY
+ * (
+ FishingSpotType.BITE_TYPICAL_MAX_SECONDS
+ + FishingSpotType.BITE_LONG_MAX_SECONDS
+ )
+ * 0.5
+ + very_long_probability
+ * (
+ FishingSpotType.BITE_LONG_MAX_SECONDS
+ + FishingSpotType.BITE_MAX_SECONDS
+ )
+ * 0.5
+ )
+ assert(absf(
+ quick_ratio - FishingSpotType.BITE_QUICK_PROBABILITY
+ ) <= 0.02)
+ assert(typical_or_long_count > quick_count)
+ assert(absf(very_long_ratio - very_long_probability) <= 0.005)
+ assert(absf(
+ average_wait_seconds - expected_average_wait_seconds
+ ) <= 1.0)
+ fishing_spot.queue_free()
+ await process_frame
+
+
+func _validate_remote_presentation() -> void:
+ var player := PlayerScene.instantiate() as Player
+ root.add_child(player)
+ player.set_local_control(false)
+ var presentation := RemoteFishingPresentationType.new()
+ root.add_child(presentation)
+ presentation.setup(player)
+ await process_frame
+ var origin: Vector3 = player.get_fishing_rod_tip().global_position
+ var target: Vector3 = origin + Vector3(-4.0, -0.5, 0.0)
+ presentation.show_cast(origin, target)
+ var bobber := presentation.get("_bobber") as MeshInstance3D
+ assert(bobber.visible)
+ assert(presentation.get("_cast_tween") != null)
+ await create_timer(0.7).timeout
+ assert(bobber.global_position.distance_to(target) < 0.1)
+ var first_bob_y: float = bobber.global_position.y
+ await create_timer(0.2).timeout
+ assert(not is_equal_approx(bobber.global_position.y, first_bob_y))
+
+ var fish_catch := FishCatchType.new()
+ var fish: FishData = PondPool.candidates.front()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.catch_id = &"remote-presentation-test"
+ fish_catch.weight_lb = 1.0
+ fish_catch.display_scale = 1.0
+ fish_catch.sale_value = 1
+ assert(fish_catch.is_valid())
+ presentation.play_return(fish_catch)
+ var catch_display := player.find_child(
+ "CatchDisplay", true, false
+ ) as Node3D
+ assert(catch_display != null)
+ var showcase_deadline_msec: int = Time.get_ticks_msec() + 2000
+ while (
+ not catch_display.visible
+ and Time.get_ticks_msec() < showcase_deadline_msec
+ ):
+ await process_frame
+ assert(catch_display.visible)
+ await presentation.return_completed
+ assert(not catch_display.visible)
+
+ presentation.queue_free()
+ player.queue_free()
+ await process_frame
+
+
+func _add_water_region(
+ parent: Node3D,
+ position: Vector3,
+ surface_size: Vector2,
+ fish_pool: FishPoolType,
+) -> void:
+ var water_root := Node3D.new()
+ water_root.position = position
+ parent.add_child(water_root)
+ var region := FishableWaterRegionType.new()
+ region.collision_layer = 4
+ region.collision_mask = 0
+ region.monitoring = false
+ region.surface_height_mode = FishableWaterRegionType.SurfaceHeightMode.PARENT_GLOBAL_Y
+ region.fish_pool = fish_pool
+ water_root.add_child(region)
+ var shape_node := CollisionShape3D.new()
+ shape_node.position.y = -2.0
+ var shape := BoxShape3D.new()
+ shape.size = Vector3(surface_size.x, 4.0, surface_size.y)
+ shape_node.shape = shape
+ region.add_child(shape_node)
+
+
+func _add_solid_box(
+ parent: Node3D,
+ size: Vector3,
+ position: Vector3,
+) -> void:
+ var body := StaticBody3D.new()
+ body.position = position
+ body.collision_layer = 1
+ body.collision_mask = 0
+ parent.add_child(body)
+ var shape_node := CollisionShape3D.new()
+ var shape := BoxShape3D.new()
+ shape.size = size
+ shape_node.shape = shape
+ body.add_child(shape_node)
diff --git a/tests/fishing_surface_validation.gd.uid b/tests/fishing_surface_validation.gd.uid
new file mode 100644
index 0000000..3954c8a
--- /dev/null
+++ b/tests/fishing_surface_validation.gd.uid
@@ -0,0 +1 @@
+uid://bh8g62bagphbd
diff --git a/tests/fur_pattern_validation.gd b/tests/fur_pattern_validation.gd
new file mode 100644
index 0000000..0bd3eb2
--- /dev/null
+++ b/tests/fur_pattern_validation.gd
@@ -0,0 +1,192 @@
+extends SceneTree
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var legacy_snapshot := CharacterCustomizationCatalog.default_snapshot()
+ for added_id: String in [
+ "fur_style",
+ "fur_color_2",
+ "fur_color_3",
+ "fur_color_4",
+ ]:
+ legacy_snapshot.erase(added_id)
+ var migrated := CharacterCustomizationCatalog.sanitized_snapshot(
+ legacy_snapshot
+ )
+ assert(CharacterCustomizationCatalog.validate_snapshot(migrated))
+ assert(migrated["fur_style"] == "solid")
+ var fur_colors := CharacterCustomizationCatalog.options_for("fur_pattern")
+ assert(fur_colors.size() == 30)
+ for natural_color_id: String in [
+ "ivory",
+ "sand",
+ "tan",
+ "taupe",
+ "black",
+ "dark_brown",
+ "chocolate",
+ "chestnut",
+ "ginger",
+ "sage",
+ "forest",
+ ]:
+ assert(CharacterCustomizationCatalog.is_valid_option(
+ "fur_pattern",
+ natural_color_id,
+ ))
+
+ var pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture(
+ "spots_bengal"
+ )
+ assert(pattern_texture != null)
+ assert(pattern_texture.get_width() == 1024)
+ assert(pattern_texture.get_height() == 1024)
+ var arm_pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture(
+ "spots_bengal",
+ "body_arms",
+ )
+ assert(arm_pattern_texture != null)
+ assert(arm_pattern_texture.get_width() == 1024)
+ assert(arm_pattern_texture.get_height() == 1024)
+ var fox_pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture(
+ "fox",
+ "body_main",
+ )
+ assert(fox_pattern_texture != null)
+ assert(fox_pattern_texture.get_width() == 1024)
+ assert(fox_pattern_texture.get_height() == 1024)
+ var fox_arm_pattern_texture := (
+ CharacterCustomizationCatalog.fur_pattern_texture(
+ "fox", "body_arms"
+ )
+ )
+ assert(fox_arm_pattern_texture != null)
+ assert(fox_arm_pattern_texture.get_width() == 1024)
+ assert(fox_arm_pattern_texture.get_height() == 1024)
+ var bengal_head_texture := (
+ CharacterCustomizationCatalog.fur_pattern_texture(
+ "spots_bengal", "head_round"
+ )
+ )
+ assert(bengal_head_texture != null)
+ assert(bengal_head_texture.get_width() == 1024)
+ assert(bengal_head_texture.get_height() == 1024)
+ var fox_head_texture := CharacterCustomizationCatalog.fur_pattern_texture(
+ "fox", "head_pointy"
+ )
+ assert(fox_head_texture != null)
+ assert(fox_head_texture.get_width() == 1024)
+ assert(fox_head_texture.get_height() == 1024)
+ var fox_round_head_texture := (
+ CharacterCustomizationCatalog.fur_pattern_texture(
+ "fox", "head_round"
+ )
+ )
+ assert(fox_round_head_texture != null)
+ assert(fox_round_head_texture.get_width() == 1024)
+ assert(fox_round_head_texture.get_height() == 1024)
+ var fox_tail_texture := CharacterCustomizationCatalog.fur_pattern_texture(
+ "fox", "tails_fox"
+ )
+ assert(fox_tail_texture != null)
+ assert(fox_tail_texture.get_width() == 1024)
+ assert(fox_tail_texture.get_height() == 1024)
+
+ var patterned := migrated.duplicate(true)
+ patterned["fur_pattern"] = "orange"
+ patterned["fur_style"] = "spots_bengal"
+ patterned["fur_color_2"] = "cream"
+ patterned["fur_color_3"] = "brown"
+ patterned["fur_color_4"] = "red"
+ assert(CharacterCustomizationCatalog.validate_snapshot(patterned))
+
+ var visuals := PlayerVisualPresenter.instantiate_visuals()
+ root.add_child(visuals)
+ PlayerVisualPresenter.apply_appearance(visuals, patterned)
+ var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
+ assert(skeleton != null)
+ var body := skeleton.get_node_or_null("body_main") as MeshInstance3D
+ assert(body != null)
+ var body_arrays := body.mesh.surface_get_arrays(0)
+ var body_uvs := body_arrays[Mesh.ARRAY_TEX_UV] as PackedVector2Array
+ assert(not body_uvs.is_empty())
+ var uv_min := Vector2(INF, INF)
+ var uv_max := Vector2(-INF, -INF)
+ for uv: Vector2 in body_uvs:
+ uv_min = uv_min.min(uv)
+ uv_max = uv_max.max(uv)
+ assert(uv_min.x < 0.05 and uv_min.y < 0.05)
+ assert(uv_max.x > 0.95 and uv_max.y > 0.95)
+ var material := body.material_override as ShaderMaterial
+ assert(material != null)
+ assert(material.shader == preload("res://player/fur_pattern.gdshader"))
+ assert(material.get_shader_parameter("pattern_texture") == pattern_texture)
+ assert(material.get_shader_parameter("base_color") == Color("c86c36"))
+ assert(material.get_shader_parameter("red_zone_color") == Color("e6d39b"))
+ assert(material.get_shader_parameter("green_zone_color") == Color("7b4a32"))
+ assert(material.get_shader_parameter("blue_zone_color") == Color("a9433f"))
+ var arms := skeleton.get_node_or_null("body_arms") as MeshInstance3D
+ assert(arms != null)
+ var arm_material := arms.material_override as ShaderMaterial
+ assert(arm_material != null)
+ assert(
+ arm_material.get_shader_parameter("pattern_texture")
+ == arm_pattern_texture
+ )
+ var round_head := skeleton.get_node_or_null("head_round") as MeshInstance3D
+ assert(round_head != null)
+ var round_head_material := round_head.material_override as ShaderMaterial
+ assert(round_head_material != null)
+ assert(
+ round_head_material.get_shader_parameter("pattern_texture")
+ == bengal_head_texture
+ )
+
+ var fox_snapshot := patterned.duplicate(true)
+ fox_snapshot["species"] = "pointy"
+ fox_snapshot["fur_style"] = "fox"
+ fox_snapshot["tail"] = "fox"
+ PlayerVisualPresenter.apply_appearance(visuals, fox_snapshot)
+ var pointy_head := (
+ skeleton.get_node_or_null("head_pointy") as MeshInstance3D
+ )
+ assert(pointy_head != null)
+ var pointy_head_material := pointy_head.material_override as ShaderMaterial
+ assert(pointy_head_material != null)
+ assert(
+ pointy_head_material.get_shader_parameter("pattern_texture")
+ == fox_head_texture
+ )
+ fox_snapshot["species"] = "round"
+ PlayerVisualPresenter.apply_appearance(visuals, fox_snapshot)
+ assert(
+ round_head_material.get_shader_parameter("pattern_texture")
+ == fox_round_head_texture
+ )
+ var fox_tail := skeleton.get_node_or_null("tails_fox") as MeshInstance3D
+ assert(fox_tail != null)
+ var fox_tail_material := fox_tail.material_override as ShaderMaterial
+ assert(fox_tail_material != null)
+ assert(
+ fox_tail_material.get_shader_parameter("pattern_texture")
+ == fox_tail_texture
+ )
+
+ var recolored := patterned.duplicate(true)
+ recolored["fur_color_3"] = "teal"
+ assert(
+ NetworkProfileProtocol.signature_fields({"appearance": patterned})
+ != NetworkProfileProtocol.signature_fields({"appearance": recolored})
+ )
+
+ PlayerVisualPresenter.apply_appearance(visuals, migrated)
+ assert(body.material_override is StandardMaterial3D)
+
+ print("Fur pattern validation: PASS")
+ visuals.queue_free()
+ await process_frame
+ quit()
diff --git a/tests/fur_pattern_validation.gd.uid b/tests/fur_pattern_validation.gd.uid
new file mode 100644
index 0000000..7333f9d
--- /dev/null
+++ b/tests/fur_pattern_validation.gd.uid
@@ -0,0 +1 @@
+uid://bwdmyn34biahx
diff --git a/tests/inventory_notepad_art_validation.gd b/tests/inventory_notepad_art_validation.gd
new file mode 100644
index 0000000..de76221
--- /dev/null
+++ b/tests/inventory_notepad_art_validation.gd
@@ -0,0 +1,309 @@
+extends SceneTree
+
+const PlayerMenuScene = preload("res://ui/player_menu.tscn")
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+const EXPECTED_HOST_SIZE := Vector2(278.0, 484.0)
+const EXPECTED_ART_SIZE := Vector2(306.28125, 580.8)
+const EXPECTED_ART_POSITION := Vector2(-4.140625, -28.4)
+const INVENTORY_PANEL_RECT := Rect2(54.0, 166.0, 882.0, 484.0)
+const NOTEPAD_HOST_RECT := Rect2(952.0, 166.0, 278.0, 484.0)
+const NOTEPAD_ART_RECT := Rect2(
+ NOTEPAD_HOST_RECT.position + EXPECTED_ART_POSITION,
+ EXPECTED_ART_SIZE,
+)
+const HOTBAR_BUBBLE_FIELD_RECT := Rect2(245.0, 608.0, 790.0, 104.0)
+const TARGET_SIZES: Array[Vector2] = [
+ Vector2(1280.0, 720.0),
+ Vector2(1920.0, 1080.0),
+ Vector2(2560.0, 1440.0),
+ Vector2(3840.0, 2160.0),
+ Vector2(640.0, 480.0),
+ Vector2(1024.0, 768.0),
+ Vector2(1729.0, 973.0),
+ Vector2(2560.0, 1080.0),
+ Vector2(3440.0, 1440.0),
+]
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _print_runtime_parameters()
+ var presentation_stage := Control.new()
+ presentation_stage.name = "CanonicalUIStage"
+ _apply_stage_layout(presentation_stage, Vector2(root.size))
+ root.add_child(presentation_stage)
+ var player_menu := PlayerMenuScene.instantiate() as PlayerMenu
+ presentation_stage.add_child(player_menu)
+ await process_frame
+ var notepads: Array[Node] = player_menu.find_children(
+ "*", "InventoryNotepad", true, false
+ )
+ assert(notepads.size() == 3)
+ for node: Node in notepads:
+ var notepad := node as InventoryNotepad
+ assert(notepad != null)
+ assert(notepad.size.is_equal_approx(EXPECTED_HOST_SIZE))
+ assert(notepad.mouse_filter == Control.MOUSE_FILTER_PASS)
+ var art_rect: Rect2 = notepad.get_art_rect()
+ assert(art_rect.size.is_equal_approx(EXPECTED_ART_SIZE))
+ assert(art_rect.position.is_equal_approx(EXPECTED_ART_POSITION))
+ assert(art_rect.get_center().is_equal_approx(
+ EXPECTED_HOST_SIZE * 0.5
+ + InventoryNotepad.NOTEPAD_ART_OFFSET
+ ))
+ _validate_shared_inventory_geometry(player_menu)
+ _validate_inventory_layering(player_menu)
+ _validate_cooler_notepad_typography(player_menu)
+ _validate_resolution_matrix()
+ await _capture_inventory_pages(player_menu)
+ print("Inventory notepad artwork validation: PASS")
+ presentation_stage.queue_free()
+ for _frame: int in 10:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _print_runtime_parameters() -> void:
+ var window: Window = root
+ var screen_index: int = window.current_screen
+ print(
+ (
+ "Runtime UI: window=%s screen=%s screen_scale=%.3f dpi=%d "
+ + "content_size=%s content_mode=%d content_aspect=%d "
+ + "content_factor=%.3f"
+ )
+ % [
+ window.size,
+ DisplayServer.screen_get_size(screen_index),
+ DisplayServer.screen_get_scale(screen_index),
+ DisplayServer.screen_get_dpi(screen_index),
+ window.content_scale_size,
+ window.content_scale_mode,
+ window.content_scale_aspect,
+ window.content_scale_factor,
+ ]
+ )
+
+
+func _apply_stage_layout(stage: Control, display_size: Vector2) -> void:
+ stage.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ stage.position = UIReferencePresentationType.get_offset(display_size)
+ stage.size = UIReferencePresentationType.REFERENCE_SIZE
+ stage.scale = Vector2.ONE * UIReferencePresentationType.get_scale(
+ display_size
+ )
+
+
+func _validate_shared_inventory_geometry(player_menu: PlayerMenu) -> void:
+ for node_name: StringName in [
+ &"DetailConstellation",
+ &"BagDetailConstellation",
+ &"TackleDetailPanel",
+ ]:
+ var host := player_menu.get_node("%%%s" % node_name) as Control
+ assert(host != null)
+ assert(host.position.is_equal_approx(Vector2(952.0, 166.0)))
+ assert(host.size.is_equal_approx(EXPECTED_HOST_SIZE))
+
+
+func _validate_inventory_layering(player_menu: PlayerMenu) -> void:
+ var bag_filters := player_menu.get_node("%BagFilterTabs") as Control
+ var bag_panel := player_menu.get_node("%BagOuterWall") as Control
+ var sale_confirmation := player_menu.get_node("%SaleConfirmation") as Control
+ var cooler_panel := player_menu.get_node("%CoolerOuterWall") as Control
+ var tackle_panel := player_menu.get_node("%TackleMainPanel") as Control
+ assert(bag_filters != null)
+ assert(bag_panel != null)
+ assert(sale_confirmation != null)
+ assert(cooler_panel != null)
+ assert(tackle_panel != null)
+ assert(bag_filters.z_index > bag_panel.z_index)
+ assert(sale_confirmation.z_index > cooler_panel.z_index)
+ assert(sale_confirmation.z_index > tackle_panel.z_index)
+ # The contextual Hotbar uses z=90 while the Player Menu is open.
+ assert(sale_confirmation.z_index > 90)
+ assert(sale_confirmation.position.is_equal_approx(Vector2(380.0, 265.0)))
+ assert(sale_confirmation.size.is_equal_approx(Vector2(520.0, 200.0)))
+ var confirmation_style := sale_confirmation.get_theme_stylebox(
+ "panel"
+ ) as StyleBoxFlat
+ assert(confirmation_style != null)
+ assert(
+ confirmation_style.bg_color.is_equal_approx(
+ UtilityPageStyle.OCEAN_PANEL_DEEP
+ )
+ )
+
+
+func _validate_cooler_notepad_typography(player_menu: PlayerMenu) -> void:
+ var sort_choice := player_menu.get_node("%CoolerSortOption") as Control
+ var sort_direction := player_menu.get_node(
+ "%CoolerSortDirection"
+ ) as Button
+ var empty_selection := player_menu.get_node(
+ "%CoolerSelectionEmpty"
+ ) as Label
+ var favorite := player_menu.get_node("%FavoriteBubble") as Button
+ var sell := player_menu.get_node("%SellBubble") as Button
+ var sell_all := player_menu.get_node("%SellAllBubble") as Button
+ assert(sort_choice != null)
+ assert(sort_direction != null)
+ assert(empty_selection != null)
+ assert(favorite != null and sell != null and sell_all != null)
+ var displayed_value := sort_choice.get_node("%DisplayedValue") as Label
+ assert(displayed_value != null)
+ assert(displayed_value.get_theme_font_size("font_size") == 17)
+ for choice_name: StringName in [
+ &"CatchOrderChoice",
+ &"NameChoice",
+ &"RarityChoice",
+ ]:
+ var choice := sort_choice.get_node("%%%s" % choice_name) as Button
+ assert(choice != null)
+ assert(choice.get_theme_font_size("font_size") == 17)
+ assert(sort_direction.get_theme_font_size("font_size") == 17)
+ assert(empty_selection.get_theme_font_size("font_size") == 20)
+ assert(favorite.get_theme_font_size("font_size") == 19)
+ assert(sell.get_theme_font_size("font_size") == 19)
+ assert(sell_all.get_theme_font_size("font_size") == 17)
+
+
+func _validate_resolution_matrix() -> void:
+ for target_size: Vector2 in TARGET_SIZES:
+ var stage_rect: Rect2 = UIReferencePresentationType.get_rect(
+ target_size
+ )
+ var stage_scale: float = UIReferencePresentationType.get_scale(
+ target_size
+ )
+ assert(is_equal_approx(
+ stage_rect.size.x / stage_rect.size.y,
+ 1280.0 / 720.0,
+ ))
+ assert(stage_rect.get_center().is_equal_approx(target_size * 0.5))
+ _validate_transformed_rect(
+ "Inventory panel", INVENTORY_PANEL_RECT, stage_rect, stage_scale
+ )
+ _validate_transformed_rect(
+ "Notepad host", NOTEPAD_HOST_RECT, stage_rect, stage_scale
+ )
+ _validate_transformed_rect(
+ "Notepad art", NOTEPAD_ART_RECT, stage_rect, stage_scale
+ )
+ _validate_transformed_rect(
+ "Hotbar", HOTBAR_BUBBLE_FIELD_RECT, stage_rect, stage_scale
+ )
+ var screen_host: Rect2 = _to_screen_rect(
+ NOTEPAD_HOST_RECT, stage_rect.position, stage_scale
+ )
+ var screen_art: Rect2 = _to_screen_rect(
+ NOTEPAD_ART_RECT, stage_rect.position, stage_scale
+ )
+ assert(is_equal_approx(
+ screen_art.size.y,
+ screen_host.size.y * InventoryNotepad.NOTEPAD_CANONICAL_OVERSCAN,
+ ))
+ assert(screen_art.get_center().is_equal_approx(
+ screen_host.get_center()
+ + InventoryNotepad.NOTEPAD_ART_OFFSET * stage_scale
+ ))
+ print(
+ (
+ "UI geometry: window=%s stage=%s scale=%.6f "
+ + "inventory=%s notepad=%s art=%s hotbar=%s"
+ )
+ % [
+ target_size,
+ stage_rect,
+ stage_scale,
+ _to_screen_rect(
+ INVENTORY_PANEL_RECT,
+ stage_rect.position,
+ stage_scale,
+ ),
+ screen_host,
+ screen_art,
+ _to_screen_rect(
+ HOTBAR_BUBBLE_FIELD_RECT,
+ stage_rect.position,
+ stage_scale,
+ ),
+ ]
+ )
+
+
+func _validate_transformed_rect(
+ label: String,
+ canonical_rect: Rect2,
+ stage_rect: Rect2,
+ stage_scale: float,
+) -> void:
+ var screen_rect: Rect2 = _to_screen_rect(
+ canonical_rect, stage_rect.position, stage_scale
+ )
+ assert(
+ screen_rect.position.is_equal_approx(
+ stage_rect.position + canonical_rect.position * stage_scale
+ ),
+ "%s position does not use the single stage transform" % label,
+ )
+ assert(
+ screen_rect.size.is_equal_approx(canonical_rect.size * stage_scale),
+ "%s size does not use the single stage transform" % label,
+ )
+
+
+func _to_screen_rect(
+ canonical_rect: Rect2,
+ stage_offset: Vector2,
+ stage_scale: float,
+) -> Rect2:
+ return Rect2(
+ stage_offset + canonical_rect.position * stage_scale,
+ canonical_rect.size * stage_scale,
+ )
+
+
+func _capture_inventory_pages(player_menu: PlayerMenu) -> void:
+ if not OS.has_environment("NETFISHING_NOTEPAD_CAPTURE"):
+ return
+ player_menu.visible = true
+ var sections: Array[PlayerMenu.Section] = [
+ PlayerMenu.Section.COOLER,
+ PlayerMenu.Section.TACKLE_BOX,
+ PlayerMenu.Section.BAG,
+ ]
+ var suffixes: Array[String] = ["cooler", "tackle", "equipment"]
+ for index: int in sections.size():
+ player_menu.call("_show_section_immediate", sections[index])
+ await process_frame
+ await process_frame
+ await _save_capture(suffixes[index])
+ player_menu.call("_show_section_immediate", PlayerMenu.Section.COOLER)
+ var sale_confirmation := player_menu.get_node("%SaleConfirmation") as Control
+ var confirmation_message := player_menu.get_node(
+ "%ConfirmationMessage"
+ ) as Label
+ assert(sale_confirmation != null)
+ assert(confirmation_message != null)
+ confirmation_message.text = "sell selected fish?"
+ sale_confirmation.visible = true
+ await process_frame
+ await process_frame
+ await _save_capture("sale-confirmation")
+ sale_confirmation.visible = false
+
+
+func _save_capture(suffix: String) -> void:
+ var image: Image = root.get_texture().get_image()
+ var path: String = "%s-%s.png" % [
+ OS.get_environment("NETFISHING_NOTEPAD_CAPTURE"),
+ suffix,
+ ]
+ assert(image.save_png(path) == OK)
diff --git a/tests/inventory_notepad_art_validation.gd.uid b/tests/inventory_notepad_art_validation.gd.uid
new file mode 100644
index 0000000..59a9b8b
--- /dev/null
+++ b/tests/inventory_notepad_art_validation.gd.uid
@@ -0,0 +1 @@
+uid://np6jfuf8tej8
diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd
new file mode 100644
index 0000000..c31d392
--- /dev/null
+++ b/tests/job_multiplayer_validation.gd
@@ -0,0 +1,128 @@
+extends SceneTree
+
+const MainScene: PackedScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 18139
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("Job multiplayer validation needs host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var jobs := main.get_node("%PlayerJobService") as PlayerJobService
+ assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
+ jobs.begin_progression_session()
+ await process_frame
+ assert(session.set_host_open(true))
+ assert(session.is_dedicated_host())
+ var host_board: Dictionary = jobs.get_host_board_network_data()
+ assert(PlayerJobService.validate_board(host_board))
+
+ var remote_peer_id: int = 0
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0:
+ await process_frame
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ remote_peer_id = peer_id
+ break
+ assert(remote_peer_id > 1)
+ assert(session.peer_supports_capability(
+ remote_peer_id, NetworkProtocol.JOBS_CAPABILITY
+ ))
+
+ var disconnect_deadline: int = Time.get_ticks_msec() + 12000
+ while (
+ Time.get_ticks_msec() < disconnect_deadline
+ and session.is_authenticated_peer(remote_peer_id)
+ ):
+ await process_frame
+ assert(not session.is_authenticated_peer(remote_peer_id))
+ print("Job multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call(
+ "_on_title_join_game_requested",
+ "127.0.0.1:%d" % TEST_PORT,
+ )
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var jobs := main.get_node("%PlayerJobService") as PlayerJobService
+ var weather := main.get_node("%WorldWeatherService") as WorldWeatherService
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if (
+ session.is_joined_client()
+ and bool(main.get("_gameplay_started"))
+ and not jobs.get_plan_id().is_empty()
+ ):
+ break
+ assert(session.is_joined_client())
+ assert(session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY))
+ assert(not jobs.get_plan_id().is_empty())
+ assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
+ assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
+ var preserved_plan_id: String = jobs.get_plan_id()
+ jobs.clear_remote_board()
+ assert(not jobs.has_active_board())
+ assert(jobs.get_plan_id() == preserved_plan_id)
+ var retry_deadline: int = Time.get_ticks_msec() + 5000
+ while (
+ Time.get_ticks_msec() < retry_deadline
+ and not jobs.has_active_board()
+ ):
+ await process_frame
+ assert(jobs.has_active_board())
+ assert(jobs.get_plan_id() == preserved_plan_id)
+ assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
+ assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
+ assert(weather.get_daily_plan_id().is_empty())
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var player_menu := game_ui.get("_player_menu") as PlayerMenu
+ player_menu.call("_show_section_immediate", PlayerMenu.Section.NET)
+ assert((player_menu.get_node("%TheNetPage") as TheNetPage).visible)
+ print("Job multiplayer client validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
diff --git a/tests/job_multiplayer_validation.gd.uid b/tests/job_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..1b526ba
--- /dev/null
+++ b/tests/job_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://c4y0pmxk2bhmu
diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd
new file mode 100644
index 0000000..32fe10e
--- /dev/null
+++ b/tests/job_system_validation.gd
@@ -0,0 +1,329 @@
+extends SceneTree
+
+const MainScene: PackedScene = preload("res://main/main.tscn")
+const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session != null)
+ var occupied_port := PacketPeerUDP.new()
+ assert(occupied_port.bind(18137, "127.0.0.1") == OK)
+ assert(session.start_private_host(18137, 3))
+ assert(session.get_host_port() == 18138)
+ occupied_port.close()
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ await physics_frame
+ await physics_frame
+
+ var jobs := main.get_node("%PlayerJobService") as PlayerJobService
+ var weather := main.get_node("%WorldWeatherService") as WorldWeatherService
+ var network_fishing := main.get(
+ "_network_fishing"
+ ) as NetworkFishingService
+ var network_sale := main.get("_network_sale") as NetworkSaleService
+ var player := main.get("_player") as Player
+ assert(jobs != null and weather != null and player != null)
+ assert(network_fishing != null and network_sale != null)
+
+ var board: Dictionary = jobs.get_host_board_network_data()
+ assert(PlayerJobService.validate_board(board))
+ assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
+ assert(weather.get_daily_plan_id() == jobs.get_plan_id())
+ _validate_weather_guarantees(board)
+ _validate_late_board_weather_fallback()
+ _validate_remote_tamper_rejection(jobs, board)
+
+ var sell_job: Dictionary = _find_job(
+ jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
+ )
+ assert(not sell_job.is_empty())
+ var sell_target: int = int(sell_job.get("target", 0))
+ var sold_ids: Array[StringName] = []
+ for index: int in sell_target * 2:
+ sold_ids.append(StringName("job-sale-%d" % index))
+ network_sale.local_sale_finished.emit(
+ "job-test", true, "sold", sold_ids, 1
+ )
+ await process_frame
+ var pending: Array[Dictionary] = jobs.get_pending_rewards()
+ assert(pending.size() == 1)
+ var refreshed_sell: Dictionary = _find_job(
+ jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
+ )
+ assert(int(refreshed_sell.get("progress", -1)) == sell_target)
+ assert(int(refreshed_sell.get("completed_count", 0)) == 1)
+
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var player_menu := game_ui.get("_player_menu") as PlayerMenu
+ assert(player_menu != null)
+ player_menu.open_menu()
+ for _frame: int in 24:
+ await process_frame
+ assert(player_menu.visible)
+ player_menu.call("_show_section_immediate", PlayerMenu.Section.NET)
+ var net_page := player_menu.get_node("%TheNetPage") as TheNetPage
+ assert(net_page.visible)
+ assert((player_menu.get_node("%TheNetTab") as Button).button_pressed)
+ assert(
+ (player_menu.get_node("%NavigationCluster") as Control).get_child_count()
+ == 7
+ )
+ await _validate_unavailable_fishnet_layout()
+
+ var wallet_before: int = player.wallet.get_balance()
+ var experience_before: int = player.experience.get_total_experience()
+ var first_claim_id: String = str(pending[0].get("claim_id", ""))
+ net_page.call("_claim", first_claim_id)
+ await process_frame
+ await process_frame
+ assert(bool(game_ui.get("_experience_animation_active")))
+ var experience_presentation := (
+ game_ui.get_node("%ExperiencePresentation") as Control
+ )
+ assert(experience_presentation.visible)
+ assert((game_ui.get_node("%ExperienceProgressPanel") as Control).visible)
+ assert((game_ui.get_node("%ExperienceBubble") as Control).visible)
+ assert(
+ experience_presentation.get_index()
+ == experience_presentation.get_parent().get_child_count() - 1
+ )
+ assert(not (game_ui.get_node("%GameplayTransientHUD") as Control).visible)
+ assert(player_menu.visible)
+ assert(not jobs.claim(first_claim_id))
+ assert(
+ player.wallet.get_balance()
+ == wallet_before + int(pending[0].get("fish_coin", 0))
+ )
+ assert(
+ player.experience.get_total_experience()
+ == experience_before + int(pending[0].get("experience", 0))
+ )
+ refreshed_sell = _find_job(
+ jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
+ )
+ assert(int(refreshed_sell.get("progress", -1)) == sell_target)
+ assert(int(refreshed_sell.get("completed_count", 0)) == 1)
+ assert(not bool(refreshed_sell.get("claimable", true)))
+ network_sale.local_sale_finished.emit(
+ "job-test-repeat", true, "sold", sold_ids, 1
+ )
+ await process_frame
+ assert(jobs.get_pending_rewards().is_empty())
+ refreshed_sell = _find_job(
+ jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
+ )
+ assert(int(refreshed_sell.get("progress", -1)) == sell_target)
+ assert(int(refreshed_sell.get("completed_count", 0)) == 1)
+
+ var fresh_job: Dictionary = _find_job(
+ jobs.get_daily_jobs(), JobCatalog.Kind.CATCH_WATER
+ )
+ assert(not fresh_job.is_empty())
+ var bluegill: FishData = Catalog.get_fish_by_id(&"bluegill")
+ assert(bluegill != null)
+ for index: int in int(fresh_job.get("target", 0)):
+ var fish_catch := FishCatch.new()
+ fish_catch.fish = bluegill
+ fish_catch.fish_id = bluegill.id
+ fish_catch.catch_id = StringName("job-catch-%d" % index)
+ fish_catch.catch_sequence = index + 1
+ fish_catch.weight_lb = bluegill.get_minimum_weight()
+ fish_catch.display_scale = bluegill.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.quality = FishQuality.Tier.BORING
+ fish_catch.sale_value = bluegill.get_sale_value_for_weight(
+ fish_catch.weight_lb
+ )
+ assert(fish_catch.is_valid())
+ network_fishing.local_catch_received.emit(fish_catch)
+ await process_frame
+ var refreshed_fresh: Dictionary = _find_job(
+ jobs.get_daily_jobs(), JobCatalog.Kind.CATCH_WATER
+ )
+ assert(int(refreshed_fresh.get("completed_count", 0)) == 1)
+
+ var pause_menu := game_ui.get_pause_menu()
+ var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage
+ assert(join_page != null)
+ pause_menu.show()
+ join_page.set_status("Connection timed out.")
+ join_page.show()
+ join_page.call(
+ "_on_discovery_status_changed", "2 public rooms found", false
+ )
+ var join_status := join_page.get_node("%Status") as Label
+ assert(join_status.text == "Could not reach the server.")
+ join_page.hide()
+ join_page.call("_set_mode", JoinGamePage.Mode.DIRECT)
+ join_page.call("_set_mode", JoinGamePage.Mode.DISCOVER)
+ join_page.show()
+ join_page.call(
+ "_on_discovery_status_changed", "2 public rooms found", false
+ )
+ assert(join_status.text == "2 public rooms found")
+ join_page.close_page()
+ pause_menu.hide()
+ join_page.call("_refresh")
+ var session_summary := (
+ join_page.get_node("%SessionSummary") as Label
+ )
+ assert(not join_page.has_node(
+ "Paper/Margin/Layout/Actions/OpenCloseButton"
+ ))
+ assert("UDP 18138" in session_summary.text)
+ assert(session.set_host_open(true))
+ await process_frame
+ assert("UDP 18138" in session_summary.text)
+ assert(session.set_host_open(false))
+
+ assert(save_manager.save_now())
+ var save_file := FileAccess.open(
+ str(save_manager.get("_save_path")), FileAccess.READ
+ )
+ assert(save_file != null)
+ var parsed: Variant = JSON.parse_string(save_file.get_as_text())
+ save_file.close()
+ assert(typeof(parsed) == TYPE_DICTIONARY)
+ var save_data: Dictionary = parsed
+ assert(int(save_data.get("save_version", -1)) == 7)
+ assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
+
+ _validate_pause_session_switch(main, session)
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ print("Job system validation: PASS")
+ quit()
+
+
+func _validate_pause_session_switch(
+ main: Node,
+ session: NetworkSession,
+) -> void:
+ var discovery := main.get_node("%DiscoveryClient") as DiscoveryClient
+ discovery.set("_pending_join_endpoint", "127.0.0.1:18141")
+ discovery.set("_pending_join_room_id", "session-switch-room")
+ discovery.set("_pending_join_token", "session-switch-token")
+ discovery.call(
+ "_set_public_join_state",
+ DiscoveryClient.PublicJoinState.CONNECTING,
+ )
+ main.call("_on_pause_join_game_requested", "127.0.0.1:18141")
+ assert(session.state == NetworkSession.State.CONNECTING)
+ assert(discovery.get("_pending_join_token") == "session-switch-token")
+ var join_probe_timer := discovery.get("_join_probe_timer") as Timer
+ assert(join_probe_timer != null and not join_probe_timer.is_stopped())
+ session.cancel_connection()
+ assert(session.state == NetworkSession.State.INACTIVE)
+ assert(str(discovery.get("_pending_join_token")).is_empty())
+
+
+func _validate_unavailable_fishnet_layout() -> void:
+ var unavailable_page := TheNetPage.new()
+ unavailable_page.size = Vector2(1280.0, 720.0)
+ root.add_child(unavailable_page)
+ for _frame: int in 3:
+ await process_frame
+ unavailable_page.call("_refresh_forecast", true)
+ await process_frame
+ var forecast_list := unavailable_page.get("_forecast_list") as HBoxContainer
+ assert(forecast_list != null)
+ assert(forecast_list.size.x >= 272.0)
+ assert(forecast_list.size.y <= 66.0)
+ assert(forecast_list.get_child_count() == 1)
+ var placeholder := forecast_list.get_child(0) as Label
+ assert(placeholder != null)
+ assert(placeholder.size.x >= 252.0)
+ assert(placeholder.size.y <= 66.0)
+ assert(placeholder.autowrap_mode == TextServer.AUTOWRAP_OFF)
+ var tabs := unavailable_page.get("_tabs") as HBoxContainer
+ assert(tabs != null)
+ assert(tabs.position.y + tabs.size.y < UtilityPageStyle.LAPTOP_RECT.end.y)
+ unavailable_page.queue_free()
+ for _frame: int in 2:
+ await process_frame
+
+
+func _validate_weather_guarantees(board: Dictionary) -> void:
+ var jobs: Array = board.get("jobs", [])
+ var schedule: Array = board.get("weather_schedule", [])
+ var anchor_index: int = int(board.get("schedule_anchor_index", 0))
+ for required_weather: int in JobCatalog.weather_requirements(jobs):
+ var occurrences: int = 0
+ var has_later_window: bool = false
+ for index: int in schedule.size():
+ var entry: Dictionary = schedule[index]
+ if int(entry.get("weather", -1)) != required_weather:
+ continue
+ occurrences += 1
+ if index > anchor_index:
+ has_later_window = true
+ assert(occurrences >= 2)
+ assert(has_later_window)
+
+
+func _validate_late_board_weather_fallback() -> void:
+ var late_jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs(
+ "late-board-validation", [], false
+ )
+ assert(JobCatalog.weather_requirements(late_jobs).is_empty())
+ var anchored_jobs: Array[Dictionary] = JobCatalog.generate_daily_jobs(
+ "anchored-board-validation", [], true
+ )
+ var anchored_schedule: Array[Dictionary] = (
+ JobCatalog.generate_weather_schedule(
+ "anchored-board-validation", anchored_jobs, 9
+ )
+ )
+ for required_weather: int in JobCatalog.weather_requirements(anchored_jobs):
+ var occurrence_indices: Array[int] = []
+ for index: int in anchored_schedule.size():
+ var entry: Dictionary = anchored_schedule[index]
+ if (
+ index >= 9
+ and int(entry.get("weather", -1)) == required_weather
+ ):
+ occurrence_indices.append(index)
+ assert(occurrence_indices.size() >= 2)
+ assert(occurrence_indices[0] >= 9)
+ assert(occurrence_indices[-1] > 9)
+
+
+func _validate_remote_tamper_rejection(
+ jobs: PlayerJobService,
+ board: Dictionary,
+) -> void:
+ var tampered: Dictionary = board.duplicate(true)
+ var tampered_jobs: Array = tampered.get("jobs", [])
+ var first_job: Dictionary = tampered_jobs[0]
+ first_job["fish_coin"] = int(first_job.get("fish_coin", 0)) + 1
+ tampered_jobs[0] = first_job
+ tampered["jobs"] = tampered_jobs
+ assert(PlayerJobService.validate_board(tampered))
+ assert(not jobs.apply_remote_board(tampered))
+
+
+func _find_job(jobs: Array[Dictionary], kind: JobCatalog.Kind) -> Dictionary:
+ for job: Dictionary in jobs:
+ if int(job.get("kind", -1)) == int(kind):
+ return job
+ return {}
diff --git a/tests/job_system_validation.gd.uid b/tests/job_system_validation.gd.uid
new file mode 100644
index 0000000..d37784b
--- /dev/null
+++ b/tests/job_system_validation.gd.uid
@@ -0,0 +1 @@
+uid://b2grposd30lkw
diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd
new file mode 100644
index 0000000..88227aa
--- /dev/null
+++ b/tests/logbook_runtime_validation.gd
@@ -0,0 +1,181 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_gameplay_started")))
+ _validate_save_round_trip(main, save_manager)
+ var player := main.get("_player") as Player
+ var no_catches: Array[FishCatch] = []
+ var no_discoveries: Array[StringName] = []
+ assert(player.inventory.replace_all_catches(no_catches, 1))
+ assert(player.collection_log.replace_discovered_ids(no_discoveries))
+
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
+ var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage
+ var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect
+ var hotbar := game_ui.get_node("%Hotbar") as Control
+
+ var shortcut := InputEventKey.new()
+ shortcut.pressed = true
+ shortcut.physical_keycode = KEY_L
+ assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
+ await create_timer(2.2).timeout
+ assert(player_menu.visible)
+ assert(logbook.visible)
+ assert(backdrop.visible)
+ assert(not bool(game_ui.get("_player_menu_hotbar_visible")))
+ assert(not hotbar.visible)
+
+ var entry_buttons: Dictionary = logbook.get("_entry_buttons")
+ assert(entry_buttons.size() == 19)
+ await _capture_if_requested("-unknown")
+ logbook.call(
+ "_select_category", WaterType.Type.FRESH_WATER
+ )
+ await create_timer(0.25).timeout
+ assert((logbook.get("_entry_buttons") as Dictionary).size() == 19)
+ await _capture_if_requested("-fresh")
+ logbook.call("_select_category", WaterType.Type.SALT_WATER)
+ await create_timer(0.25).timeout
+ assert((logbook.get("_entry_buttons") as Dictionary).size() == 34)
+ logbook.call("_select_category", WaterType.Type.FRESH_WATER)
+ await create_timer(0.25).timeout
+ assert((logbook.get("_entry_buttons") as Dictionary).size() == 19)
+ player.collection_log.mark_discovered(&"bluegill")
+ await process_frame
+ logbook.call("_select_entry", &"bluegill", &"bluegill")
+ await create_timer(0.2).timeout
+ await _capture_if_requested("-known")
+
+ assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
+ await create_timer(2.2).timeout
+ assert(not player_menu.visible)
+
+ assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
+ await create_timer(2.2).timeout
+ player_menu.call(
+ "_show_section_immediate", PlayerMenu.Section.PROFILE
+ )
+ await process_frame
+ assert(bool(
+ player_menu.call("_handle_direct_page_shortcut", shortcut)
+ ))
+ await create_timer(0.5).timeout
+ assert(player_menu.visible)
+ assert(logbook.visible)
+ assert(
+ player_menu.get("_current_section") == PlayerMenu.Section.LOGBOOK
+ )
+ player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false)
+
+ print(
+ "Logbook runtime validation: PASS at %dx%d"
+ % [root.size.x, root.size.y]
+ )
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _validate_save_round_trip(
+ main: Node,
+ save_manager: PlayerSaveManager,
+) -> void:
+ var player := main.get("_player") as Player
+ var catalog := main.get("fish_catalog") as FishPool
+ assert(catalog != null)
+ assert(catalog.candidates.size() == 53)
+ for index: int in 4:
+ _add_test_catch(player, catalog.candidates[index])
+ assert(save_manager.save_now())
+ var no_catches: Array[FishCatch] = []
+ var no_discoveries: Array[StringName] = []
+ assert(player.inventory.replace_all_catches(no_catches, 1))
+ assert(player.collection_log.replace_discovered_ids(no_discoveries))
+ assert(save_manager.load_player_data())
+ assert(player.inventory.get_all_catches().size() == 4)
+ for index: int in 4:
+ var original_fish: FishData = catalog.candidates[index]
+ assert(player.inventory.get_count(original_fish.id) == 1)
+ assert(player.collection_log.has_discovered(original_fish.id))
+
+ for index: int in range(4, catalog.candidates.size()):
+ _add_test_catch(player, catalog.candidates[index])
+ assert(save_manager.save_now())
+ assert(player.inventory.replace_all_catches(no_catches, 1))
+ assert(player.collection_log.replace_discovered_ids(no_discoveries))
+ assert(save_manager.load_player_data())
+ assert(player.inventory.get_all_catches().size() == 53)
+ for fish: FishData in catalog.candidates:
+ assert(player.inventory.get_count(fish.id) == 1)
+ assert(player.collection_log.has_discovered(fish.id))
+ for fish_id: StringName in [
+ &"catfish_blue",
+ &"catfish_channel",
+ &"catfish_flathead",
+ &"catfish_white",
+ ]:
+ var fish_catch: FishCatch = (
+ player.inventory.get_catches_by_fish_id(fish_id).front()
+ )
+ player.begin_catch_showcase(fish_catch)
+ var catch_sprite := player.find_child(
+ "CatchSprite", true, false
+ ) as Sprite3D
+ assert(catch_sprite != null)
+ assert(catch_sprite.texture == fish_catch.fish.display_texture)
+ player.end_catch_showcase(Callable(), true)
+
+
+func _add_test_catch(player: Player, fish: FishData) -> void:
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.weight_lb = fish.get_minimum_weight()
+ fish_catch.display_scale = fish.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.sale_value = fish.get_sale_value_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.ensure_identity()
+ player.inventory.add_catch(fish_catch)
+ player.collection_log.mark_discovered(fish.id)
+
+
+func _capture_if_requested(suffix: String) -> void:
+ if not OS.has_environment("NETFISHING_LOGBOOK_CAPTURE"):
+ return
+ await process_frame
+ await process_frame
+ var image: Image = root.get_texture().get_image()
+ var path: String = (
+ OS.get_environment("NETFISHING_LOGBOOK_CAPTURE")
+ + suffix
+ + ".png"
+ )
+ assert(image.save_png(path) == OK)
diff --git a/tests/logbook_runtime_validation.gd.uid b/tests/logbook_runtime_validation.gd.uid
new file mode 100644
index 0000000..1d23e1d
--- /dev/null
+++ b/tests/logbook_runtime_validation.gd.uid
@@ -0,0 +1 @@
+uid://ciii3u308pk4n
diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd
new file mode 100644
index 0000000..866a34f
--- /dev/null
+++ b/tests/logbook_validation.gd
@@ -0,0 +1,443 @@
+extends SceneTree
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const CollectionLogType = preload("res://collection/collection_log.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const LogbookPortraitType = preload(
+ "res://ui/components/logbook_portrait.gd"
+)
+const InventoryNotepadType = preload(
+ "res://ui/components/inventory_notepad.gd"
+)
+const LogbookPageScene = preload("res://ui/logbook_page.tscn")
+const CatalogResource: FishPoolType = preload(
+ "res://fish/pools/fish_catalog.tres"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ if OS.has_environment("NETFISHING_TEST_CREATE_ROOT"):
+ var data_root := PlayerDataRoot.new()
+ root.add_child(data_root)
+ var result: Dictionary = data_root.create_unbound_root(
+ OS.get_environment("NETFISHING_TEST_CREATE_ROOT")
+ )
+ assert(bool(result.get("ok", false)))
+ data_root.queue_free()
+ print("Logbook portable test root: PASS")
+ quit()
+ return
+ _validate_catalog()
+ await _validate_page()
+ print("Logbook validation: PASS")
+ quit()
+
+
+func _validate_catalog() -> void:
+ var expected_ids: Array[StringName] = [
+ &"bluegill",
+ &"bass",
+ &"carp",
+ &"sunfish",
+ &"catfish_blue",
+ &"catfish_channel",
+ &"catfish_flathead",
+ &"catfish_white",
+ &"tuna_albacore",
+ &"tuna_bigeye",
+ &"tuna_bluefin",
+ &"tuna_skipjack",
+ &"tuna_yellowfin",
+ &"goby_round",
+ &"salmon_atlantic",
+ &"salmon_chum",
+ &"salmon_coho",
+ &"salmon_pink",
+ &"salmon_sockeye",
+ &"anchovy_european",
+ &"anchovy_northern",
+ &"chub_european",
+ &"chub_flame",
+ &"chub_lake",
+ &"goldfish",
+ &"goldfish_bubbleeye",
+ &"grouper_gulf",
+ &"grouper_red",
+ &"mackerel_atlantic",
+ &"mackerel_cero",
+ &"mackerel_chub",
+ &"mackerel_king",
+ &"mackerel_spanish",
+ &"marlin_black",
+ &"marlin_blue",
+ &"marlin_white",
+ &"pomfret_black",
+ &"pomfret_chinese",
+ &"pomfret_golden",
+ &"pomfret_white",
+ &"sailfish",
+ &"sauger",
+ &"saugeye",
+ &"snapper_lane",
+ &"snapper_mangrove",
+ &"snapper_mutton",
+ &"snapper_red",
+ &"swordfish",
+ &"trout_cutthroat",
+ &"trout_golden",
+ &"trout_rainbow",
+ &"trout_steelhead",
+ &"walleye",
+ ]
+ assert(LogbookCatalog.CATALOG_ORDER == expected_ids)
+ for index: int in expected_ids.size():
+ var fish := CatalogResource.get_fish_by_id(expected_ids[index])
+ assert(fish != null)
+ assert(not LogbookCatalog.facts_for(fish.id).is_empty())
+ assert(
+ LogbookCatalog.facts_for(fish.id)
+ == LogbookCatalog.facts_for(fish.id).to_lower()
+ )
+ if LogbookCatalog.FISH_FACTS.has(fish.id):
+ assert(LogbookCatalog.facts_for(fish.id) != "unknown")
+ else:
+ assert(LogbookCatalog.facts_for(fish.id) == "unknown")
+ var expected_category: WaterType.Type = (
+ WaterType.Type.SALT_WATER
+ if expected_ids[index] in [
+ &"bass",
+ &"sunfish",
+ &"tuna_albacore",
+ &"tuna_bigeye",
+ &"tuna_bluefin",
+ &"tuna_skipjack",
+ &"tuna_yellowfin",
+ &"salmon_atlantic",
+ &"salmon_chum",
+ &"salmon_coho",
+ &"salmon_pink",
+ &"salmon_sockeye",
+ &"anchovy_european",
+ &"anchovy_northern",
+ &"grouper_gulf",
+ &"grouper_red",
+ &"mackerel_atlantic",
+ &"mackerel_cero",
+ &"mackerel_chub",
+ &"mackerel_king",
+ &"mackerel_spanish",
+ &"marlin_black",
+ &"marlin_blue",
+ &"marlin_white",
+ &"pomfret_black",
+ &"pomfret_chinese",
+ &"pomfret_golden",
+ &"pomfret_white",
+ &"sailfish",
+ &"snapper_lane",
+ &"snapper_mangrove",
+ &"snapper_mutton",
+ &"snapper_red",
+ &"swordfish",
+ ]
+ else WaterType.Type.FRESH_WATER
+ )
+ assert(
+ LogbookCatalog.category_for(fish)
+ == expected_category
+ )
+ assert(LogbookCatalog.catalog_number(fish.id) == index + 1)
+ assert(
+ LogbookCatalog.empty_state(WaterType.Type.SALT_WATER)
+ == "No saltwater catches cataloged yet."
+ )
+
+
+func _validate_page() -> void:
+ var collection := CollectionLogType.new()
+ var inventory := FishInventoryType.new()
+ root.add_child(collection)
+ root.add_child(inventory)
+ var page := LogbookPageScene.instantiate() as LogbookPage
+ root.add_child(page)
+ await process_frame
+ page.setup(collection, inventory, CatalogResource)
+ page.activate()
+ await process_frame
+ assert(page.get("_category") == WaterType.Type.FRESH_WATER)
+ assert((page.get("_catalog_grid") as GridContainer).columns == 4)
+ var initial_detail_body := page.get("_detail_body") as VBoxContainer
+ assert(not initial_detail_body.get_parent() is ScrollContainer)
+ assert(
+ page.call("_entry_label_text", "flathead catfish")
+ == "flathead\ncatfish"
+ )
+
+ var shared_material: Material
+ var silhouette_count: int = 0
+ for category: WaterType.Type in [
+ WaterType.Type.FRESH_WATER,
+ WaterType.Type.SALT_WATER,
+ ]:
+ page.call("_select_category", category)
+ await create_timer(0.25).timeout
+ var entries: Dictionary = page.get("_entry_buttons")
+ assert(entries.size() == (19 if category == WaterType.Type.FRESH_WATER else 34))
+ for fish: FishDataType in LogbookCatalog.ordered_species(
+ CatalogResource.candidates
+ ):
+ if LogbookCatalog.category_for(fish) != category:
+ continue
+ var unknown_key := StringName(
+ "unknown_%d" % LogbookCatalog.catalog_number(fish.id)
+ )
+ var entry := entries.get(unknown_key) as Button
+ assert(entry != null)
+ assert(entry.text.is_empty())
+ assert(entry.tooltip_text.is_empty())
+ assert(entry.icon == null)
+ assert(entry.accessibility_name == "Unknown catalog entry")
+ assert(_has_visible_unknown_name(entry))
+ var portrait: LogbookPortraitType = _find_portrait(entry)
+ assert(portrait != null)
+ assert(portrait.source_texture == fish.display_texture)
+ assert(
+ portrait.custom_minimum_size
+ == LogbookPage.CATALOG_PORTRAIT_SIZE
+ )
+ assert(
+ portrait.expand_mode
+ == TextureRect.EXPAND_IGNORE_SIZE
+ )
+ assert(
+ portrait.stretch_mode
+ == TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ )
+ assert(portrait.material is ShaderMaterial)
+ if shared_material == null:
+ shared_material = portrait.material
+ else:
+ assert(portrait.material == shared_material)
+ assert(_texture_has_transparency(portrait.texture))
+ silhouette_count += 1
+ for candidate: FishDataType in CatalogResource.candidates:
+ assert(not entry.text.contains(candidate.display_name))
+ assert(
+ not entry.tooltip_text.contains(
+ candidate.display_name
+ )
+ )
+ assert(
+ not entry.accessibility_name.contains(
+ candidate.display_name
+ )
+ )
+ assert(silhouette_count == 53)
+
+ page.call("_select_category", WaterType.Type.OTHER)
+ await create_timer(0.25).timeout
+ assert((page.get("_entry_buttons") as Dictionary).is_empty())
+ assert(
+ (page.get("_empty_state") as Label).text
+ == "No entries available."
+ )
+ page.call("_select_category", WaterType.Type.FRESH_WATER)
+ await create_timer(0.25).timeout
+
+ var bluegill = CatalogResource.get_fish_by_id(&"bluegill")
+ page.call("_select_entry", &"unknown_1", StringName())
+ assert(
+ (page.get("_entry_buttons") as Dictionary)
+ .get(&"unknown_1") != null
+ )
+ collection.mark_discovered(&"bluegill")
+ await process_frame
+ var entries: Dictionary = page.get("_entry_buttons")
+ var known := entries.get(&"bluegill") as Button
+ assert(known != null)
+ assert(known.custom_minimum_size.x == known.custom_minimum_size.y)
+ _validate_entry_bubble_styles(known)
+ assert(known.text.is_empty())
+ assert(known.icon == null)
+ assert(_has_visible_name(known, bluegill.display_name))
+ var known_portrait: LogbookPortraitType = _find_portrait(known)
+ assert(known_portrait != null)
+ assert(known_portrait.source_texture == bluegill.display_texture)
+ assert(known_portrait.material == null)
+ assert(known.button_pressed)
+ _validate_handwritten_logbook_font(page)
+
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = bluegill
+ fish_catch.fish_id = bluegill.id
+ fish_catch.weight_lb = bluegill.get_minimum_weight()
+ fish_catch.display_scale = bluegill.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.sale_value = bluegill.get_sale_value_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.ensure_identity()
+ inventory.add_catch(fish_catch)
+ page.call("_select_entry", &"bluegill", &"bluegill")
+ await process_frame
+ assert(_detail_text(page).contains("number owned\n1"))
+ assert(_detail_text(page).contains("number caught\nunknown"))
+ assert(_detail_text(page).contains("body of water\nfresh water"))
+ assert(_detail_text(page).contains("catalog number\n#001"))
+ assert(_detail_text(page).contains("fish facts\n"))
+ assert(_detail_text(page).contains(LogbookCatalog.facts_for(&"bluegill")))
+ _validate_detail_field_fonts(page)
+ _validate_handwritten_numeric_scale(page)
+ var portrait_button := page.get("_detail_portrait_button") as Button
+ assert(portrait_button != null)
+ portrait_button.pressed.emit()
+ await process_frame
+ var portrait_overlay := page.get("_portrait_overlay") as Control
+ assert(portrait_overlay.visible)
+ var overlay_artwork := (
+ page.get("_portrait_overlay_artwork") as LogbookPortraitType
+ )
+ assert(overlay_artwork.source_texture == bluegill.display_texture)
+ assert(
+ overlay_artwork.custom_minimum_size.x <= 860.0
+ and overlay_artwork.custom_minimum_size.y <= 480.0
+ )
+ (page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
+ await process_frame
+ assert(not portrait_overlay.visible)
+
+ inventory.remove_catch_by_id(fish_catch.catch_id)
+ await process_frame
+ assert(_detail_text(page).contains("number owned\n0"))
+ page.queue_free()
+ collection.queue_free()
+ inventory.queue_free()
+
+
+func _detail_text(page: LogbookPage) -> String:
+ var values: PackedStringArray = []
+ var detail_body := page.get("_detail_body") as VBoxContainer
+ for node: Node in detail_body.find_children("*", "Label", true, false):
+ values.append((node as Label).text)
+ return "\n".join(values)
+
+
+func _find_portrait(entry: Button) -> LogbookPortraitType:
+ var pending: Array[Node] = [entry]
+ while not pending.is_empty():
+ var node: Node = pending.pop_back()
+ if node is LogbookPortraitType:
+ return node as LogbookPortraitType
+ pending.append_array(node.get_children())
+ return null
+
+
+func _has_visible_unknown_name(entry: Button) -> bool:
+ return _has_visible_name(entry, "???")
+
+
+func _has_visible_name(entry: Button, expected_name: String) -> bool:
+ var pending: Array[Node] = [entry]
+ while not pending.is_empty():
+ var node: Node = pending.pop_back()
+ if node is Label and (node as Label).text == expected_name:
+ return true
+ pending.append_array(node.get_children())
+ return false
+
+
+func _validate_handwritten_logbook_font(page: LogbookPage) -> void:
+ var detail_body := page.get("_detail_body") as VBoxContainer
+ for node: Node in detail_body.find_children("*", "Label", true, false):
+ var label := node as Label
+ if label.text.begins_with("quality collection"):
+ continue
+ if label.text in [
+ "fish facts",
+ "catalog number",
+ "rarity",
+ "body of water",
+ "time of day",
+ "weight range",
+ "value range",
+ "number caught",
+ "number owned",
+ ]:
+ continue
+ assert(
+ label.get_theme_font("font")
+ == InventoryNotepadType.HANDWRITTEN_FONT
+ )
+
+
+func _validate_detail_field_fonts(page: LogbookPage) -> void:
+ var detail_body := page.get("_detail_body") as VBoxContainer
+ var field_names: Array[String] = [
+ "fish facts",
+ "catalog number",
+ "rarity",
+ "body of water",
+ "time of day",
+ "weight range",
+ "value range",
+ "number caught",
+ "number owned",
+ ]
+ for node: Node in detail_body.find_children("*", "Label", true, false):
+ var label := node as Label
+ if label.text.begins_with("quality collection"):
+ assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont)
+ continue
+ if label.text not in field_names:
+ continue
+ assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont)
+ if label.text != "fish facts":
+ assert(
+ label.horizontal_alignment
+ == HORIZONTAL_ALIGNMENT_CENTER
+ )
+
+
+func _validate_handwritten_numeric_scale(page: LogbookPage) -> void:
+ var detail_body := page.get("_detail_body") as VBoxContainer
+ var text_value: Label
+ var numeric_value: Label
+ for node: Node in detail_body.find_children("*", "Label", true, false):
+ var label := node as Label
+ if label.text == "common":
+ text_value = label
+ elif label.text == "#001":
+ numeric_value = label
+ assert(text_value != null)
+ assert(numeric_value != null)
+ assert(
+ numeric_value.get_theme_font_size("font_size")
+ < text_value.get_theme_font_size("font_size")
+ )
+
+
+func _validate_entry_bubble_styles(entry: Button) -> void:
+ var normal := entry.get_theme_stylebox("normal") as StyleBoxFlat
+ var hover := entry.get_theme_stylebox("hover") as StyleBoxFlat
+ var pressed := entry.get_theme_stylebox("pressed") as StyleBoxFlat
+ assert(normal != null)
+ assert(hover != null)
+ assert(pressed != null)
+ assert(is_zero_approx(normal.bg_color.a))
+ assert(hover.bg_color.a > 0.0)
+ assert(pressed.bg_color.a > 0.0)
+ assert(hover.border_width_left == 0)
+ assert(pressed.border_width_left == 0)
+ assert(hover.corner_radius_top_left == 51)
+
+
+func _texture_has_transparency(texture: Texture2D) -> bool:
+ var image: Image = texture.get_image()
+ return image != null and image.detect_alpha() != Image.ALPHA_NONE
diff --git a/tests/logbook_validation.gd.uid b/tests/logbook_validation.gd.uid
new file mode 100644
index 0000000..1f35fa1
--- /dev/null
+++ b/tests/logbook_validation.gd.uid
@@ -0,0 +1 @@
+uid://c8pt0ibr37rmw
diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd
new file mode 100644
index 0000000..cb1d4eb
--- /dev/null
+++ b/tests/movement_multiplayer_validation.gd
@@ -0,0 +1,163 @@
+extends SceneTree
+
+const MainScene: PackedScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 18141
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("Movement multiplayer validation needs host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(session.start_private_host(TEST_PORT))
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ for _frame: int in 4:
+ await physics_frame
+ assert(session.set_host_open(true))
+ var remote_peer_id: int = await _wait_for_remote_peer(session)
+ assert(remote_peer_id > 1)
+ var player := main.get("_player") as Player
+ player.configure_network_remote(true)
+ player.apply_authoritative_network_input(_movement_input(1, true))
+ await create_timer(2.5).timeout
+ player.apply_authoritative_network_input(_movement_input(2, false))
+ await create_timer(2.5).timeout
+ player.apply_authoritative_network_input(_movement_input(3, false, false))
+ var disconnect_deadline: int = Time.get_ticks_msec() + 8000
+ while (
+ Time.get_ticks_msec() < disconnect_deadline
+ and session.is_authenticated_peer(remote_peer_id)
+ ):
+ await process_frame
+ assert(not session.is_authenticated_peer(remote_peer_id))
+ print("Movement multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call(
+ "_on_title_join_game_requested",
+ "127.0.0.1:%d" % TEST_PORT,
+ )
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ break
+ assert(session.is_joined_client())
+ var spawn_service := main.get_node(
+ "%PlayerSpawnService"
+ ) as PlayerSpawnService
+ var host_avatar: Player = spawn_service.get_avatar(1)
+ assert(host_avatar != null)
+ var animation_player := host_avatar.get_node(
+ "Visuals/CharacterRig/AnimationPlayer"
+ ) as AnimationPlayer
+ var sprint_dust := host_avatar.get_node("%SprintDust") as SprintDustTrail
+ var saw_running: bool = false
+ var saw_walking: bool = false
+ var saw_animation_advance: bool = false
+ var saw_dust: bool = false
+ var previous_animation: StringName = &""
+ var previous_animation_position: float = -1.0
+ var observation_deadline: int = Time.get_ticks_msec() + 7000
+ while Time.get_ticks_msec() < observation_deadline:
+ await process_frame
+ var current_animation: StringName = animation_player.current_animation
+ saw_running = saw_running or current_animation.begins_with("running")
+ saw_walking = saw_walking or current_animation.begins_with("walking")
+ var current_position: float = (
+ animation_player.current_animation_position
+ )
+ if (
+ current_animation == previous_animation
+ and previous_animation_position >= 0.0
+ and absf(current_position - previous_animation_position) > 0.001
+ ):
+ saw_animation_advance = true
+ previous_animation = current_animation
+ previous_animation_position = current_position
+ saw_dust = saw_dust or sprint_dust.get_active_puff_count() > 0
+ if saw_running and saw_walking and saw_animation_advance and saw_dust:
+ break
+ assert(saw_running)
+ assert(saw_walking)
+ assert(saw_animation_advance)
+ assert(saw_dust)
+ print("Movement multiplayer client validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _movement_input(
+ sequence: int,
+ sprinting: bool,
+ moving: bool = true,
+) -> Dictionary:
+ return {
+ "sequence": sequence,
+ "axis": [0.0, -1.0] if moving else [0.0, 0.0],
+ "camera_yaw": 0.0,
+ "jump": false,
+ "sprint": sprinting,
+ "sneak": false,
+ "slow_walk": false,
+ "sitting": false,
+ "casting": false,
+ "animation_action": (
+ NetworkPlayerAnimationProtocol.make_action_state()
+ ),
+ }
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
+
+
+func _wait_for_remote_peer(session: NetworkSession) -> int:
+ var deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < deadline:
+ await process_frame
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ return peer_id
+ return 0
diff --git a/tests/movement_multiplayer_validation.gd.uid b/tests/movement_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..4d8c726
--- /dev/null
+++ b/tests/movement_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://dlpu5lwb2pges
diff --git a/tests/network_player_animation_protocol_validation.gd b/tests/network_player_animation_protocol_validation.gd
new file mode 100644
index 0000000..8c3474c
--- /dev/null
+++ b/tests/network_player_animation_protocol_validation.gd
@@ -0,0 +1,49 @@
+extends SceneTree
+
+
+func _initialize() -> void:
+ var idle := NetworkPlayerAnimationProtocol.make_state(
+ NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
+ true,
+ )
+ assert(NetworkPlayerAnimationProtocol.validate_state(idle))
+ var emote := NetworkPlayerAnimationProtocol.make_state(
+ NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING,
+ false,
+ &"wave_hello",
+ 17,
+ 1.25,
+ )
+ assert(NetworkPlayerAnimationProtocol.validate_state(emote))
+ var future_locomotion := NetworkPlayerAnimationProtocol.make_state(
+ &"swimming_fast",
+ false,
+ )
+ assert(NetworkPlayerAnimationProtocol.validate_state(future_locomotion))
+ var extra_future_field: Dictionary = emote.duplicate(true)
+ extra_future_field["future_layer"] = {"id": "umbrella"}
+ assert(NetworkPlayerAnimationProtocol.validate_state(extra_future_field))
+
+ var invalid_action: Dictionary = emote.duplicate(true)
+ invalid_action["action"] = {
+ "id": "../unsafe",
+ "sequence": 17,
+ "elapsed": 1.25,
+ }
+ assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_action))
+ var invalid_sequence: Dictionary = emote.duplicate(true)
+ invalid_sequence["action"] = {
+ "id": "wave_hello",
+ "sequence": -1,
+ "elapsed": 1.25,
+ }
+ assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_sequence))
+ var invalid_elapsed: Dictionary = emote.duplicate(true)
+ invalid_elapsed["action"] = {
+ "id": "wave_hello",
+ "sequence": 17,
+ "elapsed": INF,
+ }
+ assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_elapsed))
+ print("Network player animation protocol validation: PASS")
+ quit()
diff --git a/tests/network_player_animation_protocol_validation.gd.uid b/tests/network_player_animation_protocol_validation.gd.uid
new file mode 100644
index 0000000..017146d
--- /dev/null
+++ b/tests/network_player_animation_protocol_validation.gd.uid
@@ -0,0 +1 @@
+uid://cjgelp62dkykn
diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd
new file mode 100644
index 0000000..e794cda
--- /dev/null
+++ b/tests/on_screen_keyboard_validation.gd
@@ -0,0 +1,82 @@
+extends SceneTree
+
+const KeyboardType = preload("res://ui/on_screen_keyboard.gd")
+const SettingsManagerType = preload(
+ "res://settings/player_settings_manager.gd"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_default_and_persistence()
+ await _validate_keyboard_entry()
+ print("On-screen keyboard validation: PASS")
+ quit()
+
+
+func _validate_default_and_persistence() -> void:
+ var defaults := PlayerSettings.new()
+ assert(not defaults.on_screen_keyboard_enabled)
+ var manager := SettingsManagerType.new()
+ root.add_child(manager)
+ assert(manager.load_settings())
+ var edited: PlayerSettings = manager.current_settings.copy()
+ edited.on_screen_keyboard_enabled = true
+ assert(manager.apply_settings(edited))
+ var reloaded := SettingsManagerType.new()
+ root.add_child(reloaded)
+ assert(reloaded.load_settings())
+ assert(reloaded.current_settings.on_screen_keyboard_enabled)
+ manager.queue_free()
+ reloaded.queue_free()
+
+
+func _validate_keyboard_entry() -> void:
+ var host := Control.new()
+ root.add_child(host)
+ var edit := LineEdit.new()
+ edit.focus_mode = Control.FOCUS_ALL
+ host.add_child(edit)
+ var keyboard := KeyboardType.new()
+ host.add_child(keyboard)
+ await process_frame
+ keyboard.set_enabled(true)
+ edit.grab_focus()
+ var activate_event := InputEventJoypadButton.new()
+ activate_event.button_index = JOY_BUTTON_A
+ activate_event.pressed = true
+ keyboard.call("_input", activate_event)
+ assert(keyboard.is_open())
+ keyboard.call("_type_character", "a")
+ keyboard.call("_type_space")
+ keyboard.call("_set_page", KeyboardType.Page.UPPER)
+ keyboard.call("_type_character", "B")
+ assert(edit.text == "a B")
+ keyboard.call("_move_caret", -1)
+ keyboard.call("_type_character", "C")
+ assert(edit.text == "a CB")
+ var backspace_event := InputEventJoypadButton.new()
+ backspace_event.button_index = JOY_BUTTON_X
+ backspace_event.pressed = true
+ keyboard.call("_input", backspace_event)
+ assert(edit.text == "a B")
+ var defocus_event := InputEventJoypadButton.new()
+ defocus_event.button_index = JOY_BUTTON_LEFT_SHOULDER
+ defocus_event.pressed = true
+ keyboard.call("_input", defocus_event)
+ assert(not keyboard.is_open())
+ assert(root.gui_get_focus_owner() == null)
+ edit.grab_focus()
+ keyboard.call("_input", activate_event)
+ assert(keyboard.is_open())
+ var submitted: Array[String] = []
+ edit.text_submitted.connect(func(value: String) -> void:
+ submitted.append(value)
+ )
+ keyboard.call("_submit")
+ assert(not keyboard.is_open())
+ assert(submitted == ["a B"])
+ host.queue_free()
diff --git a/tests/on_screen_keyboard_validation.gd.uid b/tests/on_screen_keyboard_validation.gd.uid
new file mode 100644
index 0000000..973bdca
--- /dev/null
+++ b/tests/on_screen_keyboard_validation.gd.uid
@@ -0,0 +1 @@
+uid://bgmt6joqdk37q
diff --git a/tests/operator_multiplayer_validation.gd b/tests/operator_multiplayer_validation.gd
new file mode 100644
index 0000000..3712fd4
--- /dev/null
+++ b/tests/operator_multiplayer_validation.gd
@@ -0,0 +1,253 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 18140
+const FIRST_BANNED_FINGERPRINT: String = (
+ "1111111111111111111111111111111111111111111111111111111111111111"
+)
+const SECOND_BANNED_FINGERPRINT: String = (
+ "2222222222222222222222222222222222222222222222222222222222222222"
+)
+
+var _moderation_results: Array[Dictionary] = []
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("Operator multiplayer validation needs host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var service := (
+ main.get_node("%NetworkPlayerListService")
+ as NetworkPlayerListService
+ )
+ var bans := main.get_node("%HostBanStore") as HostBanStore
+ assert(session.start_private_host(TEST_PORT))
+ assert(session.set_host_open(true))
+
+ var remote_peer_id: int = await _wait_for_remote_peer(session)
+ assert(remote_peer_id > 1)
+ var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
+ remote_peer_id
+ )
+ assert(remote_record != null and remote_record.identity_authenticated)
+ var host_fingerprint: String = session.get_host_identity_fingerprint()
+ assert(bans.ban(
+ host_fingerprint, FIRST_BANNED_FINGERPRINT, "First Banned Player"
+ ))
+
+ var entry: PlayerListEntry = _entry_for_peer(service, remote_peer_id)
+ assert(entry != null and entry.can_manage_operator)
+ assert(service.set_operator(
+ remote_peer_id,
+ remote_record.identity_fingerprint,
+ true,
+ entry.revision,
+ ))
+ assert(session.is_peer_operator(remote_peer_id))
+ var players_page := main.find_child(
+ "PlayersPage", true, false
+ ) as PlayersPage
+ assert(players_page != null)
+ players_page.call("_refresh")
+ assert(_has_button_text(players_page, "deop"))
+
+ var unban_deadline: int = Time.get_ticks_msec() + 12000
+ while (
+ Time.get_ticks_msec() < unban_deadline
+ and bans.is_banned(host_fingerprint, FIRST_BANNED_FINGERPRINT)
+ ):
+ await process_frame
+ assert(not bans.is_banned(
+ host_fingerprint, FIRST_BANNED_FINGERPRINT
+ ))
+ assert(bans.ban(
+ host_fingerprint, SECOND_BANNED_FINGERPRINT, "Second Banned Player"
+ ))
+
+ entry = _entry_for_peer(service, remote_peer_id)
+ assert(entry != null and entry.is_operator)
+ assert(service.set_operator(
+ remote_peer_id,
+ remote_record.identity_fingerprint,
+ false,
+ entry.revision,
+ ))
+ assert(not session.is_peer_operator(remote_peer_id))
+ await create_timer(2.0).timeout
+ assert(session.kick_authenticated_peer(
+ remote_peer_id,
+ remote_record.identity_fingerprint,
+ ))
+
+ var disconnect_deadline: int = Time.get_ticks_msec() + 8000
+ while (
+ Time.get_ticks_msec() < disconnect_deadline
+ and session.is_authenticated_peer(remote_peer_id)
+ ):
+ await process_frame
+ assert(not session.is_authenticated_peer(remote_peer_id))
+ assert(bans.is_banned(host_fingerprint, SECOND_BANNED_FINGERPRINT))
+ print("Operator multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call(
+ "_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT
+ )
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var service := (
+ main.get_node("%NetworkPlayerListService")
+ as NetworkPlayerListService
+ )
+ service.moderation_finished.connect(
+ func(success: bool, message: String) -> void:
+ _moderation_results.append({
+ "success": success,
+ "message": message,
+ })
+ )
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client():
+ break
+ assert(session.is_joined_client())
+
+ var operator_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
+ await process_frame
+ assert(session.is_local_operator())
+ assert(service.is_local_moderator())
+ var players_page := main.find_child(
+ "PlayersPage", true, false
+ ) as PlayersPage
+ assert(players_page != null)
+ players_page.call("_refresh")
+ var tabs := players_page.get("_tabs") as HBoxContainer
+ assert((tabs.get_child(2) as Button).visible)
+ players_page.call("_select_tab", 2)
+ assert(int(players_page.get("_current_tab")) == 2)
+ var local_entry: PlayerListEntry = _entry_for_peer(
+ service, session.get_local_peer_id()
+ )
+ assert(local_entry != null and local_entry.is_operator)
+
+ var ban_deadline: int = Time.get_ticks_msec() + 10000
+ while (
+ Time.get_ticks_msec() < ban_deadline
+ and not _has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT)
+ ):
+ await process_frame
+ assert(_has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT))
+ assert(service.unban(FIRST_BANNED_FINGERPRINT))
+ var result_deadline: int = Time.get_ticks_msec() + 8000
+ while Time.get_ticks_msec() < result_deadline and _moderation_results.is_empty():
+ await process_frame
+ assert(not _moderation_results.is_empty())
+ assert(bool(_moderation_results.back().get("success", false)))
+
+ var deop_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < deop_deadline and session.is_local_operator():
+ await process_frame
+ assert(not session.is_local_operator())
+ assert(not service.is_local_moderator())
+ assert(not (tabs.get_child(2) as Button).visible)
+ assert(int(players_page.get("_current_tab")) == 0)
+ service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT)
+ var disconnect_deadline: int = Time.get_ticks_msec() + 10000
+ while (
+ Time.get_ticks_msec() < disconnect_deadline
+ and session.state != NetworkSession.State.SERVER_LOST
+ ):
+ await process_frame
+ assert(session.state == NetworkSession.State.SERVER_LOST)
+ assert(not bool(main.get("_gameplay_started")))
+ var game_ui := main.get_node("%GameUI") as GameUI
+ var title_screen := game_ui.get_title_screen()
+ assert(title_screen.visible)
+ assert(not title_screen.is_awaiting_start_input())
+ assert((title_screen.get_node("%Center") as Control).visible)
+ assert((title_screen.get_node("%ButtonCenter") as Control).visible)
+ assert(not (title_screen.get_node("%JoinGamePage") as Control).visible)
+ var feedback := title_screen.get_node("%FeedbackLabel") as RichTextLabel
+ assert(feedback.visible)
+ assert("removed by the host" in feedback.text.to_lower())
+ print("Operator multiplayer client validation: PASS")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
+
+
+func _wait_for_remote_peer(session: NetworkSession) -> int:
+ var deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < deadline:
+ await process_frame
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ return peer_id
+ return 0
+
+
+func _entry_for_peer(
+ service: NetworkPlayerListService,
+ peer_id: int,
+) -> PlayerListEntry:
+ for entry: PlayerListEntry in service.get_entries():
+ if entry.peer_id == peer_id:
+ return entry
+ return null
+
+
+func _has_ban(records: Array[Dictionary], fingerprint: String) -> bool:
+ for record: Dictionary in records:
+ if str(record.get("target_fingerprint", "")) == fingerprint:
+ return true
+ return false
+
+
+func _has_button_text(root_node: Node, text: String) -> bool:
+ for node: Node in root_node.find_children("*", "Button", true, false):
+ var button := node as Button
+ if button != null and button.text == text:
+ return true
+ return false
diff --git a/tests/operator_multiplayer_validation.gd.uid b/tests/operator_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..2951ea6
--- /dev/null
+++ b/tests/operator_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://cv17xsf20ue5t
diff --git a/tests/player_experience_ui_validation.gd b/tests/player_experience_ui_validation.gd
new file mode 100644
index 0000000..31ed497
--- /dev/null
+++ b/tests/player_experience_ui_validation.gd
@@ -0,0 +1,51 @@
+extends SceneTree
+
+const GameUIScene: PackedScene = preload("res://ui/game_ui.tscn")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var game_ui := GameUIScene.instantiate() as GameUI
+ root.add_child(game_ui)
+ await process_frame
+ game_ui.set("_gameplay_ui_enabled", true)
+ var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer
+ var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer
+ var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label
+ var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label
+ assert(panel != null and bubble != null)
+ assert(not panel.visible and not bubble.visible)
+ game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true)
+ await process_frame
+ game_ui.call(
+ "_on_experience_awarded",
+ 50,
+ 0,
+ 50,
+ 1,
+ 1,
+ )
+ await process_frame
+ assert(not panel.visible and not bubble.visible)
+ game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false)
+ await process_frame
+ await process_frame
+ assert(panel.visible and bubble.visible)
+ assert(award_label.text == "+50 xp")
+ assert(bubble_label.text == "+50 xp!")
+ await create_timer(1.6).timeout
+ var progress := game_ui.get_node("%ExperienceProgress") as ProgressBar
+ var level_label := game_ui.get_node("%ExperienceLevelLabel") as Label
+ assert(is_equal_approx(progress.value, 50.0))
+ assert(level_label.text == "level 1")
+ await create_timer(1.2).timeout
+ assert(not panel.visible and not bubble.visible)
+ game_ui.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ print("Player experience UI validation: PASS")
+ quit()
diff --git a/tests/player_experience_ui_validation.gd.uid b/tests/player_experience_ui_validation.gd.uid
new file mode 100644
index 0000000..b2f976b
--- /dev/null
+++ b/tests/player_experience_ui_validation.gd.uid
@@ -0,0 +1 @@
+uid://c03tvq1ydhdqj
diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd
new file mode 100644
index 0000000..c9b36cd
--- /dev/null
+++ b/tests/player_experience_validation.gd
@@ -0,0 +1,181 @@
+extends SceneTree
+
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishExperienceType = preload("res://fish/fish_experience.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
+const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_level_curve()
+ _validate_catch_awards()
+ _validate_player_experience_state()
+ _validate_save_migration()
+ print("Player experience validation: PASS")
+ quit()
+
+
+func _validate_level_curve() -> void:
+ assert(PlayerExperienceType.experience_required_for_next_level(1) == 100)
+ assert(PlayerExperienceType.experience_required_for_next_level(2) == 130)
+ assert(PlayerExperienceType.experience_required_for_next_level(3) == 170)
+ assert(PlayerExperienceType.total_experience_for_level(1) == 0)
+ assert(PlayerExperienceType.total_experience_for_level(2) == 100)
+ assert(PlayerExperienceType.total_experience_for_level(3) == 230)
+ assert(PlayerExperienceType.total_experience_for_level(4) == 400)
+ assert(PlayerExperienceType.level_for_total_experience(99) == 1)
+ assert(PlayerExperienceType.level_for_total_experience(100) == 2)
+ assert(PlayerExperienceType.level_for_total_experience(399) == 3)
+ assert(PlayerExperienceType.level_for_total_experience(400) == 4)
+ assert(
+ is_equal_approx(
+ PlayerExperienceType.progress_for_total_experience(50),
+ 0.5,
+ )
+ )
+
+
+func _validate_catch_awards() -> void:
+ var minimum_boring: FishCatch = _make_catch(
+ FishQualityType.Tier.BORING,
+ false,
+ )
+ assert(
+ FishExperienceType.calculate_catch_experience(
+ minimum_boring,
+ false,
+ 0,
+ ) == 50
+ )
+ var maximum_shiny: FishCatch = _make_catch(
+ FishQualityType.Tier.SHINY,
+ true,
+ )
+ var boring_mask: int = FishQualityType.bit_for(
+ FishQualityType.Tier.BORING
+ )
+ assert(
+ FishExperienceType.calculate_catch_experience(
+ maximum_shiny,
+ true,
+ boring_mask,
+ ) == 46
+ )
+ var almost_mastered: int = (
+ FishQualityType.ALL_TIERS_MASK
+ & ~FishQualityType.bit_for(FishQualityType.Tier.SHINY)
+ )
+ assert(
+ FishExperienceType.calculate_catch_experience(
+ maximum_shiny,
+ true,
+ almost_mastered,
+ ) == 146
+ )
+ assert(
+ FishExperienceType.calculate_catch_experience(
+ maximum_shiny,
+ true,
+ FishQualityType.ALL_TIERS_MASK,
+ ) == 31
+ )
+
+
+func _validate_player_experience_state() -> void:
+ var experience := PlayerExperienceType.new()
+ root.add_child(experience)
+ var awards: Array[Array] = []
+ experience.experience_awarded.connect(
+ func(
+ amount: int,
+ previous_total: int,
+ new_total: int,
+ previous_level: int,
+ new_level: int,
+ ) -> void:
+ awards.append([
+ amount,
+ previous_total,
+ new_total,
+ previous_level,
+ new_level,
+ ])
+ )
+ assert(experience.get_level() == 1)
+ assert(experience.award_experience(125))
+ assert(experience.get_total_experience() == 125)
+ assert(experience.get_level() == 2)
+ assert(experience.get_experience_in_level() == 25)
+ assert(awards.size() == 1)
+ assert(awards[0] == [125, 0, 125, 1, 2])
+ assert(experience.restore_total_experience(400))
+ assert(experience.get_level() == 4)
+ assert(not experience.restore_total_experience(-1))
+ experience.queue_free()
+
+
+func _validate_save_migration() -> void:
+ var manager := PlayerSaveManager.new()
+ root.add_child(manager)
+ var version_five: Dictionary = {
+ "save_version": 5,
+ "wallet": {"balance": 0},
+ "collection": {
+ "discovered_fish_ids": [],
+ "discovered_quality_masks": {},
+ },
+ "inventory": {"next_catch_sequence": 1, "catches": []},
+ "bag": {"items": []},
+ "hotbar": {"selected_slot": 0, "slots": []},
+ "upgrades": {"reel_speed_level": 0, "barrier_power_level": 0},
+ "cooler": {"capacity_level": 0},
+ "art": {"unlock_mask": 0},
+ }
+ var migrated: Dictionary = manager.call(
+ "_migrate_save",
+ version_five,
+ 5,
+ )
+ assert(int(migrated.get("save_version", -1)) == 7)
+ var experience_data: Dictionary = migrated.get("experience", {})
+ assert(int(experience_data.get("total_experience", -1)) == 0)
+ var world_data: Dictionary = migrated.get("world", {})
+ assert(is_equal_approx(float(world_data.get("time_hours", -1.0)), 8.0))
+ assert(
+ PlayerJobService.validate_save_data(migrated.get("jobs", {}))
+ )
+ manager.queue_free()
+
+
+func _make_catch(quality: int, maximum_weight: bool) -> FishCatch:
+ var fish: FishData = Catalog.get_fish_by_id(&"bluegill")
+ assert(fish != null)
+ var fish_catch := FishCatchType.new()
+ fish_catch.fish = fish
+ fish_catch.fish_id = fish.id
+ fish_catch.catch_id = StringName("bluegill:xp-%d-%s" % [
+ quality,
+ "max" if maximum_weight else "min",
+ ])
+ fish_catch.catch_sequence = 1
+ fish_catch.weight_lb = (
+ fish.get_maximum_weight()
+ if maximum_weight
+ else fish.get_minimum_weight()
+ )
+ fish_catch.display_scale = fish.get_display_scale_for_weight(
+ fish_catch.weight_lb
+ )
+ fish_catch.quality = quality
+ fish_catch.sale_value = FishQualityType.apply_sale_value(
+ fish.get_sale_value_for_weight(fish_catch.weight_lb),
+ quality,
+ )
+ return fish_catch
diff --git a/tests/player_experience_validation.gd.uid b/tests/player_experience_validation.gd.uid
new file mode 100644
index 0000000..1afe50c
--- /dev/null
+++ b/tests/player_experience_validation.gd.uid
@@ -0,0 +1 @@
+uid://djkr2iaa5taks
diff --git a/tests/shoreline_ambience_validation.gd b/tests/shoreline_ambience_validation.gd
new file mode 100644
index 0000000..4297d72
--- /dev/null
+++ b/tests/shoreline_ambience_validation.gd
@@ -0,0 +1,48 @@
+extends SceneTree
+
+const ShorelineAmbienceType := preload("res://world/shoreline_ambience.gd")
+const WavesStream := preload("res://audio/ambience/waves.wav")
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var waves := WavesStream as AudioStreamWAV
+ assert(waves != null)
+
+ var segments: Array[PackedVector2Array] = [
+ PackedVector2Array([Vector2.ZERO, Vector2(10.0, 0.0)]),
+ ]
+ assert(is_equal_approx(
+ ShorelineAmbienceType.distance_to_segments(Vector2(5.0, 3.0), segments),
+ 3.0,
+ ))
+ assert(is_equal_approx(
+ ShorelineAmbienceType.distance_to_segments(Vector2(-4.0, 0.0), segments),
+ 4.0,
+ ))
+
+ var controller := ShorelineAmbienceType.new() as ShorelineAmbience
+ var runtime_audio := AudioStreamPlayer.new()
+ runtime_audio.name = "WavesAudio"
+ runtime_audio.unique_name_in_owner = true
+ runtime_audio.stream = WavesStream
+ runtime_audio.bus = &"SFX"
+ controller.add_child(runtime_audio)
+ runtime_audio.owner = controller
+ root.add_child(controller)
+ await process_frame
+ assert(runtime_audio.bus == &"Environment")
+ assert(controller.near_distance < controller.far_distance)
+ assert(controller.near_volume_db > controller.far_volume_db)
+ var runtime_waves := runtime_audio.stream as AudioStreamWAV
+ assert(runtime_waves.loop_mode == AudioStreamWAV.LOOP_FORWARD)
+ assert(runtime_waves.loop_begin == 0)
+ assert(runtime_waves.loop_end == 1044956)
+ controller.free()
+ await process_frame
+
+ print("Shoreline ambience validation: PASS")
+ quit()
diff --git a/tests/shoreline_ambience_validation.gd.uid b/tests/shoreline_ambience_validation.gd.uid
new file mode 100644
index 0000000..4b7987b
--- /dev/null
+++ b/tests/shoreline_ambience_validation.gd.uid
@@ -0,0 +1 @@
+uid://bwvhgpbkb00l1
diff --git a/tests/shoreline_ribbon_validation.gd b/tests/shoreline_ribbon_validation.gd
new file mode 100644
index 0000000..e78acb0
--- /dev/null
+++ b/tests/shoreline_ribbon_validation.gd
@@ -0,0 +1,97 @@
+extends SceneTree
+
+const ShorelineMesh: ArrayMesh = preload(
+ "res://world/generated/shorelines/starter_ocean_shoreline.tres"
+)
+const GEOMETRY_EPSILON := 0.0001
+const CROSSING_NEIGHBORHOOD := 32
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ assert(ShorelineMesh.get_surface_count() == 1)
+ var arrays := ShorelineMesh.surface_get_arrays(0)
+ var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
+ var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
+ assert(not vertices.is_empty())
+ assert(vertices.size() % 2 == 0)
+ assert(indices.size() == vertices.size() * 3)
+ var point_count := vertices.size() / 2
+ var land_edge := PackedVector2Array()
+ var water_edge := PackedVector2Array()
+ for index: int in point_count:
+ var land := vertices[index * 2]
+ var water := vertices[index * 2 + 1]
+ land_edge.append(Vector2(land.x, land.z))
+ water_edge.append(Vector2(water.x, water.z))
+ var ribbon_width := land.distance_to(water)
+ assert(ribbon_width >= 0.34)
+ assert(ribbon_width <= 1.5)
+ var crossing_count := (
+ _count_local_crossings(water_edge)
+ + _count_local_crossings(land_edge)
+ )
+ if crossing_count > 0:
+ push_error(
+ "Shoreline ribbon contains %d local edge crossings."
+ % crossing_count
+ )
+ quit(1)
+ return
+ print("Shoreline ribbon validation: PASS")
+ quit()
+
+
+func _count_local_crossings(points: PackedVector2Array) -> int:
+ var crossing_count := 0
+ for first: int in points.size():
+ var first_next := (first + 1) % points.size()
+ var checked_neighbors := mini(
+ CROSSING_NEIGHBORHOOD,
+ points.size() - 2,
+ )
+ for offset: int in range(2, checked_neighbors + 1):
+ var second := (first + offset) % points.size()
+ var second_next := (second + 1) % points.size()
+ if second_next == first:
+ continue
+ if _segments_cross(
+ points[first],
+ points[first_next],
+ points[second],
+ points[second_next],
+ ):
+ crossing_count += 1
+ return crossing_count
+
+
+func _segments_cross(
+ a: Vector2,
+ b: Vector2,
+ c: Vector2,
+ d: Vector2,
+) -> bool:
+ if (
+ maxf(a.x, b.x) < minf(c.x, d.x) - GEOMETRY_EPSILON
+ or maxf(c.x, d.x) < minf(a.x, b.x) - GEOMETRY_EPSILON
+ or maxf(a.y, b.y) < minf(c.y, d.y) - GEOMETRY_EPSILON
+ or maxf(c.y, d.y) < minf(a.y, b.y) - GEOMETRY_EPSILON
+ ):
+ return false
+ var first_direction := b - a
+ var second_direction := d - c
+ var denominator := first_direction.cross(second_direction)
+ if absf(denominator) <= GEOMETRY_EPSILON:
+ return false
+ var offset := c - a
+ var first_amount := offset.cross(second_direction) / denominator
+ var second_amount := offset.cross(first_direction) / denominator
+ return (
+ first_amount > GEOMETRY_EPSILON
+ and first_amount < 1.0 - GEOMETRY_EPSILON
+ and second_amount > GEOMETRY_EPSILON
+ and second_amount < 1.0 - GEOMETRY_EPSILON
+ )
diff --git a/tests/shoreline_ribbon_validation.gd.uid b/tests/shoreline_ribbon_validation.gd.uid
new file mode 100644
index 0000000..7989d89
--- /dev/null
+++ b/tests/shoreline_ribbon_validation.gd.uid
@@ -0,0 +1 @@
+uid://c73frygpfhred
diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd
new file mode 100644
index 0000000..74c86d2
--- /dev/null
+++ b/tests/surface_drawing_multiplayer_validation.gd
@@ -0,0 +1,266 @@
+extends SceneTree
+
+const MainScene: PackedScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 18133
+const WAIT_MSEC: int = 20000
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("Surface drawing multiplayer validation needs host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session.start_private_host(TEST_PORT))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ await physics_frame
+ await physics_frame
+ var player := main.get("_player") as Player
+ assert(player.bag.add_item(&"art_kit", 1))
+ assert(player.hotbar.assign_item(0, &"art_kit"))
+ assert(player.hotbar.select_slot(0))
+ assert(player.art_unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK))
+
+ var service := main.get_node(
+ "%NetworkSurfaceDrawingService"
+ ) as NetworkSurfaceDrawingService
+ var surface: Dictionary = _surface_below_local_player(main)
+ assert(not surface.is_empty())
+ assert(service.request_canvas_at_surface(
+ surface["position"], surface["normal"], Vector3.RIGHT
+ ))
+ var canvas_id: String = service.get_canvas_ids()[0]
+ assert(service.request_cell_edits(canvas_id, [{
+ "x": 7,
+ "y": 7,
+ "color_id": "coral",
+ }]))
+ assert(session.set_host_open(true))
+
+ var remote_peer_id: int = await _wait_for_remote_peer(session)
+ assert(remote_peer_id > 1)
+ var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
+ remote_peer_id
+ )
+ assert(remote_record != null)
+ assert(session.peer_supports_capability(
+ remote_peer_id, SurfaceDrawingProtocol.CAPABILITY
+ ))
+
+ var overwrite_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < overwrite_deadline:
+ await process_frame
+ var state: Dictionary = service.get_canvas_state(canvas_id)
+ if int(state.get("revision", 0)) < 2:
+ continue
+ var cell: Dictionary = _find_cell(state, 7, 7)
+ if (
+ str(cell.get("color_id", "")) == "blue"
+ and str(cell.get("author_fingerprint", ""))
+ == remote_record.identity_fingerprint
+ ):
+ break
+ var overwritten_state: Dictionary = service.get_canvas_state(canvas_id)
+ assert(int(overwritten_state["revision"]) >= 2)
+ var overwritten_cell: Dictionary = _find_cell(overwritten_state, 7, 7)
+ assert(str(overwritten_cell["color_id"]) == "blue")
+ assert(
+ str(overwritten_cell["author_fingerprint"])
+ == remote_record.identity_fingerprint
+ )
+
+ assert(service.request_cell_edits(canvas_id, [{
+ "x": 8,
+ "y": 7,
+ "color_id": "sunny",
+ }]))
+ var undo_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < undo_deadline:
+ await process_frame
+ var state: Dictionary = service.get_canvas_state(canvas_id)
+ if str(_find_cell(state, 7, 7).get("color_id", "")) == "coral":
+ break
+ assert(
+ str(_find_cell(service.get_canvas_state(canvas_id), 7, 7)["color_id"])
+ == "coral"
+ )
+ var guide_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < guide_deadline:
+ await process_frame
+ if not bool(service.get_canvas_state(canvas_id).get("guide_visible", true)):
+ break
+ assert(not bool(service.get_canvas_state(canvas_id)["guide_visible"]))
+ # Keep the shared hidden state observable before publishing the restore.
+ await create_timer(1.0).timeout
+ assert(service.request_guide_visibility(canvas_id, true))
+ await create_timer(1.0).timeout
+ assert(service.request_guide_visibility(canvas_id, false, true))
+ await create_timer(1.0).timeout
+ assert(service.clear_session_artwork())
+ await create_timer(1.0).timeout
+ print("Surface drawing multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call(
+ "_on_title_join_game_requested",
+ "127.0.0.1:%d" % TEST_PORT,
+ )
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var join_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ break
+ assert(session.is_joined_client())
+ assert(session.supports_server_capability(
+ SurfaceDrawingProtocol.CAPABILITY
+ ))
+ var player := main.get("_player") as Player
+ assert(player.bag.add_item(&"art_kit", 1))
+ assert(player.hotbar.assign_item(0, &"art_kit"))
+ assert(player.hotbar.select_slot(0))
+ assert(player.art_unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK))
+
+ var service := main.get_node(
+ "%NetworkSurfaceDrawingService"
+ ) as NetworkSurfaceDrawingService
+ var snapshot_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while (
+ Time.get_ticks_msec() < snapshot_deadline
+ and service.get_canvas_ids().is_empty()
+ ):
+ await process_frame
+ assert(service.get_canvas_ids().size() == 1)
+ var canvas_id: String = service.get_canvas_ids()[0]
+ var snapshot: Dictionary = service.get_canvas_state(canvas_id)
+ assert(str(_find_cell(snapshot, 7, 7).get("color_id", "")) == "coral")
+ assert(service.request_cell_edits(canvas_id, [{
+ "x": 7,
+ "y": 7,
+ "color_id": "blue",
+ }]))
+
+ var shared_edit_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < shared_edit_deadline:
+ await process_frame
+ var state: Dictionary = service.get_canvas_state(canvas_id)
+ if str(_find_cell(state, 8, 7).get("color_id", "")) == "sunny":
+ break
+ var final_state: Dictionary = service.get_canvas_state(canvas_id)
+ assert(str(_find_cell(final_state, 7, 7)["color_id"]) == "blue")
+ assert(str(_find_cell(final_state, 8, 7)["color_id"]) == "sunny")
+ assert(service.request_undo_last_stroke())
+ var undo_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < undo_deadline:
+ await process_frame
+ var state: Dictionary = service.get_canvas_state(canvas_id)
+ if str(_find_cell(state, 7, 7).get("color_id", "")) == "coral":
+ break
+ assert(
+ str(_find_cell(service.get_canvas_state(canvas_id), 7, 7)["color_id"])
+ == "coral"
+ )
+ assert(service.request_guide_visibility(canvas_id, false))
+ var guide_hidden_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < guide_hidden_deadline:
+ await process_frame
+ if not bool(service.get_canvas_state(canvas_id).get("guide_visible", true)):
+ break
+ assert(not bool(service.get_canvas_state(canvas_id)["guide_visible"]))
+ var guide_restored_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < guide_restored_deadline:
+ await process_frame
+ if bool(service.get_canvas_state(canvas_id).get("guide_visible", false)):
+ break
+ assert(bool(service.get_canvas_state(canvas_id)["guide_visible"]))
+ var finalized_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < finalized_deadline:
+ await process_frame
+ if bool(service.get_canvas_state(canvas_id).get("finalized", false)):
+ break
+ assert(bool(service.get_canvas_state(canvas_id)["finalized"]))
+ var reset_deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < reset_deadline:
+ await process_frame
+ if service.get_canvas_ids().is_empty():
+ break
+ assert(service.get_canvas_ids().is_empty())
+ print("Surface drawing multiplayer client validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _surface_below_local_player(main: Node) -> Dictionary:
+ var player := main.get("_player") as Player
+ var query := PhysicsRayQueryParameters3D.create(
+ player.global_position + Vector3.UP * 3.0,
+ player.global_position + Vector3.DOWN * 6.0,
+ 1,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ query.exclude = [player.get_rid()]
+ return player.get_world_3d().direct_space_state.intersect_ray(query)
+
+
+func _wait_for_remote_peer(session: NetworkSession) -> int:
+ var deadline: int = Time.get_ticks_msec() + WAIT_MSEC
+ while Time.get_ticks_msec() < deadline:
+ await process_frame
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ return peer_id
+ return 0
+
+
+func _find_cell(state: Dictionary, x: int, y: int) -> Dictionary:
+ for value: Variant in state.get("cells", []):
+ if typeof(value) != TYPE_DICTIONARY:
+ continue
+ var cell: Dictionary = value
+ if int(cell.get("x", -1)) == x and int(cell.get("y", -1)) == y:
+ return cell
+ return {}
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
diff --git a/tests/surface_drawing_multiplayer_validation.gd.uid b/tests/surface_drawing_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..cc43853
--- /dev/null
+++ b/tests/surface_drawing_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://bj5dje2scya34
diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd
new file mode 100644
index 0000000..8835127
--- /dev/null
+++ b/tests/surface_drawing_runtime_validation.gd
@@ -0,0 +1,179 @@
+extends SceneTree
+
+const MainScene: PackedScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 18132
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ root.size = Vector2i(1280, 720)
+ var main: Node = MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ assert(session.start_private_host(TEST_PORT))
+ var save_manager := main.get("_save_manager") as PlayerSaveManager
+ assert(save_manager.initialize_new_game())
+ main.call("_enter_gameplay")
+ await physics_frame
+ await physics_frame
+
+ var player := main.get("_player") as Player
+ assert(player.bag.add_item(&"art_kit", 1))
+ assert(player.hotbar.assign_item(0, &"art_kit"))
+ assert(player.hotbar.select_slot(0))
+ assert(player.art_unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK))
+ var service := main.get_node(
+ "%NetworkSurfaceDrawingService"
+ ) as NetworkSurfaceDrawingService
+ assert(service != null)
+ assert(session.supports_server_capability(SurfaceDrawingProtocol.CAPABILITY))
+ service.deactivate()
+ _validate_marker_controls(service, player)
+ var query := PhysicsRayQueryParameters3D.create(
+ player.global_position + Vector3.UP * 3.0,
+ player.global_position + Vector3.DOWN * 6.0,
+ 1,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ query.exclude = [player.get_rid()]
+ var hit: Dictionary = player.get_world_3d().direct_space_state.intersect_ray(query)
+ assert(not hit.is_empty())
+ assert(service.request_canvas_at_surface(
+ hit["position"], hit["normal"], Vector3.RIGHT
+ ))
+ assert(service.get_canvas_ids().size() == 1)
+ var canvas_id: String = service.get_canvas_ids()[0]
+ assert(service.request_cell_edits(canvas_id, [{
+ "x": 8,
+ "y": 8,
+ "color_id": "ocean_teal",
+ }]))
+ var state: Dictionary = service.get_canvas_state(canvas_id)
+ assert(int(state["revision"]) == 1)
+ assert((state["cells"] as Array).size() == 1)
+ assert(state["cells"][0]["color_id"] == "ocean_teal")
+ assert(
+ state["cells"][0]["author_fingerprint"]
+ == session.get_local_identity_fingerprint()
+ )
+ assert(service.request_guide_visibility(canvas_id, false))
+ state = service.get_canvas_state(canvas_id)
+ assert(not bool(state["guide_visible"]))
+ var canvas_nodes: Dictionary = service.get("_canvas_nodes")
+ var canvas := canvas_nodes[canvas_id] as SurfaceDrawingCanvas
+ assert(not canvas.is_guide_visible())
+ assert(service.request_guide_visibility(canvas_id, true))
+ assert(bool(service.get_canvas_state(canvas_id)["guide_visible"]))
+ assert(service.request_guide_visibility(canvas_id, false, true))
+ assert(bool(service.get_canvas_state(canvas_id)["finalized"]))
+ assert(canvas.is_finalized())
+ assert(service.request_canvas_at_surface(
+ hit["position"], hit["normal"], Vector3.RIGHT
+ ))
+ assert(service.get_canvas_ids().size() == 2)
+ var replacement_canvas_id: String = ""
+ for candidate_id: String in service.get_canvas_ids():
+ if candidate_id != canvas_id:
+ replacement_canvas_id = candidate_id
+ assert(not replacement_canvas_id.is_empty())
+ assert(service.request_cell_edits(replacement_canvas_id, [{
+ "x": 8,
+ "y": 8,
+ "color_id": "blue",
+ }]))
+ assert((service.get_canvas_state(canvas_id)["cells"] as Array).is_empty())
+ assert(
+ str(service.get_canvas_state(replacement_canvas_id)["cells"][0]["color_id"])
+ == "blue"
+ )
+ assert(service.request_undo_last_stroke())
+ assert((service.get_canvas_state(replacement_canvas_id)["cells"] as Array).is_empty())
+ assert(
+ str(service.get_canvas_state(canvas_id)["cells"][0]["color_id"])
+ == "ocean_teal"
+ )
+ var player_list := main.get_node(
+ "%NetworkPlayerListService"
+ ) as NetworkPlayerListService
+ assert(player_list.get_session_artwork_counts() == Vector2i(2, 1))
+ assert(player_list.reset_session_artwork())
+ assert(service.get_canvas_ids().is_empty())
+
+ print("Surface drawing runtime validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _validate_marker_controls(
+ service: NetworkSurfaceDrawingService,
+ player: Player,
+) -> void:
+ var prior_mouse_mode: Input.MouseMode = Input.mouse_mode
+ var activation_pointer := Vector2(824.0, 318.0)
+ service.activate(activation_pointer)
+ assert(service.is_active())
+ assert(not service.is_placement_mode())
+ assert(service.get_pointer_screen_position() == activation_pointer)
+ if DisplayServer.get_name() != "headless":
+ assert(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE)
+
+ var placement_key := InputEventKey.new()
+ placement_key.physical_keycode = KEY_R
+ placement_key.pressed = true
+ assert(service.handle_input(placement_key, true))
+ assert(service.is_placement_mode())
+
+ var pointer_before: Vector2 = service.get_pointer_screen_position()
+ var pointer_motion := InputEventMouseMotion.new()
+ pointer_motion.position = pointer_before + Vector2(30.0, -12.0)
+ assert(service.handle_input(pointer_motion, true))
+ assert(service.get_pointer_screen_position() != pointer_before)
+ var scaled_ui_motion := InputEventMouseMotion.new()
+ scaled_ui_motion.position = Vector2(260.0, 140.0)
+ var root_viewport_pointer := Vector2(960.0, 540.0)
+ assert(service.handle_input(
+ scaled_ui_motion,
+ true,
+ root_viewport_pointer,
+ ))
+ assert(service.get_pointer_screen_position() == root_viewport_pointer)
+ var zoom_event := InputEventMouseButton.new()
+ zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
+ zoom_event.shift_pressed = true
+ zoom_event.pressed = true
+ assert(not service.handle_input(zoom_event, true))
+ var prior_zoom: float = float(player.get("_target_zoom"))
+ player.call("_unhandled_input", zoom_event)
+ assert(float(player.get("_target_zoom")) < prior_zoom)
+
+ var camera_press := InputEventMouseButton.new()
+ camera_press.button_index = MOUSE_BUTTON_RIGHT
+ camera_press.pressed = true
+ assert(not service.handle_input(camera_press, true))
+ var pointer_before_camera: Vector2 = service.get_pointer_screen_position()
+ assert(not service.handle_input(pointer_motion, true))
+ assert(service.get_pointer_screen_position() == pointer_before_camera)
+ var camera_release := InputEventMouseButton.new()
+ camera_release.button_index = MOUSE_BUTTON_RIGHT
+ camera_release.pressed = false
+ assert(not service.handle_input(camera_release, true))
+
+ assert(service.handle_input(placement_key, true))
+ assert(not service.is_placement_mode())
+ service.deactivate()
+ assert(Input.mouse_mode == prior_mouse_mode)
diff --git a/tests/surface_drawing_runtime_validation.gd.uid b/tests/surface_drawing_runtime_validation.gd.uid
new file mode 100644
index 0000000..af3cd35
--- /dev/null
+++ b/tests/surface_drawing_runtime_validation.gd.uid
@@ -0,0 +1 @@
+uid://8c30wxeu4lxv
diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd
new file mode 100644
index 0000000..596cec0
--- /dev/null
+++ b/tests/surface_drawing_validation.gd
@@ -0,0 +1,317 @@
+extends SceneTree
+
+const FINGERPRINT_A: String = (
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+)
+const FINGERPRINT_B: String = (
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_palette()
+ _validate_art_unlocks()
+ _validate_protocol_bounds()
+ _validate_grid_snapping()
+ await _validate_canvas_geometry_and_collaboration()
+ await _validate_blocked_author_visibility()
+ _validate_peer_capability_tracking()
+ print("Surface drawing validation: PASS")
+ for _frame: int in 4:
+ await process_frame
+ quit()
+
+
+func _validate_palette() -> void:
+ var ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids()
+ assert(ids.size() == 8)
+ assert(ids.front() == SurfaceDrawingPalette.DEFAULT_COLOR_ID)
+ for color_id: StringName in ids:
+ assert(SurfaceDrawingPalette.has_color(color_id))
+ assert(not SurfaceDrawingPalette.get_display_name(color_id).is_empty())
+ assert(
+ SurfaceDrawingPalette.filter_unlocked_ids([])
+ == [SurfaceDrawingPalette.DEFAULT_COLOR_ID]
+ )
+
+
+func _validate_protocol_bounds() -> void:
+ assert(SurfaceDrawingProtocol.GRID_WIDTH == 16)
+ assert(SurfaceDrawingProtocol.GRID_HEIGHT == 16)
+ assert(SurfaceDrawingProtocol.GRID_SIZES == [16, 32, 64, 128])
+ assert(is_equal_approx(SurfaceDrawingProtocol.CELL_SIZE, 0.075))
+ assert(is_equal_approx(
+ float(SurfaceDrawingProtocol.GRID_WIDTH)
+ * SurfaceDrawingProtocol.CELL_SIZE,
+ 1.2,
+ ))
+ var canvas_request: Dictionary = {
+ "request_id": "canvas-1",
+ "session_id": "session",
+ "origin": [0.0, 1.0, 2.0],
+ "normal": [0.0, 1.0, 0.0],
+ "tangent": [1.0, 0.0, 0.0],
+ "width": SurfaceDrawingProtocol.GRID_WIDTH,
+ "height": SurfaceDrawingProtocol.GRID_HEIGHT,
+ "cell_size": SurfaceDrawingProtocol.CELL_SIZE,
+ }
+ assert(SurfaceDrawingProtocol.validate_canvas_request(canvas_request))
+ var malformed: Dictionary = canvas_request.duplicate(true)
+ malformed["origin"] = [INF, 0.0, 0.0]
+ assert(not SurfaceDrawingProtocol.validate_canvas_request(malformed))
+ var edit_request: Dictionary = {
+ "request_id": "edit-1",
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "stroke_id": "stroke-1",
+ "brush_size": 1,
+ "edits": [{"x": 0, "y": 0, "color_id": "ocean_teal"}],
+ }
+ assert(SurfaceDrawingProtocol.validate_edit_request(edit_request))
+ edit_request["edits"] = [{
+ "x": SurfaceDrawingProtocol.MAX_GRID_SIZE,
+ "y": 0,
+ "color_id": "ocean_teal",
+ }]
+ assert(not SurfaceDrawingProtocol.validate_edit_request(edit_request))
+ edit_request["edits"] = [{"x": 0, "y": 0, "color_id": "ocean_teal"}]
+ edit_request["brush_size"] = 5
+ assert(not SurfaceDrawingProtocol.validate_edit_request(edit_request))
+ for grid_size: int in SurfaceDrawingProtocol.GRID_SIZES:
+ var sized_request: Dictionary = canvas_request.duplicate(true)
+ sized_request["width"] = grid_size
+ sized_request["height"] = grid_size
+ assert(SurfaceDrawingProtocol.validate_canvas_request(sized_request))
+ var unsupported_size: Dictionary = canvas_request.duplicate(true)
+ unsupported_size["width"] = 48
+ unsupported_size["height"] = 48
+ assert(not SurfaceDrawingProtocol.validate_canvas_request(unsupported_size))
+ var guide_request: Dictionary = {
+ "request_id": "guide-1",
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "guide_visible": false,
+ "finalized": false,
+ }
+ assert(SurfaceDrawingProtocol.validate_guide_request(guide_request))
+ guide_request["guide_visible"] = 0
+ assert(not SurfaceDrawingProtocol.validate_guide_request(guide_request))
+ var undo_request: Dictionary = {
+ "request_id": "undo-1",
+ "session_id": "session",
+ "stroke_id": "stroke-1",
+ }
+ assert(SurfaceDrawingProtocol.validate_undo_request(undo_request))
+
+
+func _validate_art_unlocks() -> void:
+ var unlocks := PlayerArtUnlocks.new()
+ assert(unlocks.get_unlocked_color_ids() == [&"chalk_white"])
+ assert(unlocks.get_unlocked_brush_sizes() == [1])
+ assert(unlocks.get_unlocked_grid_sizes() == [16])
+ assert(unlocks.unlock_product(&"marker_ocean_teal"))
+ assert(unlocks.unlock_product(&"brush_4x"))
+ assert(unlocks.unlock_product(&"grid_128x"))
+ assert(unlocks.is_color_unlocked(&"ocean_teal"))
+ assert(unlocks.is_brush_size_unlocked(4))
+ assert(unlocks.is_grid_size_unlocked(128))
+ assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1))
+ unlocks.free()
+
+
+func _validate_grid_snapping() -> void:
+ var anchor: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "anchor",
+ "origin": [0.0, 0.0, 0.0],
+ "normal": [0.0, 1.0, 0.0],
+ "tangent": [1.0, 0.0, 0.0],
+ "width": SurfaceDrawingProtocol.GRID_WIDTH,
+ "height": SurfaceDrawingProtocol.GRID_HEIGHT,
+ "cell_size": SurfaceDrawingProtocol.CELL_SIZE,
+ "revision": 0,
+ "guide_visible": false,
+ "finalized": true,
+ "layer": 1,
+ "creator_fingerprint": FINGERPRINT_A,
+ "cells": [],
+ }
+ var snapped: Dictionary = SurfaceDrawingPlacement.resolve(
+ Vector3(1.08, 0.02, 0.08),
+ Vector3.UP,
+ Vector3.RIGHT,
+ [anchor],
+ )
+ assert(bool(snapped["snapped"]))
+ var snapped_origin: Vector3 = snapped["origin"]
+ assert(snapped_origin.is_equal_approx(Vector3(1.2, 0.0, 0.0)))
+ var unsnapped: Dictionary = SurfaceDrawingPlacement.resolve(
+ Vector3(0.6, 0.0, 0.0),
+ Vector3.UP,
+ Vector3.RIGHT,
+ [anchor],
+ )
+ assert(not bool(unsnapped["snapped"]))
+
+
+func _validate_canvas_geometry_and_collaboration() -> void:
+ var world := Node3D.new()
+ root.add_child(world)
+ var canvas := SurfaceDrawingCanvas.new()
+ world.add_child(canvas)
+ var state: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "origin": [0.0, 0.0, 0.0],
+ "normal": [0.0, 1.0, 0.0],
+ "tangent": [1.0, 0.0, 0.0],
+ "width": SurfaceDrawingProtocol.GRID_WIDTH,
+ "height": SurfaceDrawingProtocol.GRID_HEIGHT,
+ "cell_size": SurfaceDrawingProtocol.CELL_SIZE,
+ "revision": 0,
+ "creator_fingerprint": FINGERPRINT_A,
+ "cells": [],
+ }
+ assert(canvas.setup(state, null))
+ var first_center: Vector3 = canvas.get_cell_world_position(0, 0)
+ assert(canvas.cell_at_world_point(first_center) == Vector2i(0, 0))
+ assert(canvas.contains_world_point(first_center))
+ var first_update: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "revision": 1,
+ "edits": [{
+ "x": 3,
+ "y": 4,
+ "color_id": "coral",
+ "author_fingerprint": FINGERPRINT_A,
+ }],
+ }
+ assert(canvas.apply_update(first_update))
+ var finish_update: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "revision": 2,
+ "guide_visible": false,
+ "finalized": true,
+ }
+ assert(canvas.apply_guide_update(finish_update))
+ assert(not canvas.is_guide_visible())
+ assert(canvas.is_finalized())
+ await process_frame
+ var finished_pixel_scale: float = canvas.get_rendered_pixel_size()
+ assert(
+ is_equal_approx(
+ finished_pixel_scale, SurfaceDrawingProtocol.CELL_SIZE
+ ),
+ "finished pixel scale %f does not match cell size %f"
+ % [finished_pixel_scale, SurfaceDrawingProtocol.CELL_SIZE],
+ )
+ var second_update: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "revision": 3,
+ "edits": [{
+ "x": 3,
+ "y": 4,
+ "color_id": "blue",
+ "author_fingerprint": FINGERPRINT_B,
+ }],
+ }
+ assert(canvas.apply_update(second_update))
+ var cells: Array[Dictionary] = canvas.get_authoritative_cells()
+ assert(cells.size() == 1)
+ assert(cells[0]["color_id"] == "blue")
+ assert(cells[0]["author_fingerprint"] == FINGERPRINT_B)
+ var erase_update: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "canvas-1",
+ "revision": 4,
+ "edits": [{
+ "x": 3,
+ "y": 4,
+ "color_id": "",
+ "author_fingerprint": "",
+ }],
+ }
+ assert(canvas.apply_update(erase_update))
+ assert(canvas.get_authoritative_cells().is_empty())
+ world.queue_free()
+ await process_frame
+
+
+func _validate_peer_capability_tracking() -> void:
+ var registry := PeerRegistry.new()
+ assert(registry.add_peer(
+ 1,
+ "profile",
+ "Voyager",
+ NetworkProtocol.PROTOCOL_VERSION,
+ FINGERPRINT_A,
+ "public key fixture",
+ PackedStringArray([str(SurfaceDrawingProtocol.CAPABILITY)]),
+ ))
+ var record: PeerRegistry.PeerRecord = registry.get_peer(1)
+ assert(record != null)
+ assert(str(SurfaceDrawingProtocol.CAPABILITY) in record.capability_flags)
+
+
+func _validate_blocked_author_visibility() -> void:
+ var relationships := PlayerRelationshipStore.new()
+ relationships.set("_loaded", true)
+ relationships.set("_records", {
+ FINGERPRINT_B: {"blocked": true, "muted": true},
+ })
+ var world := Node3D.new()
+ root.add_child(world)
+ world.add_child(relationships)
+ var canvas := SurfaceDrawingCanvas.new()
+ world.add_child(canvas)
+ var state: Dictionary = {
+ "session_id": "session",
+ "canvas_id": "blocked-author-canvas",
+ "origin": [0.0, 0.0, 0.0],
+ "normal": [0.0, 1.0, 0.0],
+ "tangent": [1.0, 0.0, 0.0],
+ "width": SurfaceDrawingProtocol.GRID_WIDTH,
+ "height": SurfaceDrawingProtocol.GRID_HEIGHT,
+ "cell_size": SurfaceDrawingProtocol.CELL_SIZE,
+ "revision": 1,
+ "creator_fingerprint": FINGERPRINT_A,
+ "cells": [
+ {
+ "x": 1,
+ "y": 1,
+ "color_id": "coral",
+ "author_fingerprint": FINGERPRINT_A,
+ },
+ {
+ "x": 2,
+ "y": 1,
+ "color_id": "blue",
+ "author_fingerprint": FINGERPRINT_B,
+ },
+ ],
+ }
+ assert(canvas.setup(state, relationships))
+ assert(_rendered_pixel_count(canvas) == 1)
+ relationships.set("_records", {})
+ canvas.refresh_relationship_visibility()
+ await process_frame
+ assert(_rendered_pixel_count(canvas) == 2)
+ world.queue_free()
+ await process_frame
+
+
+func _rendered_pixel_count(canvas: SurfaceDrawingCanvas) -> int:
+ var count: int = 0
+ for child: Node in canvas.get_children():
+ if child is MultiMeshInstance3D:
+ var instance := child as MultiMeshInstance3D
+ if instance.multimesh != null:
+ count += instance.multimesh.instance_count
+ return count
diff --git a/tests/surface_drawing_validation.gd.uid b/tests/surface_drawing_validation.gd.uid
new file mode 100644
index 0000000..72e0102
--- /dev/null
+++ b/tests/surface_drawing_validation.gd.uid
@@ -0,0 +1 @@
+uid://dui5ueufqooul
diff --git a/tests/terrain_blender_material_validation.gd b/tests/terrain_blender_material_validation.gd
new file mode 100644
index 0000000..fe3de22
--- /dev/null
+++ b/tests/terrain_blender_material_validation.gd
@@ -0,0 +1,190 @@
+extends SceneTree
+
+const REGION := preload("res://world/regions/starter_island_region.tscn")
+const TERRAIN_MODEL_PATH: String = (
+ "res://art/exported/environment/terrain/starter_island.glb"
+)
+const EXPECTED_SURFACES: Array[String] = [
+ "grass_lite",
+ "dirt",
+ "cliff_wall",
+ "dirt_wall",
+ "sand",
+ "sandstone_heavy",
+]
+
+var failures: Array[String] = []
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var model_scene := load(TERRAIN_MODEL_PATH) as PackedScene
+ _check(model_scene != null, "Starter-island GLB must load as a scene.")
+ if model_scene == null:
+ _finish()
+ return
+
+ var model_root := model_scene.instantiate()
+ var model_terrain := _find_mesh(model_root, "starter_island")
+ _check(model_terrain != null, "GLB must contain the starter_island mesh.")
+ _check(_find_mesh(model_root, "Cube") == null, "GLB must not contain Cube.")
+ if model_terrain != null:
+ _validate_terrain_materials(model_terrain, "GLB")
+ model_root.free()
+
+ var region := REGION.instantiate()
+ root.add_child(region)
+ await process_frame
+ var runtime_terrain := region.get_node_or_null(
+ "Terrain/Visual/starter_island"
+ ) as MeshInstance3D
+ _check(runtime_terrain != null, "Region must contain the terrain mesh.")
+ if runtime_terrain != null:
+ _validate_terrain_materials(runtime_terrain, "Region")
+ for surface_index: int in runtime_terrain.mesh.get_surface_count():
+ _check(
+ runtime_terrain.get_surface_override_material(surface_index) == null,
+ "Region surface %d must use its Blender material." % surface_index,
+ )
+ _validate_region_collision(region)
+ region.free()
+ _finish()
+
+
+func _find_mesh(node: Node, mesh_name: String) -> MeshInstance3D:
+ if node is MeshInstance3D and node.name == mesh_name:
+ return node as MeshInstance3D
+ for child: Node in node.get_children():
+ var match := _find_mesh(child, mesh_name)
+ if match != null:
+ return match
+ return null
+
+
+func _validate_terrain_materials(
+ terrain: MeshInstance3D,
+ context: String,
+) -> void:
+ _check(terrain.mesh != null, "%s terrain must have a mesh." % context)
+ if terrain.mesh == null:
+ return
+ _check(
+ terrain.mesh.get_surface_count() == EXPECTED_SURFACES.size(),
+ "%s terrain must have %d surfaces." % [
+ context,
+ EXPECTED_SURFACES.size(),
+ ],
+ )
+ var checked_count := mini(
+ terrain.mesh.get_surface_count(),
+ EXPECTED_SURFACES.size(),
+ )
+ for surface_index: int in checked_count:
+ var expected_name := EXPECTED_SURFACES[surface_index]
+ _check(
+ terrain.mesh.surface_get_name(surface_index) == expected_name,
+ "%s surface %d must be named %s." % [
+ context,
+ surface_index,
+ expected_name,
+ ],
+ )
+ var material := terrain.get_active_material(surface_index)
+ _check(
+ material is StandardMaterial3D,
+ "%s surface %s must use a Blender material." % [
+ context,
+ expected_name,
+ ],
+ )
+ if material is StandardMaterial3D:
+ var texture := (material as StandardMaterial3D).albedo_texture
+ _check(
+ texture != null,
+ "%s surface %s must have an embedded texture." % [
+ context,
+ expected_name,
+ ],
+ )
+ if texture != null:
+ _check(
+ texture.resource_path.begins_with(TERRAIN_MODEL_PATH + "::"),
+ "%s surface %s must source its texture from the GLB." % [
+ context,
+ expected_name,
+ ],
+ )
+
+
+func _validate_region_collision(region: StarterIslandRegion) -> void:
+ _check(
+ region.rebuild_terrain_collision_on_ready,
+ "Starter-island collision must rebuild from the imported GLB.",
+ )
+ var visual_root := region.get_node_or_null("Terrain/Visual") as Node3D
+ var collision_shape := region.get_node_or_null(
+ "Terrain/Collision/Shape"
+ ) as CollisionShape3D
+ _check(visual_root != null, "Region must contain its visual hierarchy.")
+ _check(collision_shape != null, "Region must contain its collision owner.")
+ if visual_root == null or collision_shape == null:
+ return
+ var concave_shape := collision_shape.shape as ConcavePolygonShape3D
+ _check(
+ concave_shape != null,
+ "Region collision must be a generated concave polygon shape.",
+ )
+ if concave_shape == null:
+ return
+ var expected_faces := PackedVector3Array()
+ var mesh_count := _append_collision_faces(
+ region,
+ visual_root,
+ expected_faces,
+ )
+ var actual_faces := concave_shape.get_faces()
+ _check(mesh_count > 1, "Collision must include the imported prop meshes.")
+ _check(not expected_faces.is_empty(), "Imported meshes must provide collision.")
+ _check(
+ actual_faces == expected_faces,
+ "Generated collision must exactly match every imported mesh transform.",
+ )
+
+
+func _append_collision_faces(
+ region: StarterIslandRegion,
+ node: Node,
+ faces: PackedVector3Array,
+) -> int:
+ var mesh_count := 0
+ if node is MeshInstance3D:
+ var mesh_instance := node as MeshInstance3D
+ if mesh_instance.mesh != null:
+ var mesh_shape := mesh_instance.mesh.create_trimesh_shape()
+ if mesh_shape != null:
+ mesh_count += 1
+ for face_vertex: Vector3 in mesh_shape.get_faces():
+ faces.append(
+ region.to_local(mesh_instance.to_global(face_vertex))
+ )
+ for child: Node in node.get_children():
+ mesh_count += _append_collision_faces(region, child, faces)
+ return mesh_count
+
+
+func _check(condition: bool, message: String) -> void:
+ if not condition:
+ failures.append(message)
+
+
+func _finish() -> void:
+ if failures.is_empty():
+ print("Blender terrain and collision validation: PASS")
+ quit(0)
+ return
+ for failure: String in failures:
+ printerr("Blender terrain and collision validation: ", failure)
+ quit(1)
diff --git a/tests/terrain_blender_material_validation.gd.uid b/tests/terrain_blender_material_validation.gd.uid
new file mode 100644
index 0000000..ad92fbb
--- /dev/null
+++ b/tests/terrain_blender_material_validation.gd.uid
@@ -0,0 +1 @@
+uid://cto4rc6bn0n4o
diff --git a/tests/texture_sampling_validation.gd b/tests/texture_sampling_validation.gd
new file mode 100644
index 0000000..deeabce
--- /dev/null
+++ b/tests/texture_sampling_validation.gd
@@ -0,0 +1,148 @@
+extends SceneTree
+
+const POLICY := preload("res://main/texture_sampling_policy.gd")
+
+var failures: Array[String] = []
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_runtime_policy()
+ _validate_repository_files("res://")
+ if failures.is_empty():
+ print("Texture sampling validation passed.")
+ quit(0)
+ return
+ for failure: String in failures:
+ printerr("Texture sampling validation: ", failure)
+ quit(1)
+
+
+func _validate_runtime_policy() -> void:
+ var texture_rect := TextureRect.new()
+ texture_rect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
+ POLICY.enforce_node(texture_rect)
+ _check(
+ texture_rect.texture_filter == CanvasItem.TEXTURE_FILTER_NEAREST,
+ "CanvasItem policy must replace linear sampling with nearest sampling.",
+ )
+ texture_rect.free()
+
+ var sprite := Sprite3D.new()
+ sprite.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
+ POLICY.enforce_node(sprite)
+ _check(
+ sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST,
+ "Sprite3D policy must replace linear sampling with nearest sampling.",
+ )
+ sprite.free()
+
+ var material := StandardMaterial3D.new()
+ material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
+ var quad := QuadMesh.new()
+ quad.material = material
+ var mesh_instance := MeshInstance3D.new()
+ mesh_instance.mesh = quad
+ POLICY.enforce_node(mesh_instance)
+ _check(
+ material.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST,
+ "3D material policy must replace linear sampling with nearest sampling.",
+ )
+ mesh_instance.free()
+
+
+func _validate_repository_files(path: String) -> void:
+ var directory := DirAccess.open(path)
+ if directory == null:
+ failures.append("Could not scan %s." % path)
+ return
+ directory.list_dir_begin()
+ while true:
+ var name := directory.get_next()
+ if name.is_empty():
+ break
+ if name == ".godot" or name == ".git":
+ continue
+ var child_path := path.path_join(name)
+ if directory.current_is_dir():
+ _validate_repository_files(child_path)
+ continue
+ if name.ends_with(".import"):
+ _validate_import_file(child_path)
+ elif name.ends_with(".gdshader"):
+ _validate_shader_file(child_path)
+ elif name.ends_with(".tscn") or name.ends_with(".tres"):
+ _validate_serialized_resource(child_path)
+ elif name.ends_with(".gd") and child_path != get_script().resource_path:
+ _validate_script_file(child_path)
+ directory.list_dir_end()
+
+
+func _validate_import_file(path: String) -> void:
+ var source := FileAccess.get_file_as_string(path)
+ _check(
+ "mipmaps/generate=true" not in source,
+ "Texture import generates mipmaps: %s" % path,
+ )
+
+
+func _validate_shader_file(path: String) -> void:
+ var source := FileAccess.get_file_as_string(path)
+ var declaration_start := source.find("uniform sampler")
+ while declaration_start >= 0:
+ var declaration_end := source.find(";", declaration_start)
+ if declaration_end < 0:
+ _check(false, "Incomplete sampler declaration in %s." % path)
+ return
+ var declaration := source.substr(
+ declaration_start,
+ declaration_end - declaration_start,
+ )
+ _check(
+ "filter_nearest" in declaration,
+ "Shader sampler must declare filter_nearest: %s" % path,
+ )
+ declaration_start = source.find("uniform sampler", declaration_end + 1)
+
+
+func _validate_serialized_resource(path: String) -> void:
+ var source := FileAccess.get_file_as_string(path)
+ var uses_3d_filter_enum := false
+ for line: String in source.split("\n"):
+ if line.begins_with("["):
+ uses_3d_filter_enum = (
+ ("type=\"Sprite3D\"" in line)
+ or ("type=\"AnimatedSprite3D\"" in line)
+ or ("type=\"StandardMaterial3D\"" in line)
+ or ("type=\"ORMMaterial3D\"" in line)
+ )
+ if not line.begins_with("texture_filter = "):
+ continue
+ var value := int(line.trim_prefix("texture_filter = "))
+ if uses_3d_filter_enum:
+ _check(
+ value == BaseMaterial3D.TEXTURE_FILTER_NEAREST,
+ "3D resource declares non-nearest filtering: %s" % path,
+ )
+ else:
+ _check(
+ value == CanvasItem.TEXTURE_FILTER_PARENT_NODE
+ or value == CanvasItem.TEXTURE_FILTER_NEAREST,
+ "Canvas resource declares non-nearest filtering: %s" % path,
+ )
+
+
+func _validate_script_file(path: String) -> void:
+ var source := FileAccess.get_file_as_string(path)
+ _check(
+ "TEXTURE_FILTER_LINEAR" not in source,
+ "Script explicitly requests linear texture filtering: %s" % path,
+ )
+
+
+func _check(condition: bool, message: String) -> void:
+ if not condition:
+ failures.append(message)
diff --git a/tests/texture_sampling_validation.gd.uid b/tests/texture_sampling_validation.gd.uid
new file mode 100644
index 0000000..9a2f48b
--- /dev/null
+++ b/tests/texture_sampling_validation.gd.uid
@@ -0,0 +1 @@
+uid://dqm0j1xnkeox1
diff --git a/tests/title_credits_validation.gd b/tests/title_credits_validation.gd
new file mode 100644
index 0000000..7e50f89
--- /dev/null
+++ b/tests/title_credits_validation.gd
@@ -0,0 +1,207 @@
+extends SceneTree
+
+const TitleScreenScene := preload("res://ui/title_screen.tscn")
+const TitleCreditsPageType = preload("res://ui/title_credits_page.gd")
+
+var _failures: Array[String] = []
+
+
+func _initialize() -> void:
+ _run.call_deferred()
+
+
+func _run() -> void:
+ var original_size: Vector2i = root.size
+ root.size = Vector2i(1280, 720)
+ var title_screen := TitleScreenScene.instantiate() as TitleScreen
+ root.add_child(title_screen)
+ await process_frame
+ await process_frame
+
+ var credits_button := title_screen.get_node(
+ "%CreditsButton"
+ ) as BubbleButton
+ var credits_page := title_screen.get_node(
+ "%CreditsPage"
+ ) as TitleCreditsPageType
+ var back_button: Button = credits_page.get_back_button()
+ var presentation := title_screen.get_node("%Center") as Control
+ var bubble_field := title_screen.get_node("%BubbleField") as Control
+ title_screen.call("_update_title_layout")
+ (title_screen.get_node("%ButtonCenter") as Control).show()
+ bubble_field.show()
+ title_screen.call("_set_title_bubbles_interactive", true)
+ await process_frame
+
+ _expect(credits_button != null, "title menu exposes a credits button")
+ _expect(credits_page != null, "title screen contains the credits page")
+ _expect(not credits_page.visible, "credits page starts closed")
+ _expect(
+ Rect2(Vector2.ZERO, bubble_field.size).encloses(
+ Rect2(credits_button.position, credits_button.size)
+ ),
+ "credits bubble stays inside the authored title field",
+ )
+ _expect(
+ credits_button.mouse_filter == Control.MOUSE_FILTER_STOP,
+ "credits bubble accepts pointer input",
+ )
+ if OS.has_environment("NETFISHING_TITLE_MENU_CAPTURE"):
+ (title_screen.get_node("%StartPromptCenter") as Control).hide()
+ title_screen.set_process(true)
+ await create_timer(0.5).timeout
+ _expect(
+ _save_capture(
+ OS.get_environment("NETFISHING_TITLE_MENU_CAPTURE")
+ ) == OK,
+ "title menu validation capture saves successfully",
+ )
+ title_screen.set_process(false)
+
+ title_screen.set("_navigation_focus_active", false)
+ credits_button.pressed.emit()
+ await process_frame
+ _expect(credits_page.visible, "pointer activation opens credits")
+ _expect(not presentation.visible, "credits replace the primary title menu")
+ _expect(
+ root.gui_get_focus_owner() != back_button,
+ "pointer activation does not pseudo-select back",
+ )
+ await create_timer(0.25).timeout
+ _expect(
+ credits_page.mouse_filter == Control.MOUSE_FILTER_PASS,
+ "credits page becomes pointer-interactive after entry motion",
+ )
+ _expect(
+ back_button.mouse_filter == Control.MOUSE_FILTER_STOP,
+ "credits back button accepts pointer clicks",
+ )
+ if OS.has_environment("NETFISHING_CREDITS_CAPTURE"):
+ await process_frame
+ _expect(
+ _save_capture(
+ OS.get_environment("NETFISHING_CREDITS_CAPTURE")
+ ) == OK,
+ "credits validation capture saves successfully",
+ )
+ back_button.pressed.emit()
+ await process_frame
+ _expect(not credits_page.visible, "pointer back closes credits")
+ _expect(presentation.visible, "closing credits restores the title menu")
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "pointer return leaves the title menu unfocused",
+ )
+
+ title_screen.set("_navigation_focus_active", true)
+ credits_button.grab_focus()
+ credits_button.pressed.emit()
+ await process_frame
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == back_button,
+ "keyboard or controller activation focuses the credits back button",
+ )
+
+ var pointer_motion := InputEventMouseMotion.new()
+ pointer_motion.position = Vector2(640.0, 360.0)
+ title_screen._input(pointer_motion)
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == null,
+ "pointer motion clears credits navigation focus",
+ )
+
+ var navigate := InputEventAction.new()
+ navigate.action = &"ui_down"
+ navigate.pressed = true
+ title_screen._input(navigate)
+ await process_frame
+ _expect(
+ root.gui_get_focus_owner() == back_button,
+ "navigation input restores credits focus",
+ )
+
+ var cancel := InputEventAction.new()
+ cancel.action = &"ui_cancel"
+ cancel.pressed = true
+ title_screen._input(cancel)
+ await process_frame
+ _expect(not credits_page.visible, "ui cancel closes credits")
+ _expect(
+ root.gui_get_focus_owner() == credits_button,
+ "navigation return restores focus to the credits bubble",
+ )
+
+ var chillnfill_name := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits/ChillnfillName"
+ ) as Label
+ var voyager_name := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits/VoyagerName"
+ ) as Label
+ var endeavour_name := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits/EndeavourName"
+ ) as Label
+ var chillnfill_credit := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits/ChillnfillCredit"
+ ) as Label
+ var tekgator_name := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits/TekgatorName"
+ ) as Label
+ var tekgator_credit := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits/TekgatorCredit"
+ ) as Label
+ var creative_credits := credits_page.get_node(
+ "Paper/Margin/Layout/Columns/CreativeCredits"
+ ) as VBoxContainer
+ _expect(voyager_name.text == "Voyager", "in-game credits use Voyager")
+ _expect(
+ endeavour_name.text == "Endeavour",
+ "in-game credits use Endeavour",
+ )
+ _expect(
+ chillnfill_name.text == "chillnfill",
+ "in-game credits name chillnfill",
+ )
+ _expect(
+ "trees and bridges" in chillnfill_credit.text.to_lower(),
+ "in-game credits describe the supplied model families",
+ )
+ _expect(
+ tekgator_name.text == "Tekgator",
+ "in-game credits name Tekgator",
+ )
+ _expect(
+ "shop icon" in tekgator_credit.text.to_lower(),
+ "in-game credits describe Tekgator's shop icon artwork",
+ )
+ _expect(
+ Rect2(Vector2.ZERO, creative_credits.size).encloses(
+ Rect2(tekgator_credit.position, tekgator_credit.size)
+ ),
+ "Tekgator's credit stays inside the authored credits column",
+ )
+
+ title_screen.queue_free()
+ root.size = original_size
+ await process_frame
+ await process_frame
+ if _failures.is_empty():
+ print("Title credits validation: PASS")
+ quit(0)
+ return
+ for failure: String in _failures:
+ push_error(failure)
+ quit(1)
+
+
+func _expect(condition: bool, message: String) -> void:
+ if not condition:
+ _failures.append(message)
+
+
+func _save_capture(path: String) -> Error:
+ var viewport_texture: Texture2D = root.get_texture()
+ if viewport_texture == null:
+ return ERR_UNAVAILABLE
+ return viewport_texture.get_image().save_png(path)
diff --git a/tests/title_credits_validation.gd.uid b/tests/title_credits_validation.gd.uid
new file mode 100644
index 0000000..747e70c
--- /dev/null
+++ b/tests/title_credits_validation.gd.uid
@@ -0,0 +1 @@
+uid://dimq4ehtlw64n
diff --git a/tests/ui_scaling_runtime_validation.gd b/tests/ui_scaling_runtime_validation.gd
new file mode 100644
index 0000000..f7144eb
--- /dev/null
+++ b/tests/ui_scaling_runtime_validation.gd
@@ -0,0 +1,212 @@
+extends SceneTree
+
+const UIPixelationPresenterType = preload(
+ "res://ui/ui_pixelation_presenter.gd"
+)
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+const GameUIScene := preload("res://ui/game_ui.tscn")
+const TARGET_SIZES: Array[Vector2i] = [
+ Vector2i(1280, 720),
+ Vector2i(1920, 1080),
+ Vector2i(2560, 1440),
+ Vector2i(3840, 2160),
+ Vector2i(640, 480),
+ Vector2i(1024, 768),
+ Vector2i(1729, 973),
+ Vector2i(2560, 1080),
+ Vector2i(3440, 1440),
+]
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var original_size: Vector2i = root.size
+ var presenter := UIPixelationPresenterType.new()
+ presenter.name = "UIPresentation"
+ presenter.stretch = true
+ var ui_viewport := SubViewport.new()
+ ui_viewport.name = "UIViewport"
+ var game_ui := GameUIScene.instantiate() as GameUI
+ var ui_root := game_ui.get_node("UIRoot") as Control
+ var canonical_stage := game_ui.get_node(
+ "UIRoot/CanonicalStage"
+ ) as Control
+ var chat_ui := game_ui.get_node("UIRoot/ChatUI") as ChatUI
+ var player_menu := game_ui.get_node(
+ "UIRoot/CanonicalStage/PlayerMenu"
+ ) as PlayerMenu
+ var hotbar := game_ui.get_node(
+ "UIRoot/CanonicalStage/Hotbar"
+ ) as HotbarUI
+ var screen_fade := game_ui.get_node("UIRoot/ScreenFade") as Control
+ var title_screen := game_ui.get_node("UIRoot/TitleScreen") as TitleScreen
+ var title_content_stage := title_screen.get_node(
+ "ResponsiveTitleStage"
+ ) as Control
+ var decorative_fish_layer := title_screen.get_node(
+ "DecorativeFishLayer"
+ ) as Control
+ var decorative_bubble_layer := title_screen.get_node(
+ "DecorativeBubbleLayer"
+ ) as Control
+ ui_viewport.add_child(game_ui)
+ presenter.add_child(ui_viewport)
+ root.add_child(presenter)
+ await process_frame
+ chat_ui.set_available(true)
+ print(
+ "UI viewport embedding: root=%s subviewport=%s"
+ % [root.gui_embed_subwindows, ui_viewport.gui_embed_subwindows]
+ )
+
+ for target_size: Vector2i in TARGET_SIZES:
+ root.size = target_size
+ await process_frame
+ await process_frame
+ var actual_size: Vector2i = root.size
+ var display_size := Vector2(actual_size)
+ var expected_scale: float = UIReferencePresentationType.get_scale(
+ display_size
+ )
+ var expected_stage_offset: Vector2 = UIReferencePresentationType.get_offset(
+ display_size
+ )
+ assert(presenter.position.is_equal_approx(Vector2.ZERO))
+ assert(ui_root.position.is_equal_approx(Vector2.ZERO))
+ var expected_visible_size: Vector2 = (
+ UIReferencePresentationType.get_visible_reference_size(display_size)
+ )
+ assert(ui_root.size.is_equal_approx(expected_visible_size))
+ assert(canonical_stage.size.is_equal_approx(
+ UIReferencePresentationType.REFERENCE_SIZE
+ ))
+ assert(canonical_stage.position.is_equal_approx(
+ UIReferencePresentationType.get_stage_position(display_size)
+ ))
+ assert(chat_ui.size.is_equal_approx(expected_visible_size))
+ assert(screen_fade.size.is_equal_approx(expected_visible_size))
+ assert(player_menu.position.is_equal_approx(Vector2.ZERO))
+ assert(player_menu.size.is_equal_approx(
+ UIReferencePresentationType.REFERENCE_SIZE
+ ))
+ assert(hotbar.position.is_equal_approx(Vector2(
+ 0.0,
+ canonical_stage.position.y,
+ )))
+ assert(hotbar.size.is_equal_approx(
+ UIReferencePresentationType.REFERENCE_SIZE
+ ))
+ assert(title_screen.size.is_equal_approx(expected_visible_size))
+ assert(title_content_stage.size.is_equal_approx(
+ UIReferencePresentationType.REFERENCE_SIZE
+ ))
+ assert(title_content_stage.position.is_equal_approx(
+ canonical_stage.position
+ ))
+ assert(decorative_fish_layer.size.is_equal_approx(
+ expected_visible_size
+ ))
+ assert(decorative_bubble_layer.size.is_equal_approx(
+ expected_visible_size
+ ))
+ var left_to_right_bounds: Vector2 = (
+ TitleScreen.get_decorative_fish_crossing_bounds(
+ expected_visible_size.x, 160.0, 1.0
+ )
+ )
+ var right_to_left_bounds: Vector2 = (
+ TitleScreen.get_decorative_fish_crossing_bounds(
+ expected_visible_size.x, 160.0, -1.0
+ )
+ )
+ assert(left_to_right_bounds.x < -160.0)
+ assert(left_to_right_bounds.y > expected_visible_size.x)
+ assert(right_to_left_bounds.x > expected_visible_size.x)
+ assert(right_to_left_bounds.y < -160.0)
+ assert(is_equal_approx(presenter.scale.x, presenter.scale.y))
+ assert(is_equal_approx(ui_root.scale.x, ui_root.scale.y))
+ var effective_scale: float = presenter.scale.x * ui_root.scale.x
+ assert(is_equal_approx(effective_scale, expected_scale))
+ assert((
+ canonical_stage.position * effective_scale
+ ).distance_to(expected_stage_offset) <= 0.01)
+ var expected_render_height: int = mini(
+ roundi(UIReferencePresentationType.REFERENCE_SIZE.y * expected_scale),
+ PlayerSettings.get_ui_render_height(
+ PlayerSettings.DEFAULT_UI_PIXEL_SIZE,
+ roundi(UIReferencePresentationType.REFERENCE_SIZE.y),
+ ),
+ )
+ var expected_render_scale: float = (
+ float(expected_render_height)
+ / UIReferencePresentationType.REFERENCE_SIZE.y
+ )
+ var expected_viewport_size := Vector2i(
+ roundi(expected_visible_size.x * expected_render_scale),
+ roundi(expected_visible_size.y * expected_render_scale),
+ )
+ assert(presenter.get_ui_viewport_size() == expected_viewport_size)
+ assert(presenter.size.is_equal_approx(Vector2(expected_viewport_size)))
+ var chat_panel := chat_ui.get_node("ChatPanel") as Control
+ var clock_panel := chat_ui.get_node("WorldClockPanel") as Control
+ var weather_icon := chat_ui.get_node("WorldWeatherIcon") as Control
+ assert(chat_panel != null)
+ assert(clock_panel != null)
+ assert(weather_icon != null)
+ assert(chat_panel.visible)
+ assert(is_equal_approx(chat_panel.position.x, 0.0))
+ assert(is_equal_approx(
+ (chat_panel.position.y + chat_panel.size.y) * effective_scale,
+ display_size.y - ChatUI.BOTTOM_MARGIN * effective_scale,
+ ))
+ assert(clock_panel.position.is_equal_approx(Vector2(
+ ChatUI.CLOCK_EDGE_MARGIN,
+ ChatUI.CLOCK_EDGE_MARGIN,
+ )))
+ assert(is_equal_approx(
+ weather_icon.position.y, ChatUI.CLOCK_EDGE_MARGIN
+ ))
+ assert(weather_icon.position.x > clock_panel.position.x)
+ print(
+ (
+ "UI resize: requested=%s actual=%s viewport=%s container_scale=%.6f "
+ + "root_scale=%.6f effective_scale=%.6f stage_offset=%s "
+ + "chat_x=%.3f"
+ )
+ % [
+ target_size,
+ actual_size,
+ presenter.get_ui_viewport_size(),
+ presenter.scale.x,
+ ui_root.scale.x,
+ effective_scale,
+ expected_stage_offset,
+ chat_panel.position.x * effective_scale,
+ ]
+ )
+ if OS.has_environment("NETFISHING_UI_SCALE_CAPTURE"):
+ await process_frame
+ var capture: Image = root.get_texture().get_image()
+ var capture_path: String = (
+ "%s-requested-%dx%d-actual-%dx%d.png"
+ ) % [
+ OS.get_environment("NETFISHING_UI_SCALE_CAPTURE"),
+ target_size.x,
+ target_size.y,
+ actual_size.x,
+ actual_size.y,
+ ]
+ assert(capture.save_png(capture_path) == OK)
+
+ presenter.queue_free()
+ root.size = original_size
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ print("UI scaling runtime validation: PASS")
+ quit()
diff --git a/tests/ui_scaling_runtime_validation.gd.uid b/tests/ui_scaling_runtime_validation.gd.uid
new file mode 100644
index 0000000..0b6079a
--- /dev/null
+++ b/tests/ui_scaling_runtime_validation.gd.uid
@@ -0,0 +1 @@
+uid://d1x8t4mf2kqv6
diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd
new file mode 100644
index 0000000..cebb0eb
--- /dev/null
+++ b/tests/world_time_multiplayer_validation.gd
@@ -0,0 +1,246 @@
+extends SceneTree
+
+const MainScene = preload("res://main/main.tscn")
+const TEST_PORT: int = 17983
+const INITIAL_HOST_TIME: float = 19.75
+const UPDATED_HOST_TIME: float = WorldTimeService.DUSK_END_HOUR
+const TIME_TOLERANCE_HOURS: float = 0.05
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ var arguments: PackedStringArray = OS.get_cmdline_user_args()
+ if arguments.has("host"):
+ await _run_host()
+ return
+ if arguments.has("client"):
+ await _run_client()
+ return
+ push_error("World time multiplayer validation needs host or client mode.")
+ quit(1)
+
+
+func _run_host() -> void:
+ var main: Node = await _create_initialized_main()
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var world_time := main.get_node("%WorldTimeService") as WorldTimeService
+ var world_weather := (
+ main.get_node("%WorldWeatherService") as WorldWeatherService
+ )
+ assert(session.start_private_host(TEST_PORT))
+ var game_ui := main.get_node("%GameUI") as CanvasLayer
+ var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
+ assert(chat_ui.call("_handle_chat_command", "/weather clear"))
+ assert(world_weather.get_weather() == WorldWeatherService.Weather.SUNNY)
+ world_time.synchronize_time(INITIAL_HOST_TIME)
+ assert(is_equal_approx(
+ world_time.get_persistent_time_hours(), INITIAL_HOST_TIME
+ ))
+ assert(chat_ui.call("_handle_chat_command", "/weather rainy"))
+ assert(
+ world_weather.get_persistent_weather()
+ == WorldWeatherService.Weather.RAINY
+ )
+ assert(session.set_host_open(true))
+
+ var remote_peer_id: int = 0
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0:
+ await process_frame
+ for peer_id: int in session.get_authenticated_peer_ids():
+ if peer_id != session.get_local_peer_id():
+ remote_peer_id = peer_id
+ break
+ assert(remote_peer_id > 1)
+ assert(session.peer_supports_capability(
+ remote_peer_id, NetworkProtocol.WORLD_TIME_CAPABILITY
+ ))
+ assert(session.peer_supports_capability(
+ remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ ))
+ var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
+ remote_peer_id
+ )
+ assert(remote_record != null and remote_record.identity_authenticated)
+ assert(session.set_peer_operator(
+ remote_peer_id,
+ remote_record.identity_fingerprint,
+ true,
+ ))
+ assert(world_time.get_phase() == WorldTimeService.Phase.DUSK)
+
+ var command_deadline: int = Time.get_ticks_msec() + 10000
+ while (
+ Time.get_ticks_msec() < command_deadline
+ and _wrapped_time_difference(
+ world_time.get_time_hours(), UPDATED_HOST_TIME
+ ) > TIME_TOLERANCE_HOURS
+ ):
+ await process_frame
+ assert(is_equal_approx(
+ world_time.get_persistent_time_hours(), UPDATED_HOST_TIME
+ ))
+ var fog_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < fog_deadline and not world_weather.is_foggy():
+ await process_frame
+ assert(
+ world_weather.get_persistent_weather()
+ == WorldWeatherService.Weather.FOGGY
+ )
+ var disconnect_deadline: int = Time.get_ticks_msec() + 12000
+ while (
+ Time.get_ticks_msec() < disconnect_deadline
+ and session.is_authenticated_peer(remote_peer_id)
+ ):
+ await process_frame
+ assert(not session.is_authenticated_peer(remote_peer_id))
+ print("World time multiplayer host validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _run_client() -> void:
+ var main: Node = await _create_initialized_main()
+ main.call(
+ "_on_title_join_game_requested",
+ "127.0.0.1:%d" % TEST_PORT,
+ )
+ var session := main.get_node("%NetworkSession") as NetworkSession
+ var world_time := main.get_node("%WorldTimeService") as WorldTimeService
+ var world_weather := (
+ main.get_node("%WorldWeatherService") as WorldWeatherService
+ )
+ var join_deadline: int = Time.get_ticks_msec() + 20000
+ while Time.get_ticks_msec() < join_deadline:
+ await process_frame
+ if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
+ main.call("_confirm_server_trust")
+ if session.is_joined_client() and bool(main.get("_gameplay_started")):
+ break
+ assert(session.is_joined_client())
+ assert(session.supports_server_capability(
+ NetworkProtocol.WORLD_TIME_CAPABILITY
+ ))
+ assert(session.supports_server_capability(
+ NetworkProtocol.WORLD_WEATHER_CAPABILITY
+ ))
+ assert(world_time.restore_persistent_time_hours(15.25))
+ assert(world_weather.restore_persistent_state(
+ WorldWeatherService.Weather.CLOUDY,
+ 222.0,
+ ))
+
+ var initial_deadline: int = Time.get_ticks_msec() + 8000
+ while (
+ Time.get_ticks_msec() < initial_deadline
+ and _wrapped_time_difference(
+ world_time.get_time_hours(), INITIAL_HOST_TIME
+ ) > TIME_TOLERANCE_HOURS
+ ):
+ await process_frame
+ assert(_wrapped_time_difference(
+ world_time.get_time_hours(), INITIAL_HOST_TIME
+ ) <= TIME_TOLERANCE_HOURS)
+ assert(world_time.get_phase() == WorldTimeService.Phase.DUSK)
+ var weather_deadline: int = Time.get_ticks_msec() + 8000
+ while (
+ Time.get_ticks_msec() < weather_deadline
+ and not world_weather.is_raining()
+ ):
+ await process_frame
+ assert(world_weather.is_raining())
+ var game_ui := main.get_node("%GameUI") as CanvasLayer
+ var chat_ui := game_ui.get_node("%ChatUI") as Control
+ var clock_panel := chat_ui.get_node("WorldClockPanel") as PanelContainer
+ var clock_label := clock_panel.get_node("WorldClockLabel") as Label
+ var weather_icon := chat_ui.get_node("WorldWeatherIcon") as WeatherIcon
+ var chat_panel := chat_ui.get_node("ChatPanel") as PanelContainer
+ assert(clock_panel.visible)
+ assert(clock_label.text == world_time.get_clock_text())
+ assert(clock_label.text.ends_with(" pm"))
+ assert(weather_icon.visible)
+ assert(weather_icon.get_weather() == WorldWeatherService.Weather.RAINY)
+ assert(is_equal_approx(clock_panel.position.y, 10.0))
+ assert(is_equal_approx(weather_icon.position.y, 10.0))
+ assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y)
+ var operator_deadline: int = Time.get_ticks_msec() + 10000
+ while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
+ await process_frame
+ assert(session.is_local_operator())
+ assert(chat_ui.call("_handle_chat_command", "/time night"))
+
+ var update_deadline: int = Time.get_ticks_msec() + 10000
+ while (
+ Time.get_ticks_msec() < update_deadline
+ and _wrapped_time_difference(
+ world_time.get_time_hours(), UPDATED_HOST_TIME
+ ) > TIME_TOLERANCE_HOURS
+ ):
+ await process_frame
+ assert(_wrapped_time_difference(
+ world_time.get_time_hours(), UPDATED_HOST_TIME
+ ) <= TIME_TOLERANCE_HOURS)
+ assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT)
+ assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25))
+ assert(clock_label.text == world_time.get_clock_text())
+ await create_timer(0.6).timeout
+ assert(chat_ui.call("_handle_chat_command", "/weather foggy"))
+ var fog_deadline: int = Time.get_ticks_msec() + 8000
+ while (
+ Time.get_ticks_msec() < fog_deadline
+ and not world_weather.is_foggy()
+ ):
+ await process_frame
+ assert(world_weather.is_foggy())
+ assert(weather_icon.get_weather() == WorldWeatherService.Weather.FOGGY)
+ assert(
+ world_weather.get_persistent_weather()
+ == WorldWeatherService.Weather.CLOUDY
+ )
+ assert(is_equal_approx(
+ world_weather.get_persistent_seconds_remaining(),
+ 222.0,
+ ))
+ chat_ui.call("set_dock_right", true)
+ await process_frame
+ assert(clock_panel.position.x > 1000.0)
+ assert(weather_icon.position.x < clock_panel.position.x)
+ assert(is_equal_approx(clock_panel.position.y, 10.0))
+ chat_ui.call("set_dock_right", false)
+ await process_frame
+ assert(clock_panel.position.x < 20.0)
+ assert(weather_icon.position.x > clock_panel.position.x)
+ assert(is_equal_approx(clock_panel.position.y, 10.0))
+ print("World time multiplayer client validation: PASS")
+ session.disconnect_session("")
+ main.queue_free()
+ for _frame: int in 4:
+ await process_frame
+ await create_timer(0.1).timeout
+ quit()
+
+
+func _create_initialized_main() -> Node:
+ root.size = Vector2i(1280, 720)
+ var main := MainScene.instantiate()
+ root.add_child(main)
+ for _frame: int in 4:
+ await process_frame
+ if not bool(main.get("_application_initialized")):
+ main.call("_activate_selected_data_path", "", true)
+ for _frame: int in 8:
+ await process_frame
+ assert(bool(main.get("_application_initialized")))
+ return main
+
+
+func _wrapped_time_difference(left: float, right: float) -> float:
+ var direct: float = absf(left - right)
+ return minf(direct, WorldTimeService.HOURS_PER_DAY - direct)
diff --git a/tests/world_time_multiplayer_validation.gd.uid b/tests/world_time_multiplayer_validation.gd.uid
new file mode 100644
index 0000000..3e17cd7
--- /dev/null
+++ b/tests/world_time_multiplayer_validation.gd.uid
@@ -0,0 +1 @@
+uid://6fal0btp6sgr
diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd
new file mode 100644
index 0000000..b043718
--- /dev/null
+++ b/tests/world_time_validation.gd
@@ -0,0 +1,444 @@
+extends SceneTree
+
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldTimeVisualControllerType = preload(
+ "res://world/world_time_visual_controller.gd"
+)
+const NetworkWorldTimeServiceType = preload(
+ "res://network/network_world_time_service.gd"
+)
+const FishingContextType = preload("res://fishing/fishing_context.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+const FishableWaterRegionType = preload(
+ "res://world/fishable_water_region.gd"
+)
+const WeatherIconType = preload("res://ui/weather_icon.gd")
+
+const Catalog: FishPoolType = preload("res://fish/pools/fish_catalog.tres")
+const SaltWaterMaterial: ShaderMaterial = preload(
+ "res://world/materials/stylized_water.tres"
+)
+const FreshWaterMaterial: ShaderMaterial = preload(
+ "res://world/materials/stylized_water_fresh.tres"
+)
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_clock_boundaries_and_duration()
+ _validate_persistent_host_clock()
+ _validate_fishing_availability()
+ _validate_fishing_spot_context()
+ _validate_network_snapshot_bounds()
+ _validate_environment_presentation()
+ _validate_weather_clock_icon()
+ print("World time validation: PASS")
+ quit()
+
+
+func _validate_clock_boundaries_and_duration() -> void:
+ assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 3600.0)
+ assert(
+ WorldTimeServiceType.phase_for_hour(7.49)
+ == WorldTimeServiceType.Phase.NIGHT
+ )
+ assert(
+ WorldTimeServiceType.phase_for_hour(7.5)
+ == WorldTimeServiceType.Phase.DAWN
+ )
+ assert(
+ WorldTimeServiceType.phase_for_hour(8.49)
+ == WorldTimeServiceType.Phase.DAWN
+ )
+ assert(
+ WorldTimeServiceType.phase_for_hour(8.5)
+ == WorldTimeServiceType.Phase.DAY
+ )
+ assert(
+ WorldTimeServiceType.phase_for_hour(19.5)
+ == WorldTimeServiceType.Phase.DUSK
+ )
+ assert(
+ WorldTimeServiceType.phase_for_hour(20.5)
+ == WorldTimeServiceType.Phase.NIGHT
+ )
+ assert(WorldTimeServiceType.format_clock_time(0.0) == "12:00 am")
+ assert(WorldTimeServiceType.format_clock_time(8.0) == "8:00 am")
+ assert(WorldTimeServiceType.format_clock_time(20.5) == "8:30 pm")
+
+ var clock := WorldTimeServiceType.new()
+ root.add_child(clock)
+ clock.begin_session(8.0)
+ clock.advance_time(1800.0)
+ assert(is_equal_approx(clock.get_time_hours(), 20.0))
+ assert(clock.is_night_period())
+ assert(clock.is_transition())
+ clock.advance_time(1800.0)
+ assert(is_equal_approx(clock.get_time_hours(), 8.0))
+ assert(not clock.is_night_period())
+ assert(clock.is_transition())
+
+ # Small high-refresh deltas must still advance the displayed minute. Using
+ # is_equal_approx() per frame used to suppress every clock notification.
+ var emitted_times: Array[float] = []
+ clock.time_changed.connect(
+ func(time_hours: float, _phase: WorldTimeService.Phase) -> void:
+ emitted_times.append(time_hours)
+ )
+ clock.begin_session(12.0 + 31.0 / 60.0)
+ emitted_times.clear()
+ for _frame: int in 1201:
+ clock.advance_time(1.0 / 240.0)
+ assert(clock.get_clock_text() == "12:33 pm")
+ assert(emitted_times.size() >= 2)
+ clock.queue_free()
+
+
+func _validate_persistent_host_clock() -> void:
+ var clock := WorldTimeServiceType.new()
+ root.add_child(clock)
+ assert(clock.restore_persistent_time_hours(18.75))
+ clock.set_persistence_tracking_enabled(true)
+ clock.begin_session(clock.get_persistent_time_hours())
+ assert(is_equal_approx(clock.get_time_hours(), 18.75))
+ clock.synchronize_time(19.25)
+ assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25))
+ clock.set_persistence_tracking_enabled(false)
+ clock.synchronize_time(6.5)
+ assert(is_equal_approx(clock.get_time_hours(), 6.5))
+ assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25))
+ assert(not clock.restore_persistent_time_hours(-1.0))
+ assert(not clock.restore_persistent_time_hours(24.0))
+ clock.queue_free()
+
+
+func _validate_fishing_availability() -> void:
+ var day_context := FishingContextType.new()
+ day_context.location_tags = [&"starter_pond"]
+ day_context.is_night = false
+ var night_context := FishingContextType.new()
+ night_context.location_tags = [&"starter_pond"]
+ night_context.is_night = true
+ var transition_context := FishingContextType.new()
+ transition_context.location_tags = [&"starter_pond"]
+ transition_context.is_night = true
+ transition_context.is_day_night_transition = true
+
+ for fish_id: StringName in [&"bluegill", &"sunfish"]:
+ var day_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(day_fish.availability.is_available(day_context))
+ assert(not day_fish.availability.is_available(night_context))
+ assert(day_fish.availability.is_available(transition_context))
+ for fish_id: StringName in [
+ &"catfish_blue",
+ &"catfish_channel",
+ &"catfish_flathead",
+ &"catfish_white",
+ ]:
+ var night_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(not night_fish.availability.is_available(day_context))
+ assert(night_fish.availability.is_available(night_context))
+ assert(night_fish.availability.is_available(transition_context))
+ for fish_id: StringName in [
+ &"bass",
+ &"carp",
+ &"tuna_albacore",
+ &"tuna_bigeye",
+ &"tuna_bluefin",
+ &"tuna_skipjack",
+ &"tuna_yellowfin",
+ &"goby_round",
+ &"salmon_atlantic",
+ &"salmon_chum",
+ &"salmon_coho",
+ &"salmon_pink",
+ &"salmon_sockeye",
+ ]:
+ var all_time_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
+ assert(all_time_fish.availability.is_available(day_context))
+ assert(all_time_fish.availability.is_available(night_context))
+ assert(all_time_fish.availability.is_available(transition_context))
+
+
+func _validate_fishing_spot_context() -> void:
+ var clock := WorldTimeServiceType.new()
+ clock.begin_session(20.25)
+ var fishing_spot := FishingSpotType.new()
+ fishing_spot.set("_world_time", clock)
+ var region := FishableWaterRegionType.new()
+ region.location_tags = [&"starter_pond"]
+ region.water_type = WaterType.Type.FRESH_WATER
+ var dusk_context: FishingContext = fishing_spot.build_network_context(region)
+ assert(dusk_context.is_night)
+ assert(dusk_context.is_day_night_transition)
+ clock.synchronize_time(14.0)
+ var day_context: FishingContext = fishing_spot.build_network_context(region)
+ assert(not day_context.is_night)
+ assert(not day_context.is_day_night_transition)
+ region.free()
+ fishing_spot.free()
+ clock.free()
+
+
+func _validate_network_snapshot_bounds() -> void:
+ assert(NetworkWorldTimeServiceType.validate_snapshot({
+ "session_id": "session",
+ "time_hours": 8.25,
+ "sequence": 1,
+ }))
+ assert(not NetworkWorldTimeServiceType.validate_snapshot({
+ "session_id": "session",
+ "time_hours": 24.0,
+ "sequence": 1,
+ }))
+ assert(not NetworkWorldTimeServiceType.validate_snapshot({
+ "session_id": "",
+ "time_hours": 8.0,
+ "sequence": 1,
+ }))
+
+
+func _validate_environment_presentation() -> void:
+ var world_root := Node3D.new()
+ root.add_child(world_root)
+ var world_environment := WorldEnvironment.new()
+ var environment := Environment.new()
+ var sky := Sky.new()
+ var sky_material := ShaderMaterial.new()
+ sky_material.shader = preload(
+ "res://world/environment/netfishing_sky.gdshader"
+ )
+ sky.sky_material = sky_material
+ environment.sky = sky
+ environment.adjustment_enabled = true
+ environment.fog_enabled = true
+ world_environment.environment = environment
+ world_root.add_child(world_environment)
+ var sun := DirectionalLight3D.new()
+ world_root.add_child(sun)
+ var clock := WorldTimeServiceType.new()
+ world_root.add_child(clock)
+ var visuals := WorldTimeVisualControllerType.new()
+ world_root.add_child(visuals)
+ visuals.setup(clock, world_environment, sun)
+ visuals.apply_time_immediately(12.0)
+ var runtime_environment: Environment = world_environment.environment
+ var runtime_sky_material := (
+ runtime_environment.sky.sky_material as ShaderMaterial
+ )
+ var day_horizon_value: Variant = runtime_sky_material.get_shader_parameter(
+ "sky_horizon_color"
+ )
+ assert(day_horizon_value is Color)
+ var day_horizon: Color = day_horizon_value as Color
+ var day_sun_visibility: float = float(
+ runtime_sky_material.get_shader_parameter("sun_visibility")
+ )
+ var day_sun_direction: Vector3 = (
+ runtime_sky_material.get_shader_parameter("sun_direction") as Vector3
+ )
+ assert(day_sun_visibility > 0.99)
+ assert(day_sun_direction.y > 0.85)
+ assert(
+ float(runtime_sky_material.get_shader_parameter("moon_visibility"))
+ < 0.01
+ )
+ var day_ambient_energy: float = runtime_environment.ambient_light_energy
+ assert(not runtime_environment.fog_enabled)
+ assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.35))
+ var water_shader: Shader = preload(
+ "res://world/materials/stylized_water.gdshader"
+ )
+ var sky_shader: Shader = preload(
+ "res://world/environment/netfishing_sky.gdshader"
+ )
+ assert("uniform float fog_horizon_occlusion" in sky_shader.code)
+ assert("fog_horizon_color.rgb" in sky_shader.code)
+ assert("fog_disabled" in water_shader.code)
+ assert("surface_view_position = view_vertex.xyz" in water_shader.code)
+ assert("surface_view_distance = length(surface_view_position)" in water_shader.code)
+ assert(
+ "max(weather_fog_end, weather_fog_begin + 1.0),\n"
+ + "\t\tsurface_view_distance"
+ in water_shader.code
+ )
+ assert("weather_fog_near_amount" in water_shader.code)
+ assert("float weather_fog_alpha = mix(" in water_shader.code)
+ assert("ALPHA = clamp(water_alpha, 0.1, weather_fog_alpha)" in water_shader.code)
+ assert(is_zero_approx(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_amount")
+ )))
+ assert(is_equal_approx(float(
+ SaltWaterMaterial.get_shader_parameter("surface_highlight_night_floor")
+ ), WorldTimeVisualController.DEFAULT_SURFACE_HIGHLIGHT_FLOOR))
+ var day_water_brightness := float(
+ SaltWaterMaterial.get_shader_parameter("environment_brightness")
+ )
+ assert(day_water_brightness > 0.99)
+ assert(is_equal_approx(
+ float(FreshWaterMaterial.get_shader_parameter("environment_brightness")),
+ day_water_brightness,
+ ))
+ visuals.apply_time_immediately(0.0)
+ var night_horizon: Color = runtime_sky_material.get_shader_parameter(
+ "sky_horizon_color"
+ ) as Color
+ assert(night_horizon != day_horizon)
+ assert(
+ float(runtime_sky_material.get_shader_parameter("sun_visibility"))
+ < 0.01
+ )
+ var night_moon_visibility: float = float(
+ runtime_sky_material.get_shader_parameter("moon_visibility")
+ )
+ var night_moon_direction: Vector3 = (
+ runtime_sky_material.get_shader_parameter("moon_direction") as Vector3
+ )
+ var night_sun_direction: Vector3 = (
+ runtime_sky_material.get_shader_parameter("sun_direction") as Vector3
+ )
+ assert(night_moon_visibility > 0.99)
+ assert(night_moon_direction.y > 0.85)
+ assert(night_moon_direction.is_equal_approx(-night_sun_direction))
+ assert(runtime_environment.ambient_light_energy < day_ambient_energy)
+ assert(is_zero_approx(float(
+ runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
+ )))
+ var night_water_brightness := float(
+ SaltWaterMaterial.get_shader_parameter("environment_brightness")
+ )
+ assert(night_water_brightness < day_water_brightness * 0.4)
+ assert(is_equal_approx(
+ float(FreshWaterMaterial.get_shader_parameter("environment_brightness")),
+ night_water_brightness,
+ ))
+ var night_water_tint := (
+ SaltWaterMaterial.get_shader_parameter("environment_tint") as Color
+ )
+ assert(night_water_tint.get_luminance() < 0.5)
+ var night_background_energy := runtime_environment.background_energy_multiplier
+ var night_ambient_energy := runtime_environment.ambient_light_energy
+ var night_fog_light_energy := runtime_environment.fog_light_energy
+ var night_adjustment_brightness := runtime_environment.adjustment_brightness
+ var night_adjustment_saturation := runtime_environment.adjustment_saturation
+ visuals.apply_weather_immediately(WorldWeatherService.Weather.FOGGY)
+ visuals.apply_time_immediately(0.0)
+ assert(runtime_environment.fog_enabled)
+ assert(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_amount")
+ ) > 0.99)
+ assert(is_equal_approx(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_near_amount")
+ ), WorldTimeVisualController.FOGGY_NIGHT_WATER_NEAR_FOG_AMOUNT))
+ assert(is_equal_approx(float(
+ SaltWaterMaterial.get_shader_parameter("surface_highlight_night_floor")
+ ), WorldTimeVisualController.FOGGY_NIGHT_SURFACE_HIGHLIGHT_FLOOR))
+ assert(is_equal_approx(float(
+ runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
+ ), 1.0))
+ assert((
+ runtime_sky_material.get_shader_parameter("fog_horizon_color") as Color
+ ).is_equal_approx(WorldTimeVisualController.NIGHT_FOG))
+ var night_rendered_fog_color := (
+ runtime_environment.fog_light_color.srgb_to_linear()
+ )
+ night_rendered_fog_color.r *= runtime_environment.fog_light_energy
+ night_rendered_fog_color.g *= runtime_environment.fog_light_energy
+ night_rendered_fog_color.b *= runtime_environment.fog_light_energy
+ night_rendered_fog_color.a = 1.0
+ night_rendered_fog_color = night_rendered_fog_color.linear_to_srgb()
+ var night_water_fog_color := (
+ SaltWaterMaterial.get_shader_parameter("weather_fog_color") as Color
+ )
+ var corrected_night_fog_color := night_rendered_fog_color
+ corrected_night_fog_color.r *= (
+ WorldTimeVisualController.FOGGY_DARK_WATER_FOG_CORRECTION.r
+ )
+ corrected_night_fog_color.g *= (
+ WorldTimeVisualController.FOGGY_DARK_WATER_FOG_CORRECTION.g
+ )
+ corrected_night_fog_color.b *= (
+ WorldTimeVisualController.FOGGY_DARK_WATER_FOG_CORRECTION.b
+ )
+ assert(night_water_fog_color.is_equal_approx(corrected_night_fog_color))
+ var foggy_sky_horizon := (
+ runtime_sky_material.get_shader_parameter("sky_horizon_color") as Color
+ )
+ assert(foggy_sky_horizon.is_equal_approx(night_horizon))
+ assert(is_equal_approx(runtime_environment.fog_sky_affect, 1.0))
+ assert(is_zero_approx(runtime_environment.fog_aerial_perspective))
+ assert(is_equal_approx(runtime_environment.fog_depth_begin, 3.0))
+ assert(is_equal_approx(runtime_environment.fog_depth_end, 18.0))
+ assert(is_equal_approx(
+ float(SaltWaterMaterial.get_shader_parameter("environment_brightness")),
+ night_water_brightness,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.background_energy_multiplier,
+ night_background_energy,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.ambient_light_energy,
+ night_ambient_energy,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.fog_light_energy,
+ night_fog_light_energy,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.adjustment_brightness,
+ night_adjustment_brightness,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.adjustment_saturation,
+ night_adjustment_saturation,
+ ))
+ visuals.apply_time_immediately(8.0)
+ assert(is_equal_approx(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_near_amount")
+ ), WorldTimeVisualController.FOGGY_NIGHT_WATER_NEAR_FOG_AMOUNT))
+ assert(is_equal_approx(float(
+ runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
+ ), 1.0))
+ visuals.apply_time_immediately(20.0)
+ assert(is_zero_approx(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_near_amount")
+ )))
+ assert(is_zero_approx(float(
+ runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
+ )))
+ visuals.apply_weather_immediately(WorldWeatherService.Weather.SUNNY)
+ visuals.apply_time_immediately(20.0)
+ assert(not runtime_environment.fog_enabled)
+ assert(is_zero_approx(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_amount")
+ )))
+ assert(is_zero_approx(float(
+ SaltWaterMaterial.get_shader_parameter("weather_fog_near_amount")
+ )))
+ assert(is_equal_approx(float(
+ SaltWaterMaterial.get_shader_parameter("surface_highlight_night_floor")
+ ), WorldTimeVisualController.DEFAULT_SURFACE_HIGHLIGHT_FLOOR))
+ assert(is_zero_approx(float(
+ runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
+ )))
+ var dusk_horizon: Color = runtime_sky_material.get_shader_parameter(
+ "sky_horizon_color"
+ ) as Color
+ assert(dusk_horizon.r > day_horizon.r)
+ world_root.queue_free()
+
+
+func _validate_weather_clock_icon() -> void:
+ var weather_icon := WeatherIconType.new()
+ root.add_child(weather_icon)
+ assert(weather_icon.tooltip_text == "clear")
+ assert(not weather_icon.is_nighttime())
+ weather_icon.set_nighttime(true)
+ assert(weather_icon.is_nighttime())
+ weather_icon.set_weather(WorldWeatherService.Weather.CLOUDY)
+ assert(weather_icon.tooltip_text == "cloudy")
+ weather_icon.queue_free()
diff --git a/tests/world_time_validation.gd.uid b/tests/world_time_validation.gd.uid
new file mode 100644
index 0000000..b84e544
--- /dev/null
+++ b/tests/world_time_validation.gd.uid
@@ -0,0 +1 @@
+uid://buiitafgrgm4b
diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd
new file mode 100644
index 0000000..a725117
--- /dev/null
+++ b/tests/world_weather_validation.gd
@@ -0,0 +1,528 @@
+extends SceneTree
+
+const WorldWeatherServiceType = preload(
+ "res://world/world_weather_service.gd"
+)
+const NetworkWorldWeatherServiceType = preload(
+ "res://network/network_world_weather_service.gd"
+)
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldTimeVisualControllerType = preload(
+ "res://world/world_time_visual_controller.gd"
+)
+const FishingContextType = preload("res://fishing/fishing_context.gd")
+const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+const FishAvailabilityType = preload("res://fish/fish_availability.gd")
+const FishableWaterRegionType = preload(
+ "res://world/fishable_water_region.gd"
+)
+
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+
+func _run() -> void:
+ _validate_weather_scheduler()
+ _validate_authoritative_weather_override()
+ _validate_weather_persistence()
+ _validate_snapshot_bounds()
+ _validate_fishing_weather_seams()
+ _validate_fishing_spot_context()
+ _validate_weather_presentation()
+ print("World weather validation: PASS")
+ quit()
+
+
+func _validate_weather_scheduler() -> void:
+ var weather := WorldWeatherServiceType.new()
+ root.add_child(weather)
+ weather.begin_authoritative_session(20260802)
+ assert(weather.get_weather() == WorldWeatherServiceType.Weather.SUNNY)
+ assert(_duration_is_valid(weather))
+ weather.advance_weather(weather.get_seconds_remaining() + 0.01)
+ assert(weather.get_weather() == WorldWeatherServiceType.Weather.CLOUDY)
+ assert(_duration_is_valid(weather))
+ weather.advance_weather(weather.get_seconds_remaining() + 0.01)
+ assert(weather.get_weather() in [
+ WorldWeatherServiceType.Weather.SUNNY,
+ WorldWeatherServiceType.Weather.RAINY,
+ WorldWeatherServiceType.Weather.FOGGY,
+ ])
+ assert(_duration_is_valid(weather))
+ weather.apply_authoritative_snapshot(
+ WorldWeatherServiceType.Weather.RAINY, 120.0
+ )
+ assert(weather.is_raining())
+ assert(not weather.is_foggy())
+ weather.apply_authoritative_snapshot(
+ WorldWeatherServiceType.Weather.FOGGY, 120.0
+ )
+ assert(weather.is_foggy())
+ assert(not weather.is_raining())
+ weather.queue_free()
+
+
+func _duration_is_valid(weather: WorldWeatherServiceType) -> bool:
+ var seconds: float = weather.get_seconds_remaining()
+ match weather.get_weather():
+ WorldWeatherServiceType.Weather.SUNNY:
+ return seconds >= 480.0 and seconds <= 900.0
+ WorldWeatherServiceType.Weather.CLOUDY:
+ return seconds >= 300.0 and seconds <= 720.0
+ WorldWeatherServiceType.Weather.RAINY, WorldWeatherServiceType.Weather.FOGGY:
+ return seconds >= 300.0 and seconds <= 600.0
+ return false
+
+
+func _validate_authoritative_weather_override() -> void:
+ var weather := WorldWeatherServiceType.new()
+ root.add_child(weather)
+ assert(not weather.set_authoritative_weather(
+ WorldWeatherServiceType.Weather.FOGGY
+ ))
+ weather.begin_authoritative_session(20260812)
+ assert(weather.set_authoritative_weather(
+ WorldWeatherServiceType.Weather.FOGGY
+ ))
+ assert(weather.is_foggy())
+ assert(_duration_is_valid(weather))
+ weather.end_session()
+ assert(not weather.set_authoritative_weather(
+ WorldWeatherServiceType.Weather.RAINY
+ ))
+
+ var clock := WorldTimeServiceType.new()
+ root.add_child(clock)
+ clock.begin_session(WorldTimeServiceType.DAY_START_HOUR)
+ var schedule: Array[Dictionary] = []
+ for index: int in WorldWeatherServiceType.DAILY_PLAN_SEGMENT_COUNT:
+ schedule.append({
+ "start_hour": fposmod(
+ WorldTimeServiceType.DAY_START_HOUR
+ + float(index)
+ * WorldWeatherServiceType.DAILY_PLAN_SEGMENT_HOURS,
+ WorldTimeServiceType.HOURS_PER_DAY,
+ ),
+ "weather": int(WorldWeatherServiceType.Weather.SUNNY),
+ })
+ assert(weather.configure_daily_plan("command-test", schedule, clock))
+ weather.begin_authoritative_session(20260812)
+ assert(weather.set_authoritative_weather(
+ WorldWeatherServiceType.Weather.RAINY
+ ))
+ weather.advance_weather(1.0)
+ assert(weather.is_raining())
+ clock.synchronize_time(
+ WorldTimeServiceType.DAY_START_HOUR
+ + WorldWeatherServiceType.DAILY_PLAN_SEGMENT_HOURS
+ )
+ weather.advance_weather(0.01)
+ assert(weather.get_weather() == WorldWeatherServiceType.Weather.SUNNY)
+ weather.queue_free()
+ clock.queue_free()
+
+
+func _validate_weather_persistence() -> void:
+ var weather := WorldWeatherServiceType.new()
+ root.add_child(weather)
+ weather.set_persistence_tracking_enabled(true)
+ weather.begin_authoritative_session(20260802)
+ weather.apply_authoritative_snapshot(
+ WorldWeatherServiceType.Weather.RAINY,
+ 200.0,
+ )
+ weather.advance_weather(12.5)
+ assert(weather.has_persistent_state())
+ assert(
+ weather.get_persistent_weather()
+ == WorldWeatherServiceType.Weather.RAINY
+ )
+ assert(is_equal_approx(
+ weather.get_persistent_seconds_remaining(),
+ 187.5,
+ ))
+ weather.set_persistence_tracking_enabled(false)
+ weather.begin_remote_session()
+ weather.apply_authoritative_snapshot(
+ WorldWeatherServiceType.Weather.FOGGY,
+ 45.0,
+ )
+ assert(weather.is_foggy())
+ assert(
+ weather.get_persistent_weather()
+ == WorldWeatherServiceType.Weather.RAINY
+ )
+ assert(is_equal_approx(
+ weather.get_persistent_seconds_remaining(),
+ 187.5,
+ ))
+ weather.set_persistence_tracking_enabled(true)
+ weather.begin_authoritative_session(20260803)
+ assert(weather.is_raining())
+ assert(is_equal_approx(weather.get_seconds_remaining(), 187.5))
+ assert(not weather.restore_persistent_state(
+ WorldWeatherServiceType.Weather.RAINY,
+ WorldWeatherServiceType.MAX_PERSISTED_SECONDS + 1.0,
+ ))
+ weather.queue_free()
+
+
+func _validate_snapshot_bounds() -> void:
+ assert(NetworkWorldWeatherServiceType.validate_snapshot({
+ "session_id": "session",
+ "weather": int(WorldWeatherServiceType.Weather.FOGGY),
+ "seconds_remaining": 300.0,
+ "sequence": 2,
+ }))
+ assert(not NetworkWorldWeatherServiceType.validate_snapshot({
+ "session_id": "session",
+ "weather": 99,
+ "seconds_remaining": 300.0,
+ "sequence": 2,
+ }))
+ assert(not NetworkWorldWeatherServiceType.validate_snapshot({
+ "session_id": "session",
+ "weather": int(WorldWeatherServiceType.Weather.RAINY),
+ "seconds_remaining": 1801.0,
+ "sequence": 2,
+ }))
+
+
+func _validate_fishing_weather_seams() -> void:
+ var clear_context := FishingContextType.new()
+ var rain_context := FishingContextType.new()
+ rain_context.is_raining = true
+ var fog_context := FishingContextType.new()
+ fog_context.is_foggy = true
+ fog_context.is_night = true
+ var rain_only := FishAvailabilityType.new()
+ rain_only.require_rain = true
+ assert(not rain_only.is_available(clear_context))
+ assert(rain_only.is_available(rain_context))
+ var fog_only := FishAvailabilityType.new()
+ fog_only.allow_day = false
+ fog_only.require_fog = true
+ assert(not fog_only.is_available(clear_context))
+ assert(fog_only.is_available(fog_context))
+ var no_fog := FishAvailabilityType.new()
+ no_fog.forbid_fog = true
+ assert(no_fog.is_available(clear_context))
+ assert(not no_fog.is_available(fog_context))
+
+
+func _validate_fishing_spot_context() -> void:
+ var weather := WorldWeatherServiceType.new()
+ weather.begin_remote_session()
+ weather.apply_authoritative_snapshot(
+ WorldWeatherServiceType.Weather.FOGGY, 200.0
+ )
+ var fishing_spot := FishingSpotType.new()
+ fishing_spot.set("_world_weather", weather)
+ var region := FishableWaterRegionType.new()
+ region.location_tags = [&"starter_pond"]
+ var context: FishingContext = fishing_spot.build_network_context(region)
+ assert(context.is_foggy)
+ assert(not context.is_raining)
+ weather.apply_authoritative_snapshot(
+ WorldWeatherServiceType.Weather.RAINY, 200.0
+ )
+ context = fishing_spot.build_network_context(region)
+ assert(context.is_raining)
+ assert(not context.is_foggy)
+ region.free()
+ fishing_spot.free()
+ weather.free()
+
+
+func _validate_weather_presentation() -> void:
+ var world_root := Node3D.new()
+ root.add_child(world_root)
+ var world_environment := WorldEnvironment.new()
+ var environment := Environment.new()
+ var sky := Sky.new()
+ var sky_material := ShaderMaterial.new()
+ sky_material.shader = preload(
+ "res://world/environment/netfishing_sky.gdshader"
+ )
+ sky.sky_material = sky_material
+ environment.sky = sky
+ environment.adjustment_enabled = true
+ environment.fog_enabled = true
+ world_environment.environment = environment
+ world_root.add_child(world_environment)
+ var sun := DirectionalLight3D.new()
+ world_root.add_child(sun)
+ var clock := WorldTimeServiceType.new()
+ world_root.add_child(clock)
+ clock.begin_session(14.0)
+ var weather := WorldWeatherServiceType.new()
+ world_root.add_child(weather)
+ weather.begin_remote_session()
+ var rain_target := Node3D.new()
+ world_root.add_child(rain_target)
+ var rain_camera := Camera3D.new()
+ world_root.add_child(rain_camera)
+ rain_target.global_position = Vector3(-18.0, 2.0, 14.0)
+ rain_camera.global_position = Vector3(24.0, 9.0, -18.0)
+ var visuals := WorldTimeVisualControllerType.new()
+ world_root.add_child(visuals)
+ visuals.setup(
+ clock,
+ world_environment,
+ sun,
+ weather,
+ rain_target,
+ func() -> Camera3D: return rain_camera,
+ )
+ var runtime_environment: Environment = world_environment.environment
+ var runtime_sky_material := (
+ runtime_environment.sky.sky_material as ShaderMaterial
+ )
+ var clear_background_energy: float = (
+ runtime_environment.background_energy_multiplier
+ )
+ var clear_ambient_energy: float = runtime_environment.ambient_light_energy
+ var clear_fog_light_energy: float = runtime_environment.fog_light_energy
+ var clear_brightness: float = runtime_environment.adjustment_brightness
+ var clear_saturation: float = runtime_environment.adjustment_saturation
+ var clear_sun_energy: float = sun.light_energy
+ var clear_water_brightness: float = float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "environment_brightness"
+ )
+ )
+ assert(not runtime_environment.fog_enabled)
+ assert(is_zero_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ )))
+ assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.35))
+ assert(is_zero_approx(float(
+ runtime_sky_material.get_shader_parameter("cloud_opacity")
+ )))
+ var storm_clouds := visuals.get_node("LocalStormClouds") as Node3D
+ assert(storm_clouds != null)
+ assert(storm_clouds.call("get_patch_count") == 81)
+ var cloud_field := storm_clouds.get_node("CloudField") as MultiMeshInstance3D
+ assert(cloud_field != null)
+ assert(cloud_field.multimesh != null)
+ assert(cloud_field.multimesh.instance_count == 81)
+ assert(cloud_field.multimesh.mesh is ArrayMesh)
+ var cloud_shader: Shader = preload(
+ "res://world/environment/local_storm_cloud.gdshader"
+ )
+ assert("cull_disabled" in cloud_shader.code)
+ assert(LocalStormCloudLayer.WRAP_RADIUS >= 315.0)
+ assert(is_equal_approx(LocalStormCloudLayer.MAXIMUM_OPACITY, 1.0))
+ assert(
+ LocalStormCloudLayer.DISTANCE_FADE_END
+ < LocalStormCloudLayer.WRAP_RADIUS
+ )
+ assert(is_zero_approx(float(storm_clouds.call("get_storm_amount"))))
+ visuals.apply_weather_immediately(
+ WorldWeatherServiceType.Weather.FOGGY
+ )
+ assert(runtime_environment.fog_enabled)
+ assert(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ ) > 0.99)
+ assert(is_zero_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_near_amount"
+ )
+ )))
+ var water_fog_color := (
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_color"
+ ) as Color
+ )
+ var rendered_fog_color := (
+ runtime_environment.fog_light_color.srgb_to_linear()
+ )
+ rendered_fog_color.r *= runtime_environment.fog_light_energy
+ rendered_fog_color.g *= runtime_environment.fog_light_energy
+ rendered_fog_color.b *= runtime_environment.fog_light_energy
+ rendered_fog_color.a = 1.0
+ rendered_fog_color = rendered_fog_color.linear_to_srgb()
+ assert(water_fog_color.is_equal_approx(rendered_fog_color))
+ assert(is_equal_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "surface_highlight_night_floor"
+ )
+ ), WorldTimeVisualControllerType.DEFAULT_SURFACE_HIGHLIGHT_FLOOR))
+ assert(runtime_environment.fog_depth_begin <= 4.01)
+ assert(is_equal_approx(runtime_environment.fog_depth_end, 18.0))
+ assert(is_equal_approx(runtime_environment.fog_sky_affect, 1.0))
+ assert(is_zero_approx(runtime_environment.fog_aerial_perspective))
+ assert(is_zero_approx(float(
+ runtime_sky_material.get_shader_parameter("cloud_opacity")
+ )))
+ assert(is_equal_approx(
+ runtime_environment.background_energy_multiplier,
+ clear_background_energy
+ * WorldTimeVisualControllerType.FOG_DAYLIGHT_SCENE_BRIGHTNESS,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.ambient_light_energy,
+ clear_ambient_energy
+ * WorldTimeVisualControllerType.FOG_DAYLIGHT_SCENE_BRIGHTNESS,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.fog_light_energy,
+ clear_fog_light_energy
+ * WorldTimeVisualControllerType.FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.adjustment_brightness,
+ clear_brightness
+ * WorldTimeVisualControllerType.FOG_DAYLIGHT_POST_BRIGHTNESS,
+ ))
+ assert(is_equal_approx(sun.light_energy, clear_sun_energy * 0.30))
+ assert(is_equal_approx(
+ float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "environment_brightness"
+ )
+ ),
+ clear_water_brightness
+ * WorldTimeVisualControllerType.FOG_DAYLIGHT_WATER_BRIGHTNESS,
+ ))
+ assert(is_equal_approx(
+ runtime_environment.adjustment_saturation,
+ clear_saturation,
+ ))
+ visuals.apply_weather_immediately(
+ WorldWeatherServiceType.Weather.RAINY
+ )
+ assert(runtime_environment.fog_enabled)
+ assert(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ ) > 0.99)
+ assert(is_zero_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_near_amount"
+ )
+ )))
+ var rainy_water_fog_color := (
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_color"
+ ) as Color
+ )
+ assert(
+ rainy_water_fog_color.is_equal_approx(
+ WorldTimeVisualControllerType.RAIN_WATER_FOG_COLOR
+ )
+ )
+ assert(is_equal_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "surface_highlight_night_floor"
+ )
+ ), WorldTimeVisualControllerType.DEFAULT_SURFACE_HIGHLIGHT_FLOOR))
+ assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.68))
+ assert(
+ float(runtime_sky_material.get_shader_parameter("cloud_coverage"))
+ > 0.98
+ )
+ assert(
+ float(runtime_sky_material.get_shader_parameter("cloud_opacity"))
+ > 0.99
+ )
+ assert(float(storm_clouds.call("get_storm_amount")) > 0.87)
+ assert(storm_clouds.visible)
+ assert(is_equal_approx(
+ runtime_environment.adjustment_brightness,
+ clear_brightness
+ * 0.92
+ * WorldTimeVisualControllerType.RAIN_DAYLIGHT_POST_BRIGHTNESS,
+ ))
+ var rain := visuals.get_node("LocalRain") as GPUParticles3D
+ assert(rain != null)
+ assert(rain.global_position.is_equal_approx(
+ rain_camera.global_position
+ + WorldTimeVisualControllerType.RAIN_EMITTER_OFFSET
+ ))
+ rain_camera.global_position = Vector3(-31.0, 15.0, 42.0)
+ visuals.call("_update_rain_position")
+ assert(rain.global_position.is_equal_approx(
+ rain_camera.global_position
+ + WorldTimeVisualControllerType.RAIN_EMITTER_OFFSET
+ ))
+ assert(rain.emitting)
+ assert(rain.amount_ratio > 0.99)
+ assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT)
+ assert(rain.visibility_aabb == (
+ WorldTimeVisualControllerType.RAIN_VISIBILITY_AABB
+ ))
+ var rain_material := rain.process_material as ParticleProcessMaterial
+ assert(rain_material != null)
+ assert(rain_material.emission_box_extents.is_equal_approx(
+ WorldTimeVisualControllerType.RAIN_EMISSION_EXTENTS
+ ))
+ assert(is_equal_approx(
+ rain_material.initial_velocity_min,
+ WorldTimeVisualControllerType.RAIN_VELOCITY_MIN,
+ ))
+ assert(is_equal_approx(
+ rain_material.initial_velocity_max,
+ WorldTimeVisualControllerType.RAIN_VELOCITY_MAX,
+ ))
+ var rain_mesh := rain.draw_pass_1 as BoxMesh
+ assert(rain_mesh != null)
+ assert(rain_mesh.size.is_equal_approx(
+ WorldTimeVisualControllerType.RAIN_DROP_SIZE
+ ))
+ visuals.call(
+ "_on_weather_changed",
+ WorldWeatherServiceType.Weather.SUNNY,
+ 60.0,
+ )
+ visuals.call(
+ "_process",
+ WorldTimeVisualControllerType.WEATHER_TRANSITION_SECONDS * 0.5,
+ )
+ var halfway_water_fog: float = float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ )
+ assert(halfway_water_fog > 0.49 and halfway_water_fog < 0.51)
+ assert(is_equal_approx(
+ float(
+ WorldTimeVisualControllerType.FRESH_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ ),
+ halfway_water_fog,
+ ))
+ assert(runtime_environment.fog_enabled)
+ visuals.call(
+ "_process",
+ WorldTimeVisualControllerType.WEATHER_TRANSITION_SECONDS * 0.5,
+ )
+ assert(is_zero_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ )))
+ assert(not runtime_environment.fog_enabled)
+ visuals.apply_weather_immediately(
+ WorldWeatherServiceType.Weather.CLOUDY
+ )
+ assert(not runtime_environment.fog_enabled)
+ assert(is_zero_approx(float(
+ WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter(
+ "weather_fog_amount"
+ )
+ )))
+ visuals.apply_weather_immediately(
+ WorldWeatherServiceType.Weather.SUNNY
+ )
+ assert(not runtime_environment.fog_enabled)
+ assert(not rain.emitting)
+ world_root.queue_free()
diff --git a/tests/world_weather_validation.gd.uid b/tests/world_weather_validation.gd.uid
new file mode 100644
index 0000000..8694186
--- /dev/null
+++ b/tests/world_weather_validation.gd.uid
@@ -0,0 +1 @@
+uid://cdbmbag58okjb
diff --git a/ui/animalese_voice.gd b/ui/animalese_voice.gd
new file mode 100644
index 0000000..9a75434
--- /dev/null
+++ b/ui/animalese_voice.gd
@@ -0,0 +1,125 @@
+class_name AnimaleseVoice
+extends Node
+
+const SAMPLE_DIRECTORY := "res://sound/dialogue/animalese/placeholder"
+const SUPPORTED_CHARACTERS := "abcdefghijklmnopqrstuvwxyz"
+const DEFAULT_CHARACTERS_PER_SECOND: float = 28.0
+const POLYPHONY: int = 8
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+const TypewriterRevealType = preload("res://ui/typewriter_reveal.gd")
+
+var base_pitch: float = 1.0
+var volume_db: float = -13.0
+
+var _samples: Dictionary[String, AudioStream] = {}
+var _player: AudioStreamPlayer
+var _playback: AudioStreamPlaybackPolyphonic
+
+
+func _ready() -> void:
+ _load_samples()
+ var polyphonic_stream := AudioStreamPolyphonic.new()
+ polyphonic_stream.polyphony = POLYPHONY
+ _player = AudioStreamPlayer.new()
+ _player.name = "AnimaleseAudio"
+ _player.bus = &"SFX"
+ _player.stream = polyphonic_stream
+ add_child(_player)
+ _player.play()
+ _playback = (
+ _player.get_stream_playback() as AudioStreamPlaybackPolyphonic
+ )
+
+
+func speak_text(
+ tween_owner: Node,
+ text: String,
+ voice_key: String,
+ voice_profile_id: String = VoiceProfilesType.DEFAULT_ID,
+ characters_per_second: float = -1.0,
+) -> Tween:
+ var speech_tween := tween_owner.create_tween()
+ var resolved_characters_per_second := (
+ characters_per_second
+ if characters_per_second > 0.0
+ else TypewriterRevealType.get_characters_per_second()
+ )
+ var character_seconds := 1.0 / maxf(
+ resolved_characters_per_second,
+ 1.0,
+ )
+ var voice_pitch := (
+ base_pitch * VoiceProfilesType.pitch_for(voice_profile_id)
+ )
+ for character_index: int in range(text.length()):
+ speech_tween.tween_interval(character_seconds)
+ speech_tween.tween_callback(
+ _play_character.bind(
+ text.substr(character_index, 1),
+ character_index,
+ text,
+ voice_key,
+ voice_pitch,
+ )
+ )
+ return speech_tween
+
+
+func _load_samples() -> void:
+ for character_index: int in range(SUPPORTED_CHARACTERS.length()):
+ var character := SUPPORTED_CHARACTERS.substr(character_index, 1)
+ _load_sample(character)
+ _load_sample("fallback")
+
+
+func _load_sample(sample_name: String) -> void:
+ var sample_path := "%s/%s.wav" % [SAMPLE_DIRECTORY, sample_name]
+ var sample := load(sample_path) as AudioStream
+ if sample != null:
+ _samples[sample_name] = sample
+
+
+func _play_character(
+ character: String,
+ character_index: int,
+ full_text: String,
+ voice_key: String,
+ voice_pitch: float,
+) -> void:
+ if _playback == null or character.strip_edges().is_empty():
+ return
+ var normalized := character.to_lower()
+ if normalized in [".", ",", "!", "?", ":", ";", "-", "_"]:
+ return
+ var sample_name := (
+ normalized if SUPPORTED_CHARACTERS.contains(normalized) else "fallback"
+ )
+ var sample: AudioStream = _samples.get(sample_name)
+ if sample == null:
+ return
+ var speaker_variation := (
+ float(posmod(hash(voice_key), 1001)) / 1000.0 - 0.5
+ ) * 0.16
+ var character_variation := (
+ float(posmod(hash("%s:%d" % [voice_key, character_index]), 1001))
+ / 1000.0
+ - 0.5
+ ) * 0.10
+ var question_lift := 0.0
+ var question_lift_start: int = floori(float(full_text.length()) * 0.75)
+ if full_text.ends_with("?") and character_index >= question_lift_start:
+ var final_progress := (
+ float(character_index) / maxf(float(full_text.length() - 1), 1.0)
+ )
+ question_lift = lerpf(0.0, 0.14, final_progress)
+ var pitch := clampf(
+ voice_pitch
+ + speaker_variation
+ + character_variation
+ + question_lift,
+ 0.72,
+ 1.45,
+ )
+ _playback.play_stream(sample, 0.0, volume_db, pitch)
diff --git a/ui/animalese_voice.gd.uid b/ui/animalese_voice.gd.uid
new file mode 100644
index 0000000..4cfdf9b
--- /dev/null
+++ b/ui/animalese_voice.gd.uid
@@ -0,0 +1 @@
+uid://djst00wf8yntj
diff --git a/ui/art_kit_marker_icon.gdshader b/ui/art_kit_marker_icon.gdshader
new file mode 100644
index 0000000..79a6564
--- /dev/null
+++ b/ui/art_kit_marker_icon.gdshader
@@ -0,0 +1,15 @@
+shader_type canvas_item;
+
+uniform vec4 marker_color : source_color = vec4(1.0, 0.0, 0.0, 1.0);
+
+
+void fragment() {
+ vec4 source = texture(TEXTURE, UV) * COLOR;
+ float red_dominance = source.r - max(source.g, source.b);
+ float marker_mask = smoothstep(0.04, 0.18, red_dominance);
+ vec3 colored_marker = marker_color.rgb * source.r;
+ COLOR = vec4(
+ mix(source.rgb, colored_marker, marker_mask),
+ source.a * marker_color.a
+ );
+}
diff --git a/ui/art_kit_marker_icon.gdshader.uid b/ui/art_kit_marker_icon.gdshader.uid
new file mode 100644
index 0000000..0e7148f
--- /dev/null
+++ b/ui/art_kit_marker_icon.gdshader.uid
@@ -0,0 +1 @@
+uid://bsg3cwrnjq6ev
diff --git a/ui/assets/logbook/ui_logbook.png b/ui/assets/logbook/ui_logbook.png
new file mode 100644
index 0000000..2e6126d
Binary files /dev/null and b/ui/assets/logbook/ui_logbook.png differ
diff --git a/ui/assets/logbook/ui_logbook.png.import b/ui/assets/logbook/ui_logbook.png.import
new file mode 100644
index 0000000..1ffd643
--- /dev/null
+++ b/ui/assets/logbook/ui_logbook.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bnwbcopsjmi3s"
+path="res://.godot/imported/ui_logbook.png-e45558834671114c04857bb4a6091575.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/assets/logbook/ui_logbook.png"
+dest_files=["res://.godot/imported/ui_logbook.png-e45558834671114c04857bb4a6091575.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd
new file mode 100644
index 0000000..c080a04
--- /dev/null
+++ b/ui/chat_ui.gd
@@ -0,0 +1,1500 @@
+class_name ChatUI
+extends Control
+
+const INPUT_OWNER: StringName = &"chat"
+const RECENT_SECONDS: float = 8.0
+const SPEECH_SECONDS: float = 6.0
+const DRAFT_SAVE_DELAY: float = 0.4
+const IDLE_ALPHA: float = 0.58
+const CHAT_SURFACE_COLOR := Color(0.025, 0.13, 0.19, 0.94)
+const PANEL_WIDTH: float = 290.0
+const COMPACT_HEIGHT: float = 250.0
+const BOTTOM_MARGIN: float = 12.0
+const EXPANDED_TOP_MARGIN: float = 94.0
+const MIN_TOP_MARGIN: float = 24.0
+const MIN_HISTORY_HEIGHT: float = 92.0
+const HISTORY_FONT_SIZE: int = 20
+const INPUT_FONT_SIZE: int = 23
+const HINT_FONT_SIZE: int = 12
+const HISTORY_INPUT_GAP: int = 8
+const HANDLE_SIZE := Vector2(34, 42)
+const HANDLE_GAP: float = 6.0
+const HANDLE_TOP_INSET: float = 18.0
+const COLLAPSED_REVEAL_WIDTH: float = 8.0
+const HINT_EDGE_MARGIN: float = 4.0
+const SPEECH_BUBBLE_WIDTH: float = 320.0
+const SPEECH_POINTER_HALF_WIDTH: float = 12.0
+const SPEECH_POINTER_HEIGHT: float = 14.0
+const SPEECH_POINTER_OVERLAP: float = 3.0
+const MOBILE_COMPACT_WIDTH: float = 620.0
+const MOBILE_EXPANDED_WIDTH: float = 820.0
+const MOBILE_COMPACT_HEIGHT: float = 220.0
+const MOBILE_EXPANDED_HEIGHT: float = 390.0
+const MOBILE_EDGE_MARGIN: float = 12.0
+const CLOCK_SIZE := Vector2(174.0, 51.0)
+const CLOCK_FONT_SIZE: int = 27
+const CLOCK_EDGE_MARGIN: float = 10.0
+const WEATHER_ICON_SIZE := Vector2(51.0, 51.0)
+const WEATHER_ICON_GAP: float = 6.0
+const FISH_FINDER_EFFECT_ICON_SIZE := Vector2(18.0, 18.0)
+const FISH_FINDER_EFFECT_ICON: Texture2D = preload(
+ "res://items/icons/consumables/64_consumable_fishfinder.png"
+)
+const CHAT_SHOW_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/arrow_light_right_more.png"
+)
+const CHAT_HIDE_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/arrow_light_left_more.png"
+)
+const CHAT_EXPAND_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/arrow_light_up_more.png"
+)
+const CHAT_COMPACT_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/arrow_light_down_more.png"
+)
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldWeatherServiceType = preload(
+ "res://world/world_weather_service.gd"
+)
+const WeatherIconType = preload("res://ui/weather_icon.gd")
+
+const TypewriterRevealType = preload("res://ui/typewriter_reveal.gd")
+const AnimaleseVoiceType = preload("res://ui/animalese_voice.gd")
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+
+
+enum PresentationState {
+ COLLAPSED,
+ COMPACT,
+ EXPANDED,
+}
+
+signal text_entry_ownership_changed(active: bool)
+
+var _service: NetworkChatService
+var _session: NetworkSession
+var _spawn: PlayerSpawnService
+var _player: Player
+var _fishing_spot: FishingSpot
+var _settings: PlayerSettingsManager
+var _history: RichTextLabel
+var _entry: LineEdit
+var _status: Label
+var _panel: PanelContainer
+var _collapse_button: Button
+var _height_button: Button
+var _unread_indicator: Label
+var _hint: Label
+var _speech_layer: Control
+var _animalese_voice: AnimaleseVoiceType
+var _clock_panel: PanelContainer
+var _clock_label: Label
+var _weather_icon: WeatherIconType
+var _fish_finder_effect_icon: TextureRect
+var _speech: Dictionary[int, Dictionary] = {}
+var _draft_save_timer: Timer
+var _opacity_tween: Tween
+var _height_tween: Tween
+var _opened: bool = false
+var _available: bool = false
+var _hud_hidden: bool = false
+var _presentation_state := PresentationState.COMPACT
+var _visible_state_before_collapse := PresentationState.COMPACT
+var _panel_hovered: bool = false
+var _collapsed_has_unread: bool = false
+var _last_message_time: float = -INF
+var _target_alpha: float = -1.0
+var _send_pending: bool = false
+var _pending_send_body: String = ""
+var _controller_refocused: bool = false
+var _input_lock_applied: bool = false
+var _last_submit_frame: int = -1
+var _output_scale: float = 1.0
+var _dock_right: bool = false
+var _mobile_mode: bool = false
+var _world_time: WorldTimeServiceType
+var _world_weather: WorldWeatherServiceType
+var _item_effects: PlayerItemEffects
+
+
+func _ready() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _build_ui()
+ resized.connect(_on_viewport_resized)
+ set_process(true)
+
+
+func setup(
+ service: NetworkChatService,
+ session: NetworkSession,
+ spawn: PlayerSpawnService,
+ player: Player,
+ fishing_spot: FishingSpot,
+ settings: PlayerSettingsManager,
+ world_time: WorldTimeServiceType,
+ world_weather: WorldWeatherServiceType,
+ item_effects: PlayerItemEffects,
+) -> void:
+ _service = service
+ _session = session
+ _spawn = spawn
+ _player = player
+ _fishing_spot = fishing_spot
+ _settings = settings
+ _world_time = world_time
+ _world_weather = world_weather
+ _item_effects = item_effects
+ if (
+ _fishing_spot != null
+ and not _fishing_spot.local_speech_requested.is_connected(
+ show_local_speech
+ )
+ ):
+ _fishing_spot.local_speech_requested.connect(show_local_speech)
+ if (
+ _world_time != null
+ and not _world_time.time_changed.is_connected(
+ _on_world_time_changed
+ )
+ ):
+ _world_time.time_changed.connect(_on_world_time_changed)
+ _on_world_time_changed(
+ _world_time.get_time_hours(), _world_time.get_phase()
+ )
+ if (
+ _world_weather != null
+ and not _world_weather.weather_changed.is_connected(
+ _on_world_weather_changed
+ )
+ ):
+ _world_weather.weather_changed.connect(_on_world_weather_changed)
+ _on_world_weather_changed(
+ _world_weather.get_weather(),
+ _world_weather.get_seconds_remaining(),
+ )
+ set_mobile_mode(_settings.current_settings.chat_mobile_mode)
+ set_dock_right(_settings.current_settings.chat_dock_right)
+ _service.message_received.connect(_on_message)
+ _service.local_message_confirmed.connect(_on_local_message_confirmed)
+ _service.history_replaced.connect(_on_history)
+ _service.send_rejected.connect(_on_rejected)
+ _service.world_command_finished.connect(_on_world_command_finished)
+ _session.peer_removed.connect(_on_peer_removed)
+ _entry.text = _settings.current_settings.chat_draft
+ _entry.caret_column = _entry.text.length()
+ _set_presentation_state(
+ PresentationState.COLLAPSED
+ if _settings.current_settings.chat_collapsed
+ else PresentationState.COMPACT,
+ false,
+ false,
+ )
+ _refresh_history()
+
+
+func open_chat() -> void:
+ if (
+ _opened or not _available or _service == null
+ or not _session.is_gameplay_session_active()
+ ):
+ return
+ if _presentation_state == PresentationState.COLLAPSED:
+ _set_presentation_state(_visible_state_before_collapse, true, true)
+ _controller_refocused = false
+ _opened = true
+ text_entry_ownership_changed.emit(true)
+ _input_lock_applied = true
+ _player.set_local_input_suppressed(INPUT_OWNER, true)
+ _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
+ # Clear the last authoritative input immediately. Merely disabling local
+ # prediction would let a remote host continue the last held movement.
+ _session.submit_neutral_local_movement()
+ _entry.show()
+ _entry.virtual_keyboard_enabled = false
+ _hint.hide()
+ _refresh_input_ownership()
+ call_deferred("_focus_entry_after_open")
+ _update_panel_opacity(true)
+ Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
+
+
+func open_command_chat() -> void:
+ open_chat()
+ if not _opened:
+ return
+ if not _entry.text.begins_with("/"):
+ _entry.text = "/" + _entry.text
+ _entry.caret_column = _entry.text.length()
+
+
+func set_available(value: bool) -> void:
+ _available = value
+ if not value:
+ _send_pending = false
+ _pending_send_body = ""
+ _entry.editable = true
+ close_chat()
+ _flush_draft()
+ _refresh_visibility()
+
+
+func close_chat() -> void:
+ var ownership_was_active: bool = _opened or _input_lock_applied
+ _opened = false
+ _set_status("")
+ _entry.virtual_keyboard_enabled = false
+ _entry.release_focus()
+ _entry.hide()
+ if _input_lock_applied:
+ _player.set_local_input_suppressed(INPUT_OWNER, false)
+ _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
+ _input_lock_applied = false
+ if ownership_was_active:
+ text_entry_ownership_changed.emit(false)
+ _flush_draft()
+ _refresh_visibility()
+ _refresh_input_ownership()
+
+
+func toggle_chat() -> void:
+ if _presentation_state == PresentationState.COLLAPSED:
+ open_chat()
+ return
+ if _opened:
+ close_chat()
+ _set_presentation_state(PresentationState.COLLAPSED, true, true)
+ return
+ if _controller_refocused:
+ _controller_refocused = false
+ _set_presentation_state(PresentationState.COLLAPSED, true, true)
+ return
+ open_chat()
+
+
+func refocus_gameplay() -> void:
+ close_chat()
+ _controller_refocused = true
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+
+
+func toggle_focus() -> void:
+ if _presentation_state == PresentationState.COLLAPSED:
+ return
+ if _opened:
+ refocus_gameplay()
+ else:
+ open_chat()
+
+
+func request_virtual_keyboard() -> bool:
+ if not _opened or not _entry.has_focus():
+ return false
+ _entry.virtual_keyboard_enabled = true
+ # Re-entering focus after the controller accept event is the explicit
+ # request Android uses to display its keyboard. Merely focusing Chat keeps
+ # the keyboard disabled so the world remains readable.
+ _entry.release_focus()
+ _entry.call_deferred("grab_focus")
+ return true
+
+
+func _focus_entry_after_open() -> void:
+ if not _opened or not _entry.visible:
+ return
+ _entry.grab_focus()
+ _refresh_input_ownership()
+
+
+func is_open() -> bool:
+ return _opened
+
+
+func is_collapsed() -> bool:
+ return _presentation_state == PresentationState.COLLAPSED
+
+
+func _unhandled_input(event: InputEvent) -> void:
+ if event.is_action_pressed("open_chat"):
+ if event is InputEventKey:
+ if _opened:
+ _focus_entry_after_open()
+ else:
+ open_chat()
+ else:
+ toggle_chat()
+ get_viewport().set_input_as_handled()
+ return
+ if event is InputEventKey and event.pressed and not event.echo:
+ if event.keycode in [KEY_ENTER, KEY_KP_ENTER]:
+ if _opened:
+ _last_submit_frame = Engine.get_process_frames()
+ _send()
+ elif (
+ _available
+ and get_viewport().gui_get_focus_owner() is not LineEdit
+ and Engine.get_process_frames() != _last_submit_frame
+ ):
+ open_chat()
+ get_viewport().set_input_as_handled()
+ elif event.keycode == KEY_T and not _opened and _available:
+ open_chat()
+ get_viewport().set_input_as_handled()
+ elif (
+ event.unicode == 47
+ and not _opened
+ and _available
+ and get_viewport().gui_get_focus_owner() is not LineEdit
+ ):
+ open_command_chat()
+ get_viewport().set_input_as_handled()
+ elif event.keycode == KEY_ESCAPE and _opened:
+ close_chat()
+ get_viewport().set_input_as_handled()
+
+
+func _process(_delta: float) -> void:
+ _refresh_visibility()
+ _update_panel_opacity()
+ _update_speech()
+
+
+func _exit_tree() -> void:
+ if _input_lock_applied and is_instance_valid(_player):
+ _player.set_local_input_suppressed(INPUT_OWNER, false)
+ _input_lock_applied = false
+ _flush_draft()
+
+
+func _build_ui() -> void:
+ _clock_panel = PanelContainer.new()
+ _clock_panel.name = "WorldClockPanel"
+ _clock_panel.size = CLOCK_SIZE
+ _clock_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _clock_panel.add_theme_stylebox_override(
+ "panel", _clock_panel_style()
+ )
+ add_child(_clock_panel)
+ _clock_label = Label.new()
+ _clock_label.name = "WorldClockLabel"
+ _clock_label.text = "8:00 am"
+ _clock_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _clock_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _clock_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _clock_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ _clock_label.add_theme_font_size_override("font_size", CLOCK_FONT_SIZE)
+ _clock_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _clock_panel.add_child(_clock_label)
+ _fish_finder_effect_icon = TextureRect.new()
+ _fish_finder_effect_icon.name = "FishFinderEffectIcon"
+ _fish_finder_effect_icon.texture = FISH_FINDER_EFFECT_ICON
+ _fish_finder_effect_icon.size = FISH_FINDER_EFFECT_ICON_SIZE
+ _fish_finder_effect_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ _fish_finder_effect_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ _fish_finder_effect_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ _fish_finder_effect_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ add_child(_fish_finder_effect_icon)
+ _weather_icon = WeatherIconType.new()
+ _weather_icon.name = "WorldWeatherIcon"
+ _weather_icon.size = WEATHER_ICON_SIZE
+ _weather_icon.mouse_filter = Control.MOUSE_FILTER_STOP
+ add_child(_weather_icon)
+ _panel = PanelContainer.new()
+ _panel.name = "ChatPanel"
+ _panel.mouse_filter = Control.MOUSE_FILTER_STOP
+ _panel.add_theme_stylebox_override("panel", _chat_panel_style())
+ _panel.mouse_entered.connect(func() -> void:
+ _panel_hovered = true
+ _update_panel_opacity(true)
+ )
+ _panel.mouse_exited.connect(func() -> void:
+ _panel_hovered = false
+ _update_panel_opacity(true)
+ )
+ add_child(_panel)
+ var box := VBoxContainer.new()
+ box.add_theme_constant_override("separation", HISTORY_INPUT_GAP)
+ _panel.add_child(box)
+ _history = RichTextLabel.new()
+ _history.name = "ChatHistory"
+ _history.custom_minimum_size = Vector2(0, MIN_HISTORY_HEIGHT)
+ _history.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _history.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _history.fit_content = false
+ _history.scroll_active = true
+ _history.scroll_following = true
+ _history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
+ _history.mouse_filter = Control.MOUSE_FILTER_STOP
+ _history.add_theme_font_override("normal_font", UtilityPageStyle.TuffyFont)
+ _history.add_theme_font_size_override("normal_font_size", HISTORY_FONT_SIZE)
+ _history.add_theme_color_override("default_color", UtilityPageStyle.LIGHT_TEXT)
+ _history.add_theme_stylebox_override("normal", _borderless_style(Color.TRANSPARENT))
+ box.add_child(_history)
+ _status = Label.new()
+ _status.name = "ChatStatus"
+ _status.custom_minimum_size = Vector2(0, 20)
+ _status.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _status.hide()
+ _status.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _status.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ _status.add_theme_color_override("font_color", Color("ffd6a1"))
+ box.add_child(_status)
+ _entry = LineEdit.new()
+ _entry.name = "ChatEntry"
+ _entry.placeholder_text = "type a message…"
+ _entry.max_length = NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
+ _entry.text_submitted.connect(_on_entry_text_submitted)
+ _entry.text_changed.connect(_on_draft_changed)
+ UtilityPageStyle.apply_ocean_line_edit(_entry)
+ _entry.add_theme_font_size_override("font_size", INPUT_FONT_SIZE)
+ box.add_child(_entry)
+ _entry.hide()
+ _collapse_button = Button.new()
+ _collapse_button.name = "ChatCollapseButton"
+ _collapse_button.size = HANDLE_SIZE
+ _collapse_button.mouse_filter = Control.MOUSE_FILTER_STOP
+ _collapse_button.focus_mode = Control.FOCUS_ALL
+ _collapse_button.z_index = 3
+ _collapse_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ _collapse_button.expand_icon = true
+ _collapse_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _collapse_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _collapse_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
+ _collapse_button.add_theme_constant_override(
+ "icon_max_width",
+ roundi(minf(HANDLE_SIZE.x, HANDLE_SIZE.y) * 0.5),
+ )
+ _collapse_button.tooltip_text = "Collapse chat"
+ _collapse_button.pressed.connect(func() -> void:
+ if _presentation_state == PresentationState.COLLAPSED:
+ _set_presentation_state(
+ _visible_state_before_collapse,
+ true,
+ true,
+ )
+ else:
+ if _opened:
+ close_chat()
+ _set_presentation_state(PresentationState.COLLAPSED, true, true)
+ )
+ _collapse_button.focus_entered.connect(func() -> void:
+ _update_panel_opacity(true)
+ )
+ _collapse_button.mouse_entered.connect(_on_exterior_handle_entered)
+ _collapse_button.mouse_exited.connect(_on_exterior_handle_exited)
+ _apply_flat_chat_button(_collapse_button)
+ add_child(_collapse_button)
+ _height_button = Button.new()
+ _height_button.name = "ChatHeightButton"
+ _height_button.size = HANDLE_SIZE
+ _height_button.mouse_filter = Control.MOUSE_FILTER_STOP
+ _height_button.focus_mode = Control.FOCUS_ALL
+ _height_button.z_index = 3
+ _height_button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ _height_button.expand_icon = true
+ _height_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _height_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _height_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
+ _height_button.add_theme_constant_override(
+ "icon_max_width",
+ roundi(minf(HANDLE_SIZE.x, HANDLE_SIZE.y) * 0.5),
+ )
+ _height_button.pressed.connect(func() -> void:
+ if _presentation_state == PresentationState.COLLAPSED:
+ _visible_state_before_collapse = (
+ PresentationState.COMPACT
+ if _visible_state_before_collapse == PresentationState.EXPANDED
+ else PresentationState.EXPANDED
+ )
+ _refresh_handle_labels(PresentationState.COLLAPSED)
+ _layout_presentation(true)
+ return
+ _set_presentation_state(
+ PresentationState.COMPACT
+ if _presentation_state == PresentationState.EXPANDED
+ else PresentationState.EXPANDED,
+ true,
+ true,
+ )
+ )
+ _height_button.focus_entered.connect(func() -> void:
+ _update_panel_opacity(true)
+ )
+ _height_button.mouse_entered.connect(_on_exterior_handle_entered)
+ _height_button.mouse_exited.connect(_on_exterior_handle_exited)
+ _apply_flat_chat_button(_height_button)
+ add_child(_height_button)
+ _unread_indicator = Label.new()
+ _unread_indicator.name = "ChatUnreadIndicator"
+ _unread_indicator.text = "•"
+ _unread_indicator.size = Vector2(22, 20)
+ _unread_indicator.z_index = 4
+ _unread_indicator.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _unread_indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _unread_indicator.add_theme_font_override(
+ "font", UtilityPageStyle.TuffyFont
+ )
+ _unread_indicator.add_theme_font_size_override("font_size", 20)
+ _unread_indicator.add_theme_color_override("font_color", Color("ffe08a"))
+ add_child(_unread_indicator)
+ _hint = Label.new()
+ _hint.name = "ChatHint"
+ _hint.size = Vector2(180, 18)
+ _hint.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _hint.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ _hint.add_theme_font_size_override("font_size", HINT_FONT_SIZE)
+ _hint.add_theme_color_override(
+ "font_color", Color(0.94, 0.91, 0.80, 0.72)
+ )
+ _hint.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
+ _hint.add_theme_constant_override("shadow_offset_x", 1)
+ _hint.add_theme_constant_override("shadow_offset_y", 2)
+ add_child(_hint)
+ _speech_layer = Control.new()
+ _speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ _speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ add_child(_speech_layer)
+ _animalese_voice = AnimaleseVoiceType.new()
+ _animalese_voice.name = "PlayerAnimaleseVoice"
+ add_child(_animalese_voice)
+ _draft_save_timer = Timer.new()
+ _draft_save_timer.one_shot = true
+ _draft_save_timer.wait_time = DRAFT_SAVE_DELAY
+ _draft_save_timer.timeout.connect(_flush_draft)
+ add_child(_draft_save_timer)
+ _panel.hide()
+ _clock_panel.hide()
+ _weather_icon.hide()
+ _collapse_button.hide()
+ _height_button.hide()
+ _unread_indicator.hide()
+ _hint.hide()
+ _layout_presentation(false)
+ _refresh_input_ownership()
+
+
+func _chat_panel_style() -> StyleBoxFlat:
+ var style := _borderless_style(CHAT_SURFACE_COLOR)
+ if _mobile_mode:
+ style.corner_radius_top_left = 0
+ style.corner_radius_top_right = 0
+ style.corner_radius_bottom_left = 10
+ style.corner_radius_bottom_right = 10
+ else:
+ style.corner_radius_top_left = 10 if _dock_right else 0
+ style.corner_radius_bottom_left = 10 if _dock_right else 0
+ style.corner_radius_top_right = 0 if _dock_right else 10
+ style.corner_radius_bottom_right = 0 if _dock_right else 10
+ style.content_margin_left = 12
+ style.content_margin_right = 12
+ style.content_margin_top = 10
+ style.content_margin_bottom = 10
+ return style
+
+
+func _refresh_input_ownership() -> void:
+ if _panel == null or _history == null:
+ return
+ var filter: Control.MouseFilter = (
+ Control.MOUSE_FILTER_STOP
+ if _opened
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _panel.mouse_filter = filter
+ _history.mouse_filter = filter
+ _entry.focus_mode = (
+ Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
+ )
+ _collapse_button.focus_mode = (
+ Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
+ )
+ _height_button.focus_mode = (
+ Control.FOCUS_ALL
+ if _opened and not _mobile_mode
+ else Control.FOCUS_NONE
+ )
+ if not _opened:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if focus_owner in [_entry, _collapse_button, _height_button]:
+ get_viewport().gui_release_focus()
+
+
+func _clock_panel_style() -> StyleBoxFlat:
+ var style := _borderless_style(CHAT_SURFACE_COLOR)
+ style.set_corner_radius_all(12)
+ style.content_margin_left = 10
+ style.content_margin_right = 10
+ style.content_margin_top = 4
+ style.content_margin_bottom = 4
+ return style
+
+
+func _borderless_style(color: Color) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = color
+ style.set_border_width_all(0)
+ style.anti_aliasing = false
+ style.shadow_size = 0
+ style.shadow_color = Color.TRANSPARENT
+ style.shadow_offset = Vector2.ZERO
+ return style
+
+
+func _flat_chat_button_style(color: Color) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = color
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(0)
+ if _dock_right:
+ style.corner_radius_top_left = 10
+ style.corner_radius_bottom_left = 10
+ else:
+ style.corner_radius_top_right = 10
+ style.corner_radius_bottom_right = 10
+ style.anti_aliasing = false
+ style.shadow_size = 0
+ style.shadow_color = Color.TRANSPARENT
+ style.shadow_offset = Vector2.ZERO
+ style.content_margin_left = 0
+ style.content_margin_right = 0
+ style.content_margin_top = 0
+ style.content_margin_bottom = 0
+ return style
+
+
+func _apply_flat_chat_button(button: Button) -> void:
+ button.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ button.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ button.add_theme_color_override("font_hover_color", Color.WHITE)
+ button.add_theme_color_override("font_pressed_color", Color.WHITE)
+ button.add_theme_color_override("font_focus_color", Color.WHITE)
+ button.add_theme_color_override(
+ "font_disabled_color",
+ Color(UtilityPageStyle.OCEAN_TEXT_SECONDARY, 0.52),
+ )
+ button.add_theme_stylebox_override(
+ "normal", _flat_chat_button_style(UtilityPageStyle.OCEAN_BUTTON)
+ )
+ button.add_theme_stylebox_override(
+ "hover", _flat_chat_button_style(UtilityPageStyle.OCEAN_BUTTON_HOVER)
+ )
+ button.add_theme_stylebox_override(
+ "pressed", _flat_chat_button_style(UtilityPageStyle.OCEAN_SELECTED)
+ )
+ button.add_theme_stylebox_override(
+ "hover_pressed",
+ _flat_chat_button_style(UtilityPageStyle.OCEAN_SELECTED),
+ )
+ button.add_theme_stylebox_override(
+ "focus", _flat_chat_button_style(UtilityPageStyle.OCEAN_SELECTED)
+ )
+ button.add_theme_stylebox_override(
+ "disabled", _flat_chat_button_style(UtilityPageStyle.OCEAN_DISABLED)
+ )
+ for state: StringName in [
+ &"normal",
+ &"hover",
+ &"pressed",
+ &"hover_pressed",
+ &"focus",
+ &"disabled",
+ ]:
+ button.add_theme_stylebox_override(
+ state,
+ _flat_chat_button_style(CHAT_SURFACE_COLOR),
+ )
+
+
+func _speech_panel_style() -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.96)
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(12)
+ style.anti_aliasing = false
+ style.content_margin_left = 10
+ style.content_margin_right = 10
+ style.content_margin_top = 6
+ style.content_margin_bottom = 6
+ return style
+
+
+func _create_speech_pointer() -> Polygon2D:
+ var pointer := Polygon2D.new()
+ pointer.name = "SpeechPointer"
+ pointer.polygon = PackedVector2Array([
+ Vector2(-SPEECH_POINTER_HALF_WIDTH, 0.0),
+ Vector2(SPEECH_POINTER_HALF_WIDTH, 0.0),
+ Vector2(0.0, SPEECH_POINTER_HEIGHT),
+ ])
+ pointer.color = Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.96)
+ pointer.antialiased = false
+ pointer.z_index = -1
+ return pointer
+
+
+func _on_entry_text_submitted(_value: String) -> void:
+ _last_submit_frame = Engine.get_process_frames()
+ _send()
+
+
+func _send() -> void:
+ if _send_pending:
+ return
+ var body := _entry.text
+ if body.strip_edges().is_empty():
+ close_chat()
+ return
+ if _handle_chat_command(body):
+ return
+ _send_pending = true
+ _pending_send_body = NetworkChatProtocol.sanitize_body(body)
+ _entry.editable = false
+ if not _service.send_local_message(body):
+ _send_pending = false
+ _pending_send_body = ""
+ _entry.editable = true
+ elif _send_pending:
+ _set_status("Sending…")
+
+
+func _handle_chat_command(body: String) -> bool:
+ var command_text := body.strip_edges()
+ if not command_text.begins_with("/"):
+ return false
+ var parts: PackedStringArray = command_text.split(" ", false)
+ var command := String(parts[0]).trim_prefix("/").to_lower()
+ match command:
+ "time":
+ _handle_time_command(parts)
+ "weather":
+ _handle_weather_command(parts)
+ "":
+ _set_status("Enter a command after /.")
+ _:
+ _set_status("Unknown command: /%s" % command)
+ _entry.clear()
+ _flush_draft()
+ return true
+
+
+func _handle_time_command(parts: PackedStringArray) -> void:
+ if parts.size() != 2:
+ _set_status("Usage: /time [dawn, day, dusk, night]")
+ return
+ if _service == null or _world_time == null:
+ _set_status("World time is unavailable.")
+ return
+ var phase_name := String(parts[1]).to_lower()
+ if phase_name not in ["dawn", "day", "dusk", "night"]:
+ _set_status("Usage: /time [dawn, day, dusk, night]")
+ return
+ if not _service.request_world_time_change(phase_name):
+ _set_status("Only the host or an operator can change world time.")
+ return
+ close_chat()
+
+
+func _handle_weather_command(parts: PackedStringArray) -> void:
+ if parts.size() != 2:
+ _set_status("Usage: /weather [clear, cloudy, rainy, foggy]")
+ return
+ if _service == null or _world_weather == null:
+ _set_status("World weather is unavailable.")
+ return
+ var weather_name := String(parts[1]).to_lower()
+ match weather_name:
+ "clear", "sunny":
+ weather_name = "clear"
+ "cloudy", "rainy", "foggy":
+ pass
+ _:
+ _set_status("Usage: /weather [clear, cloudy, rainy, foggy]")
+ return
+ if not _service.request_world_weather_change(weather_name):
+ _set_status("Only the host or an operator can change world weather.")
+ return
+ close_chat()
+
+
+func _on_world_command_finished(success: bool, message: String) -> void:
+ if success:
+ _set_status("")
+ elif not message.is_empty():
+ _set_status(message)
+
+
+func _on_local_message_confirmed(message: Dictionary) -> void:
+ if (
+ not _send_pending
+ or str(message.get("body", "")) != _pending_send_body
+ ):
+ return
+ _send_pending = false
+ _pending_send_body = ""
+ _entry.editable = true
+ _entry.clear()
+ _set_status("")
+ _flush_draft()
+ close_chat()
+
+
+func _on_message(message: Dictionary) -> void:
+ _last_message_time = Time.get_ticks_msec() / 1000.0
+ _refresh_history()
+ if _presentation_state == PresentationState.COLLAPSED:
+ _collapsed_has_unread = true
+ _unread_indicator.show()
+ _update_panel_opacity(true)
+ if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
+ return
+ var peer_id: int = message["sender_peer_id"]
+ _show_speech_bubble(
+ peer_id,
+ str(message["body"]),
+ str(message.get("sender_fingerprint", "")),
+ )
+
+
+func show_local_speech(body: String) -> void:
+ if body.strip_edges().is_empty() or _session == null:
+ return
+ _show_speech_bubble(_session.get_local_peer_id(), body, "")
+
+
+func _show_speech_bubble(
+ peer_id: int,
+ body: String,
+ fingerprint: String,
+) -> void:
+ _on_peer_removed(peer_id)
+ var bubble := PanelContainer.new()
+ bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bubble.custom_minimum_size = Vector2(SPEECH_BUBBLE_WIDTH, 0.0)
+ bubble.add_theme_stylebox_override("panel", _speech_panel_style())
+ var pointer := _create_speech_pointer()
+ bubble.add_child(pointer)
+ var label := Label.new()
+ label.text = body
+ label.custom_minimum_size = Vector2(
+ SPEECH_BUBBLE_WIDTH - 20.0,
+ 0.0,
+ )
+ label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ bubble.add_child(label)
+ _speech_layer.add_child(bubble)
+ var reveal_seconds := TypewriterRevealType.start(label)
+ var voice_profile_id: String = VoiceProfilesType.DEFAULT_ID
+ var speaker_avatar := _spawn.get_avatar(peer_id)
+ if speaker_avatar != null:
+ voice_profile_id = VoiceProfilesType.sanitized_id(
+ speaker_avatar.get_animalese_voice_id()
+ )
+ _animalese_voice.speak_text(
+ label,
+ label.text,
+ str(peer_id),
+ voice_profile_id,
+ )
+ _speech[peer_id] = {
+ "bubble": bubble,
+ "pointer": pointer,
+ "expires": (
+ Time.get_ticks_msec() / 1000.0
+ + reveal_seconds
+ + SPEECH_SECONDS
+ ),
+ "fingerprint": fingerprint,
+ }
+
+
+func _on_history(messages: Array) -> void:
+ for peer_id: int in _speech.keys():
+ var state: Dictionary = _speech[peer_id]
+ if (
+ messages.is_empty()
+ or _service.is_sender_filtered(
+ str(state.get("fingerprint", ""))
+ )
+ ):
+ _on_peer_removed(peer_id)
+ _refresh_history()
+
+
+func _on_rejected(message: String) -> void:
+ _send_pending = false
+ _pending_send_body = ""
+ _entry.editable = true
+ _set_status(message)
+ if not _opened:
+ open_chat()
+ else:
+ call_deferred("_focus_entry_after_open")
+
+
+func _refresh_history() -> void:
+ if _service == null:
+ return
+ var lines: Array[String] = []
+ var messages := _service.get_history()
+ for message: Dictionary in messages:
+ if int(message["kind"]) == NetworkChatProtocol.Kind.SYSTEM:
+ lines.append("• %s" % str(message["body"]))
+ else:
+ lines.append("%s: %s" % [
+ str(message["sender_display_name"]), str(message["body"]),
+ ])
+ _history.text = "\n".join(lines)
+ _scroll_history_to_bottom.call_deferred()
+
+
+func _refresh_visibility() -> void:
+ if not _available:
+ _panel.hide()
+ _clock_panel.hide()
+ _weather_icon.hide()
+ _fish_finder_effect_icon.hide()
+ _collapse_button.hide()
+ _height_button.hide()
+ _unread_indicator.hide()
+ _hint.hide()
+ return
+ if _hud_hidden and not _opened:
+ _panel.hide()
+ _clock_panel.hide()
+ _weather_icon.hide()
+ _fish_finder_effect_icon.hide()
+ _collapse_button.hide()
+ _height_button.hide()
+ _unread_indicator.hide()
+ _hint.hide()
+ return
+ _clock_panel.show()
+ _weather_icon.show()
+ _fish_finder_effect_icon.visible = (
+ _item_effects != null
+ and _item_effects.is_active(PlayerItemEffects.FISH_FINDER_ID)
+ )
+ _collapse_button.show()
+ var collapsed := _presentation_state == PresentationState.COLLAPSED
+ _panel.show()
+ _collapse_button.focus_mode = (
+ Control.FOCUS_ALL if _opened else Control.FOCUS_NONE
+ )
+ _height_button.visible = not _mobile_mode
+ _height_button.focus_mode = (
+ Control.FOCUS_ALL
+ if _opened and not _mobile_mode
+ else Control.FOCUS_NONE
+ )
+ _unread_indicator.visible = collapsed and _collapsed_has_unread
+ _hint.visible = not _opened
+
+
+func _set_presentation_state(
+ state: PresentationState,
+ persist: bool = true,
+ animate: bool = false,
+) -> void:
+ var previous_state := _presentation_state
+ if (
+ state == PresentationState.COLLAPSED
+ and previous_state != PresentationState.COLLAPSED
+ ):
+ _visible_state_before_collapse = previous_state
+ _refresh_handle_labels(state)
+ if state == _presentation_state and not animate:
+ _layout_presentation(false)
+ _refresh_visibility()
+ return
+ _presentation_state = state
+ var collapsed := state == PresentationState.COLLAPSED
+ if not collapsed:
+ _collapsed_has_unread = false
+ _unread_indicator.hide()
+ _layout_presentation(animate)
+ if persist and _settings != null:
+ _settings.update_chat_preferences(_entry.text, collapsed)
+ _draft_save_timer.start()
+ _refresh_visibility()
+
+
+func set_output_scale(output_scale: float) -> void:
+ _output_scale = maxf(output_scale, 0.001)
+
+
+func set_dock_right(should_dock_right: bool) -> void:
+ _dock_right = should_dock_right
+ if not is_node_ready() or _panel == null:
+ return
+ _panel.add_theme_stylebox_override("panel", _chat_panel_style())
+ _apply_flat_chat_button(_collapse_button)
+ _apply_flat_chat_button(_height_button)
+ _refresh_handle_labels(_presentation_state)
+ _layout_presentation(false)
+
+
+func is_docked_right() -> bool:
+ return _dock_right
+
+
+func set_mobile_mode(enabled: bool) -> void:
+ _mobile_mode = enabled
+ if not is_node_ready() or _panel == null:
+ return
+ if _mobile_mode:
+ if _presentation_state == PresentationState.EXPANDED:
+ _presentation_state = PresentationState.COMPACT
+ if _visible_state_before_collapse == PresentationState.EXPANDED:
+ _visible_state_before_collapse = PresentationState.COMPACT
+ _panel.add_theme_stylebox_override("panel", _chat_panel_style())
+ _refresh_handle_labels(_presentation_state)
+ _refresh_visibility()
+ _layout_presentation(false)
+
+
+func is_mobile_mode() -> bool:
+ return _mobile_mode
+
+
+func set_hud_hidden(hidden: bool) -> void:
+ _hud_hidden = hidden
+ _refresh_visibility()
+
+
+func is_hud_hidden() -> bool:
+ return _hud_hidden
+
+
+func _refresh_handle_labels(state: PresentationState) -> void:
+ var collapsed := state == PresentationState.COLLAPSED
+ var height_state := _visible_state_before_collapse if collapsed else state
+ _collapse_button.text = ""
+ _collapse_button.icon = CHAT_SHOW_ICON if collapsed else CHAT_HIDE_ICON
+ _collapse_button.tooltip_text = (
+ "Show chat" if collapsed else "Hide chat"
+ )
+ _collapse_button.accessibility_name = _collapse_button.tooltip_text
+ _height_button.text = ""
+ _height_button.icon = (
+ CHAT_COMPACT_ICON
+ if height_state == PresentationState.EXPANDED
+ else CHAT_EXPAND_ICON
+ )
+ _height_button.tooltip_text = (
+ "Compact chat"
+ if height_state == PresentationState.EXPANDED
+ else "Expand chat history"
+ )
+ _height_button.accessibility_name = _height_button.tooltip_text
+
+
+func _update_panel_opacity(immediate_recheck: bool = false) -> void:
+ if _panel == null or not _available:
+ return
+ if _presentation_state == PresentationState.COLLAPSED:
+ if _height_tween == null or not _height_tween.is_running():
+ _panel.modulate.a = IDLE_ALPHA
+ _collapse_button.modulate.a = IDLE_ALPHA
+ _height_button.modulate.a = IDLE_ALPHA
+ return
+ var recent := (
+ Time.get_ticks_msec() / 1000.0 - _last_message_time < RECENT_SECONDS
+ )
+ var focus_owner := get_viewport().gui_get_focus_owner()
+ var wants_full := (
+ _opened or recent or _panel_hovered
+ or focus_owner == _collapse_button
+ or focus_owner == _height_button
+ or focus_owner == _entry
+ )
+ var desired := 1.0 if wants_full else IDLE_ALPHA
+ if not immediate_recheck and is_equal_approx(desired, _target_alpha):
+ return
+ _target_alpha = desired
+ if _opacity_tween != null and _opacity_tween.is_valid():
+ _opacity_tween.kill()
+ _opacity_tween = create_tween()
+ _opacity_tween.set_parallel(true)
+ for control: Control in [_panel, _collapse_button, _height_button]:
+ _opacity_tween.tween_property(
+ control, "modulate:a", desired, 0.18
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+
+
+func _update_speech() -> void:
+ var now := Time.get_ticks_msec() / 1000.0
+ for peer_id: int in _speech.keys():
+ var state: Dictionary = _speech[peer_id]
+ var bubble := state.get("bubble") as PanelContainer
+ if bubble == null or now >= float(state.get("expires", 0.0)):
+ if bubble != null:
+ bubble.queue_free()
+ _speech.erase(peer_id)
+ continue
+ var avatar := _spawn.get_avatar(peer_id)
+ var camera := _player.get_gameplay_camera()
+ if avatar == null or camera == null:
+ bubble.hide()
+ continue
+ var world_position := avatar.get_chat_anchor_position()
+ if camera.is_position_behind(world_position):
+ bubble.hide()
+ continue
+ if _is_speech_world_occluded(camera, avatar, world_position):
+ bubble.hide()
+ continue
+ var screen_position := (
+ camera.unproject_position(world_position) / _output_scale
+ )
+ var viewport_size := get_viewport_rect().size
+ if (
+ screen_position.x < -80.0
+ or screen_position.x > viewport_size.x + 80.0
+ or screen_position.y < -80.0
+ or screen_position.y > viewport_size.y + 80.0
+ ):
+ bubble.hide()
+ continue
+ var desired := screen_position - Vector2(
+ bubble.size.x * 0.5,
+ bubble.size.y + SPEECH_POINTER_HEIGHT,
+ )
+ bubble.position = Vector2(
+ clampf(desired.x, 8.0, viewport_size.x - bubble.size.x - 8.0),
+ clampf(
+ desired.y,
+ 8.0,
+ viewport_size.y
+ - bubble.size.y
+ - SPEECH_POINTER_HEIGHT
+ - 8.0,
+ ),
+ )
+ var pointer := state.get("pointer") as Polygon2D
+ if pointer != null:
+ pointer.position = Vector2(
+ clampf(
+ screen_position.x - bubble.position.x,
+ SPEECH_POINTER_HALF_WIDTH + 12.0,
+ bubble.size.x - SPEECH_POINTER_HALF_WIDTH - 12.0,
+ ),
+ bubble.size.y - SPEECH_POINTER_OVERLAP,
+ )
+ bubble.show()
+
+
+func _is_speech_world_occluded(
+ camera: Camera3D,
+ speaker: Player,
+ world_position: Vector3,
+) -> bool:
+ var world := camera.get_world_3d()
+ if world == null:
+ return false
+ var excluded: Array[RID] = []
+ if _player != null:
+ excluded.append(_player.get_rid())
+ if speaker != _player:
+ excluded.append(speaker.get_rid())
+ var query := PhysicsRayQueryParameters3D.create(
+ camera.global_position,
+ world_position,
+ )
+ query.collide_with_areas = false
+ query.collide_with_bodies = true
+ for _attempt: int in range(8):
+ query.exclude = excluded
+ var hit: Dictionary = world.direct_space_state.intersect_ray(query)
+ if hit.is_empty():
+ return false
+ var collider := hit.get("collider") as CollisionObject3D
+ if collider is Player:
+ excluded.append(collider.get_rid())
+ continue
+ return true
+ return false
+
+
+func _on_draft_changed(_value: String) -> void:
+ if _settings == null:
+ return
+ _settings.update_chat_preferences(
+ _entry.text,
+ _presentation_state == PresentationState.COLLAPSED,
+ )
+ _draft_save_timer.start()
+
+
+func _flush_draft() -> void:
+ if _settings == null or _entry == null:
+ return
+ _settings.update_chat_preferences(
+ _entry.text,
+ _presentation_state == PresentationState.COLLAPSED,
+ )
+ _settings.save_chat_preferences()
+
+
+func _set_status(text: String) -> void:
+ _status.text = text
+ _status.visible = not text.is_empty()
+
+
+func _layout_presentation(animate: bool) -> void:
+ if _panel == null:
+ return
+ var viewport_width := size.x
+ if viewport_width <= 0.0:
+ viewport_width = 1280.0
+ var viewport_height := size.y
+ if viewport_height <= 0.0:
+ viewport_height = 720.0
+ var layout_state := (
+ _visible_state_before_collapse
+ if _presentation_state == PresentationState.COLLAPSED
+ else _presentation_state
+ )
+ var collapsed := _presentation_state == PresentationState.COLLAPSED
+ var bottom_margin := minf(
+ BOTTOM_MARGIN,
+ maxf(MIN_TOP_MARGIN, (viewport_height - MIN_HISTORY_HEIGHT) * 0.25),
+ )
+ var bottom := viewport_height - bottom_margin
+ var height := COMPACT_HEIGHT
+ var panel_width := PANEL_WIDTH
+ var panel_x: float = 0.0
+ var panel_y: float = 0.0
+ var collapse_position := Vector2.ZERO
+ var height_position := Vector2.ZERO
+ var unread_position := Vector2.ZERO
+ if _mobile_mode:
+ panel_width = (
+ MOBILE_EXPANDED_WIDTH
+ if layout_state == PresentationState.EXPANDED
+ else MOBILE_COMPACT_WIDTH
+ )
+ panel_width = minf(
+ panel_width,
+ maxf(1.0, viewport_width - MOBILE_EDGE_MARGIN * 2.0),
+ )
+ height = (
+ MOBILE_EXPANDED_HEIGHT
+ if layout_state == PresentationState.EXPANDED
+ else MOBILE_COMPACT_HEIGHT
+ )
+ height = minf(
+ height,
+ maxf(MIN_HISTORY_HEIGHT, viewport_height - HANDLE_SIZE.y),
+ )
+ panel_x = (viewport_width - panel_width) * 0.5
+ panel_y = -(height - COLLAPSED_REVEAL_WIDTH) if collapsed else 0.0
+ var mobile_handle_y: float = (
+ COLLAPSED_REVEAL_WIDTH if collapsed else height
+ )
+ collapse_position = Vector2(
+ panel_x + panel_width - HANDLE_SIZE.x,
+ mobile_handle_y,
+ )
+ height_position = Vector2(
+ collapse_position.x - HANDLE_SIZE.x - HANDLE_GAP,
+ mobile_handle_y,
+ )
+ unread_position = collapse_position + Vector2(6.0, -18.0)
+ else:
+ if layout_state == PresentationState.EXPANDED:
+ height = maxf(
+ MIN_HISTORY_HEIGHT,
+ viewport_height - bottom_margin - EXPANDED_TOP_MARGIN,
+ )
+ height = minf(
+ height,
+ maxf(MIN_HISTORY_HEIGHT, bottom - MIN_TOP_MARGIN),
+ )
+ if _dock_right:
+ panel_x = (
+ viewport_width - COLLAPSED_REVEAL_WIDTH
+ if collapsed
+ else viewport_width - PANEL_WIDTH
+ )
+ else:
+ panel_x = (
+ -(PANEL_WIDTH - COLLAPSED_REVEAL_WIDTH)
+ if collapsed
+ else 0.0
+ )
+ panel_y = bottom - height
+ var handle_stack_top := panel_y + HANDLE_TOP_INSET
+ var handle_x: float = (
+ panel_x - HANDLE_SIZE.x
+ if _dock_right
+ else COLLAPSED_REVEAL_WIDTH if collapsed else PANEL_WIDTH
+ )
+ collapse_position = Vector2(
+ handle_x,
+ handle_stack_top + HANDLE_SIZE.y + HANDLE_GAP,
+ )
+ height_position = Vector2(handle_x, handle_stack_top)
+ var unread_offset_x := -18.0 if _dock_right else 6.0
+ unread_position = collapse_position + Vector2(
+ unread_offset_x,
+ -18.0,
+ )
+ var target_position := Vector2(panel_x, panel_y)
+ var target_size := Vector2(panel_width, height)
+ var clock_x: float = (
+ viewport_width - CLOCK_SIZE.x - CLOCK_EDGE_MARGIN
+ if _dock_right else CLOCK_EDGE_MARGIN
+ )
+ var clock_position := Vector2(
+ clock_x,
+ CLOCK_EDGE_MARGIN,
+ )
+ var fish_finder_effect_position := Vector2(
+ clock_position.x
+ + (CLOCK_SIZE.x - FISH_FINDER_EFFECT_ICON_SIZE.x) * 0.5,
+ clock_position.y + CLOCK_SIZE.y + 2.0,
+ )
+ var weather_icon_x: float = (
+ clock_position.x - WEATHER_ICON_GAP - WEATHER_ICON_SIZE.x
+ if _dock_right
+ else clock_position.x + CLOCK_SIZE.x + WEATHER_ICON_GAP
+ )
+ var weather_icon_position := Vector2(
+ weather_icon_x, clock_position.y
+ )
+ var hint_position := Vector2(
+ (
+ viewport_width - _hint.size.x - HINT_EDGE_MARGIN
+ if _dock_right
+ else HINT_EDGE_MARGIN
+ ),
+ viewport_height - _hint.size.y - HINT_EDGE_MARGIN,
+ )
+ if _height_tween != null and _height_tween.is_valid():
+ _height_tween.kill()
+ if not animate:
+ _panel.position = target_position
+ _panel.size = target_size
+ _clock_panel.position = clock_position
+ _clock_panel.size = CLOCK_SIZE
+ _fish_finder_effect_icon.position = fish_finder_effect_position
+ _fish_finder_effect_icon.size = FISH_FINDER_EFFECT_ICON_SIZE
+ _weather_icon.position = weather_icon_position
+ _weather_icon.size = WEATHER_ICON_SIZE
+ _collapse_button.position = collapse_position
+ _height_button.position = height_position
+ _unread_indicator.position = unread_position
+ _hint.position = hint_position
+ return
+ _height_tween = create_tween().set_parallel(true)
+ _height_tween.tween_property(
+ _panel, "position", target_position, UIMotion.CHAT_RESIZE_DURATION
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _panel, "size", target_size, UIMotion.CHAT_RESIZE_DURATION
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _clock_panel,
+ "position",
+ clock_position,
+ UIMotion.CHAT_RESIZE_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _fish_finder_effect_icon,
+ "position",
+ fish_finder_effect_position,
+ UIMotion.CHAT_RESIZE_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _weather_icon,
+ "position",
+ weather_icon_position,
+ UIMotion.CHAT_RESIZE_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _collapse_button,
+ "position",
+ collapse_position,
+ UIMotion.CHAT_RESIZE_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _height_button,
+ "position",
+ height_position,
+ UIMotion.CHAT_RESIZE_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.tween_property(
+ _unread_indicator,
+ "position",
+ unread_position,
+ UIMotion.CHAT_RESIZE_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _height_tween.finished.connect(func() -> void:
+ _scroll_history_to_bottom()
+ )
+ _hint.position = hint_position
+
+
+func _scroll_history_to_bottom() -> void:
+ if _history == null:
+ return
+ var scroll := _history.get_v_scroll_bar()
+ if scroll == null:
+ return
+ scroll.value = maxf(scroll.min_value, scroll.max_value - scroll.page)
+
+
+func _on_viewport_resized() -> void:
+ _layout_presentation(false)
+
+
+func _on_world_time_changed(
+ _time_hours: float,
+ _phase: WorldTimeService.Phase,
+) -> void:
+ if _clock_label != null and _world_time != null:
+ _clock_label.text = _world_time.get_clock_text()
+ if _weather_icon != null and _world_time != null:
+ _weather_icon.set_nighttime(_world_time.is_night_period())
+
+
+func _on_world_weather_changed(
+ weather: WorldWeatherService.Weather,
+ _seconds_remaining: float,
+) -> void:
+ if _weather_icon != null:
+ _weather_icon.set_weather(weather)
+
+
+func _on_exterior_handle_entered() -> void:
+ _panel_hovered = true
+ _update_panel_opacity(true)
+
+
+func _on_exterior_handle_exited() -> void:
+ _panel_hovered = false
+ _update_panel_opacity(true)
+
+
+func _on_peer_removed(peer_id: int) -> void:
+ var state: Dictionary = _speech.get(peer_id, {})
+ var bubble := state.get("bubble") as PanelContainer
+ if bubble != null:
+ bubble.queue_free()
+ _speech.erase(peer_id)
diff --git a/ui/chat_ui.gd.uid b/ui/chat_ui.gd.uid
new file mode 100644
index 0000000..3b5a459
--- /dev/null
+++ b/ui/chat_ui.gd.uid
@@ -0,0 +1 @@
+uid://canbrvh1cqkie
diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd
new file mode 100644
index 0000000..4bebdfe
--- /dev/null
+++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd
@@ -0,0 +1,435 @@
+class_name BubbleHotbarSlot
+extends Button
+
+signal item_hovered(slot_index: int, item_id: StringName)
+signal item_hover_ended(slot_index: int)
+signal item_drag_started
+signal item_drag_finished
+
+const ItemCatalogType = preload("res://items/item_catalog.gd")
+const ItemDataType = preload("res://items/item_data.gd")
+const PlayerBagType = preload("res://inventory/player_bag.gd")
+const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+
+const SELECTED_SCALE: float = 1.12
+const HOVER_SCALE: float = 1.025
+const MAXIMUM_SCALE: float = 1.14
+const SELECTED_LIFT: float = 4.0
+const HOVER_LIFT: float = 0.75
+const PRESENTATION_RESPONSE: float = 18.0
+const IDLE_AMPLITUDE: float = 1.0
+const IDLE_PERIOD: float = 6.0
+const DEFORMATION_PERIOD: float = 6.8
+const DEFORMATION_PHASE_MULTIPLIER: float = 1.37
+const DEFORMATION_X_AMPLITUDE: float = 0.009
+const DEFORMATION_Y_AMPLITUDE: float = 0.007
+
+@export_range(0, PlayerHotbarType.SLOT_COUNT - 1, 1) var slot_index: int = 0
+@export_group("Authored Layout")
+@export var desktop_size: Vector2 = Vector2(78.0, 78.0)
+@export var compact_size: Vector2 = Vector2(54.0, 54.0)
+@export var desktop_anchor: Vector2 = Vector2.ZERO
+@export var compact_anchor: Vector2 = Vector2.ZERO
+@export_group("Motion")
+@export_range(0.0, TAU, 0.01) var motion_phase: float = 0.0
+@export_group("Style")
+@export var profile: BubbleMenuProfile
+
+@onready var _item_icon: TextureRect = %ItemIcon
+@onready var _quantity_label: Label = %QuantityLabel
+
+var _hotbar: PlayerHotbarType
+var _bag: PlayerBagType
+var _catalog: ItemCatalogType
+var _fish_inventory: FishInventoryType
+var _drag_enabled: bool = false
+var _drag_in_progress: bool = false
+var _selected: bool = false
+var _hovered: bool = false
+var _empty: bool = true
+var _selection_amount: float = 0.0
+var _hover_amount: float = 0.0
+var _base_position: Vector2 = Vector2.ZERO
+var _presented_size: Vector2 = Vector2.ZERO
+var _compact: bool = false
+var _presentation_initialized: bool = false
+var _controller_preview_active: bool = false
+var _controller_preview_texture: Texture2D
+
+
+func _ready() -> void:
+ focus_mode = Control.FOCUS_NONE
+ toggle_mode = false
+ pressed.connect(_select_slot)
+ mouse_entered.connect(_on_mouse_entered)
+ mouse_exited.connect(_on_mouse_exited)
+ resized.connect(_update_pivot)
+ _update_pivot()
+ _apply_style()
+
+
+func setup(
+ hotbar: PlayerHotbarType,
+ bag: PlayerBagType,
+ catalog: ItemCatalogType,
+ fish_inventory: FishInventoryType,
+) -> void:
+ _hotbar = hotbar
+ _bag = bag
+ _catalog = catalog
+ _fish_inventory = fish_inventory
+ refresh()
+
+
+func apply_layout(compact: bool) -> void:
+ _compact = compact
+ _presented_size = compact_size if compact else desktop_size
+ var center: Vector2 = compact_anchor if compact else desktop_anchor
+ custom_minimum_size = _presented_size
+ size = _presented_size
+ _base_position = center - _presented_size * 0.5
+ _update_content_layout()
+ _update_pivot()
+ _apply_style()
+ if not _presentation_initialized:
+ _selection_amount = 1.0 if _selected else 0.0
+ _hover_amount = 1.0 if _hovered else 0.0
+ _presentation_initialized = true
+
+
+func advance_presentation(delta: float, elapsed: float) -> void:
+ var response_weight: float = 1.0 - exp(-PRESENTATION_RESPONSE * delta)
+ _selection_amount = lerpf(
+ _selection_amount,
+ 1.0 if _selected else 0.0,
+ response_weight
+ )
+ _hover_amount = lerpf(
+ _hover_amount,
+ 1.0 if _hovered else 0.0,
+ response_weight
+ )
+ var idle_offset: float = sin(
+ elapsed / IDLE_PERIOD * TAU + motion_phase
+ ) * IDLE_AMPLITUDE
+ var lift: float = (
+ _selection_amount * SELECTED_LIFT
+ + _hover_amount * HOVER_LIFT
+ )
+ position = _base_position + Vector2(0.0, idle_offset - lift)
+ var presentation_scale: float = minf(
+ MAXIMUM_SCALE,
+ 1.0
+ + _selection_amount * (SELECTED_SCALE - 1.0)
+ + _hover_amount * (HOVER_SCALE - 1.0)
+ )
+ var breath: float = sin(
+ elapsed / DEFORMATION_PERIOD * TAU
+ + motion_phase * DEFORMATION_PHASE_MULTIPLIER
+ )
+ var deformation := Vector2(
+ 1.0 + breath * DEFORMATION_X_AMPLITUDE,
+ 1.0 - breath * DEFORMATION_Y_AMPLITUDE
+ )
+ scale = deformation * presentation_scale
+
+
+func set_drag_enabled(enabled: bool) -> void:
+ _drag_enabled = enabled
+ mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if enabled
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ if not enabled:
+ _hovered = false
+
+
+func set_controller_placement_preview(
+ active: bool,
+ texture: Texture2D,
+) -> void:
+ _controller_preview_active = active
+ _controller_preview_texture = texture
+ refresh()
+
+
+func refresh() -> void:
+ if _hotbar == null:
+ return
+ var item_id: StringName = _hotbar.get_item_id(slot_index)
+ var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index)
+ var item: ItemDataType = (
+ _catalog.get_item_by_id(item_id)
+ if _catalog != null and not item_id.is_empty()
+ else null
+ )
+ var fish_catch: FishCatchType = (
+ _fish_inventory.get_catch_by_id(catch_id)
+ if _fish_inventory != null and not catch_id.is_empty()
+ else null
+ )
+ var assigned_texture: Texture2D = (
+ fish_catch.fish.display_texture
+ if fish_catch != null
+ else item.icon if item != null else null
+ )
+ _item_icon.texture = (
+ _controller_preview_texture
+ if _controller_preview_active
+ else assigned_texture
+ )
+ var quantity: int = (
+ _bag.get_quantity(item_id)
+ if _bag != null and not item_id.is_empty()
+ else 0
+ )
+ var quantity_text: String = (
+ "×%d" % quantity
+ if item != null and item.stackable and quantity > 1
+ else ""
+ )
+ _quantity_label.text = quantity_text
+ _quantity_label.visible = (
+ not _controller_preview_active and not quantity_text.is_empty()
+ )
+ tooltip_text = (
+ "%s · %.1f lb" % [
+ FishQualityType.qualified_name(
+ fish_catch.fish.display_name,
+ fish_catch.quality,
+ ),
+ fish_catch.weight_lb,
+ ]
+ if fish_catch != null
+ else item.display_name if item != null else "empty hotbar slot"
+ )
+ var was_selected: bool = _selected
+ var was_empty: bool = _empty
+ _selected = slot_index == _hotbar.get_selected_slot()
+ _empty = (
+ not _controller_preview_active
+ and item == null
+ and fish_catch == null
+ )
+ if was_selected != _selected or was_empty != _empty:
+ _apply_style()
+
+
+func _update_content_layout() -> void:
+ if not is_node_ready():
+ return
+ if _compact:
+ _item_icon.position = Vector2(11.0, 11.0)
+ _item_icon.size = Vector2(34.0, 34.0)
+ _quantity_label.position = Vector2(25.0, 35.0)
+ _quantity_label.size = Vector2(25.0, 17.0)
+ _quantity_label.add_theme_font_size_override("font_size", 10)
+ else:
+ _item_icon.position = Vector2(17.0, 16.0)
+ _item_icon.size = Vector2(46.0, 46.0)
+ _quantity_label.position = Vector2(39.0, 54.0)
+ _quantity_label.size = Vector2(32.0, 20.0)
+ _quantity_label.add_theme_font_size_override("font_size", 12)
+
+
+func _apply_style() -> void:
+ if profile == null:
+ return
+ var normal_fill: Color = profile.normal_fill
+ normal_fill.a = 1.0
+ var selected_fill: Color = normal_fill
+ if _controller_preview_active:
+ selected_fill = normal_fill.lightened(0.22)
+ elif _selected:
+ selected_fill = normal_fill.lightened(0.12)
+ add_theme_stylebox_override(
+ "normal",
+ _make_style(
+ selected_fill,
+ 5 if _selected else 2,
+ 0.38 if _selected else 0.20,
+ )
+ )
+ add_theme_stylebox_override(
+ "hover",
+ _make_style(
+ selected_fill.lightened(0.055),
+ 5 if _selected else 3,
+ 0.40 if _selected else 0.29,
+ )
+ )
+ add_theme_stylebox_override(
+ "pressed",
+ _make_style(
+ selected_fill.lightened(0.085),
+ 6,
+ 0.44,
+ )
+ )
+ add_theme_stylebox_override(
+ "focus",
+ get_theme_stylebox("normal")
+ )
+ add_theme_stylebox_override(
+ "disabled",
+ _make_style(
+ normal_fill,
+ 1,
+ 0.12,
+ )
+ )
+ add_theme_color_override("font_color", profile.text_color)
+ add_theme_color_override("font_hover_color", profile.text_hover_color)
+ add_theme_color_override("font_pressed_color", profile.text_pressed_color)
+ add_theme_color_override("font_disabled_color", profile.text_disabled_color)
+
+
+func _make_style(
+ fill_color: Color,
+ shadow_size: int,
+ shadow_alpha: float,
+) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = fill_color
+ style.set_border_width_all(0)
+ var radius: int = ceili(maxf(_presented_size.x, _presented_size.y) * 0.5)
+ style.corner_radius_top_left = radius
+ style.corner_radius_top_right = radius
+ style.corner_radius_bottom_right = radius
+ style.corner_radius_bottom_left = radius
+ style.shadow_color = Color(0.015, 0.06, 0.09, shadow_alpha)
+ style.shadow_size = shadow_size
+ style.shadow_offset = Vector2(0.0, 2.0)
+ return style
+
+
+func _has_point(point: Vector2) -> bool:
+ var radius: Vector2 = size * 0.5
+ if radius.x <= 0.0 or radius.y <= 0.0:
+ return false
+ var normalized: Vector2 = (point - radius) / radius
+ return normalized.length_squared() <= 1.0
+
+
+func _gui_input(event: InputEvent) -> void:
+ if (
+ _drag_enabled
+ and event is InputEventMouseButton
+ and event.button_index == MOUSE_BUTTON_RIGHT
+ and event.pressed
+ and _hotbar != null
+ ):
+ _hotbar.clear_slot(slot_index)
+ accept_event()
+
+
+func _get_drag_data(_at_position: Vector2) -> Variant:
+ if not _drag_enabled or _hotbar == null:
+ return null
+ var item_id: StringName = _hotbar.get_item_id(slot_index)
+ var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index)
+ if item_id.is_empty() and catch_id.is_empty():
+ return null
+ var item: ItemDataType = _catalog.get_item_by_id(item_id)
+ var fish_catch: FishCatchType = (
+ _fish_inventory.get_catch_by_id(catch_id)
+ if _fish_inventory != null and not catch_id.is_empty()
+ else null
+ )
+ var preview := TextureRect.new()
+ preview.custom_minimum_size = Vector2(44.0, 44.0)
+ preview.texture = (
+ fish_catch.fish.display_texture
+ if fish_catch != null
+ else item.icon if item != null else null
+ )
+ preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ preview.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ set_drag_preview(preview)
+ _drag_in_progress = true
+ item_drag_started.emit()
+ return {
+ "kind": "hotbar_slot",
+ "slot_index": slot_index,
+ }
+
+
+func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
+ if not _drag_enabled or typeof(data) != TYPE_DICTIONARY:
+ return false
+ var payload: Dictionary = data
+ var kind: String = str(payload.get("kind", ""))
+ if kind == "hotbar_slot":
+ var source_index: int = int(payload.get("slot_index", -1))
+ return (
+ source_index >= 0
+ and source_index < PlayerHotbarType.SLOT_COUNT
+ and source_index != slot_index
+ )
+ if kind == "bag_item":
+ var item_id: StringName = StringName(str(payload.get("item_id", "")))
+ if _bag == null or not _bag.owns_item(item_id) or _catalog == null:
+ return false
+ var item: ItemDataType = _catalog.get_item_by_id(item_id)
+ return item != null and item.is_valid() and item.hotbar_allowed
+ if kind == "cooler_fish":
+ var catch_id: StringName = StringName(str(payload.get("catch_id", "")))
+ return (
+ _fish_inventory != null
+ and _fish_inventory.contains_catch_id(catch_id)
+ )
+ return false
+
+
+func _drop_data(_at_position: Vector2, data: Variant) -> void:
+ if not _can_drop_data(Vector2.ZERO, data) or _hotbar == null:
+ return
+ var payload: Dictionary = data
+ if str(payload.get("kind", "")) == "hotbar_slot":
+ _hotbar.swap_slots(int(payload["slot_index"]), slot_index)
+ elif str(payload.get("kind", "")) == "cooler_fish":
+ _hotbar.assign_fish(
+ slot_index,
+ StringName(str(payload["catch_id"])),
+ )
+ else:
+ _hotbar.assign_item(
+ slot_index,
+ StringName(str(payload["item_id"]))
+ )
+
+
+func _select_slot() -> void:
+ if _hotbar != null:
+ _hotbar.select_slot(slot_index)
+ refresh()
+
+
+func _on_mouse_entered() -> void:
+ _hovered = true
+ if _hotbar != null:
+ var identity: StringName = _hotbar.get_item_id(slot_index)
+ if identity.is_empty():
+ identity = _hotbar.get_fish_catch_id(slot_index)
+ item_hovered.emit(slot_index, identity)
+
+
+func _on_mouse_exited() -> void:
+ _hovered = false
+ item_hover_ended.emit(slot_index)
+
+
+func _update_pivot() -> void:
+ pivot_offset = size * 0.5
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_DRAG_END and _drag_in_progress:
+ _drag_in_progress = false
+ item_drag_finished.emit()
diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd.uid b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd.uid
new file mode 100644
index 0000000..9afbb1c
--- /dev/null
+++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd.uid
@@ -0,0 +1 @@
+uid://bvkhp5ly74750
diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.tscn b/ui/components/bubble_hotbar/bubble_hotbar_slot.tscn
new file mode 100644
index 0000000..68beef3
--- /dev/null
+++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.tscn
@@ -0,0 +1,41 @@
+[gd_scene load_steps=4 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd" id="1_slot"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="2_profile"]
+[ext_resource type="Theme" path="res://ui/game_theme.tres" id="3_theme"]
+
+[node name="BubbleHotbarSlot" type="Button"]
+custom_minimum_size = Vector2(78, 78)
+focus_mode = 0
+mouse_default_cursor_shape = 2
+theme = ExtResource("3_theme")
+script = ExtResource("1_slot")
+profile = ExtResource("2_profile")
+
+[node name="ItemIcon" type="TextureRect" parent="."]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 17.0
+offset_top = 16.0
+offset_right = 63.0
+offset_bottom = 62.0
+mouse_filter = 2
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="QuantityLabel" type="Label" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 39.0
+offset_top = 54.0
+offset_right = 71.0
+offset_bottom = 74.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
+theme_override_colors/font_outline_color = Color(0.91, 0.98, 1, 0.9)
+theme_override_constants/outline_size = 1
+theme_override_font_sizes/font_size = 12
+horizontal_alignment = 2
+vertical_alignment = 1
diff --git a/ui/components/bubble_menu/bag_item_sprite.gd b/ui/components/bubble_menu/bag_item_sprite.gd
new file mode 100644
index 0000000..d89f388
--- /dev/null
+++ b/ui/components/bubble_menu/bag_item_sprite.gd
@@ -0,0 +1,128 @@
+class_name BagItemSprite
+extends ItemDragSource
+
+signal drag_started
+signal drag_finished
+
+@onready var _visual_root: Control = %VisualRoot
+@onready var _item_texture: TextureRect = %ItemTexture
+@onready var _quantity_badge: PanelContainer = %QuantityBadge
+@onready var _quantity_label: Label = %QuantityLabel
+@onready var _compatibility_marker: Label = %CompatibilityMarker
+
+var _motion_phase: float = 0.0
+var _depth_scale: float = 1.0
+var _selected: bool = false
+var _hovered: bool = false
+var _dragging: bool = false
+
+
+func _ready() -> void:
+ mouse_entered.connect(_set_hovered.bind(true))
+ mouse_exited.connect(_set_hovered.bind(false))
+ focus_entered.connect(_refresh_style)
+ focus_exited.connect(_refresh_style)
+ resized.connect(_update_visual_pivot)
+ _update_visual_pivot()
+ _apply_quantity_style()
+ _refresh_style()
+
+
+func configure_item(
+ new_item_id: StringName,
+ new_item_name: String,
+ new_icon: Texture2D,
+ quantity: int,
+ hotbar_compatible: bool,
+ phase: float,
+ depth_scale: float,
+) -> void:
+ setup(new_item_id, new_item_name, new_icon)
+ _item_texture.texture = new_icon
+ _quantity_label.text = "×%d" % quantity
+ _quantity_badge.visible = quantity > 1
+ _compatibility_marker.visible = not hotbar_compatible
+ _motion_phase = phase
+ _depth_scale = depth_scale
+
+
+func set_selected(selected: bool) -> void:
+ _selected = selected
+ _refresh_style()
+
+
+func advance_presentation(elapsed: float, motion_enabled: bool) -> void:
+ if not motion_enabled or _dragging:
+ return
+ var bob: float = sin(elapsed * TAU / 7.4 + _motion_phase) * 0.9
+ var tilt: float = sin(
+ elapsed * TAU / 10.2 + _motion_phase * 1.29
+ ) * 0.014
+ _visual_root.position = Vector2(0.0, bob)
+ _visual_root.rotation = tilt
+ var emphasis: float = 1.04 if _hovered or has_focus() else 1.0
+ _visual_root.scale = Vector2.ONE * _depth_scale * emphasis
+
+
+func _get_drag_data(at_position: Vector2) -> Variant:
+ var payload: Variant = super(at_position)
+ if payload != null:
+ _dragging = true
+ drag_started.emit()
+ return payload
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_DRAG_END and _dragging:
+ _dragging = false
+ drag_finished.emit()
+
+
+func _has_point(point: Vector2) -> bool:
+ var radius: Vector2 = size * Vector2(0.46, 0.42)
+ if radius.x <= 0.0 or radius.y <= 0.0:
+ return false
+ var normalized: Vector2 = (point - size * 0.5) / radius
+ return normalized.length_squared() <= 1.0
+
+
+func _set_hovered(hovered: bool) -> void:
+ _hovered = hovered
+ _refresh_style()
+
+
+func _update_visual_pivot() -> void:
+ _visual_root.pivot_offset = size * 0.5
+
+
+func _refresh_style() -> void:
+ var style := StyleBoxFlat.new()
+ style.bg_color = (
+ UtilityPageStyle.OCEAN_SELECTED
+ if _selected else Color(UtilityPageStyle.OCEAN_FIELD, 0.48)
+ )
+ if _selected:
+ style.bg_color = UtilityPageStyle.OCEAN_SELECTED
+ if has_focus():
+ style.bg_color = UtilityPageStyle.OCEAN_BUTTON_HOVER
+ elif _hovered:
+ style.bg_color = Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.88)
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(64)
+ for state: StringName in [
+ &"normal",
+ &"hover",
+ &"focus",
+ &"pressed",
+ ]:
+ add_theme_stylebox_override(state, style)
+
+
+func _apply_quantity_style() -> void:
+ var style := StyleBoxFlat.new()
+ style.bg_color = UtilityPageStyle.OCEAN_SELECTED
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(18)
+ style.content_margin_left = 5.0
+ style.content_margin_right = 5.0
+ _quantity_badge.add_theme_stylebox_override("panel", style)
diff --git a/ui/components/bubble_menu/bag_item_sprite.gd.uid b/ui/components/bubble_menu/bag_item_sprite.gd.uid
new file mode 100644
index 0000000..7ee12e5
--- /dev/null
+++ b/ui/components/bubble_menu/bag_item_sprite.gd.uid
@@ -0,0 +1 @@
+uid://dgbagitemsprite2026
diff --git a/ui/components/bubble_menu/bag_item_sprite.tscn b/ui/components/bubble_menu/bag_item_sprite.tscn
new file mode 100644
index 0000000..2643931
--- /dev/null
+++ b/ui/components/bubble_menu/bag_item_sprite.tscn
@@ -0,0 +1,81 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bag_item_sprite.gd" id="1_script"]
+
+[node name="BagItemSprite" type="Button"]
+custom_minimum_size = Vector2(118, 86)
+focus_mode = 1
+mouse_default_cursor_shape = 2
+script = ExtResource("1_script")
+
+[node name="VisualRoot" type="Control" parent="."]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="ItemTexture" type="TextureRect" parent="VisualRoot"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 10.0
+offset_top = 8.0
+offset_right = -10.0
+offset_bottom = -8.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="QuantityBadge" type="PanelContainer" parent="VisualRoot"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 3
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = -42.0
+offset_top = -28.0
+offset_right = -2.0
+offset_bottom = -2.0
+grow_horizontal = 0
+grow_vertical = 0
+mouse_filter = 2
+
+[node name="QuantityLabel" type="Label" parent="VisualRoot/QuantityBadge"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 15
+text = "×2"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="CompatibilityMarker" type="Label" parent="VisualRoot"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -28.0
+offset_top = 0.0
+offset_right = -2.0
+offset_bottom = 24.0
+grow_horizontal = 0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 17
+text = "×"
+horizontal_alignment = 1
diff --git a/ui/components/bubble_menu/bubble_button.gd b/ui/components/bubble_menu/bubble_button.gd
new file mode 100644
index 0000000..f01d50b
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_button.gd
@@ -0,0 +1,260 @@
+class_name BubbleButton
+extends Button
+
+const ICON_FILL_RATIO: float = 0.76
+
+@export var profile: BubbleMenuProfile
+
+@export_group("Authored Layout")
+@export var neutral_size: Vector2 = Vector2(120.0, 116.0)
+@export var desktop_anchor: Vector2 = Vector2.ZERO
+@export var compact_anchor: Vector2 = Vector2.ZERO
+@export var compact_minimum_size: Vector2 = Vector2.ZERO
+
+@export_group("Typography")
+@export var label_control_path: NodePath
+@export_range(1, 256, 1) var minimum_font_size: int = 14
+@export_range(1, 256, 1) var maximum_font_size: int = 32
+
+@export_group("Motion")
+@export_range(0.0, 32.0, 0.1) var horizontal_amplitude: float = 1.8
+@export_range(0.0, 32.0, 0.1) var vertical_amplitude: float = 4.0
+@export_range(0.1, 30.0, 0.1) var motion_period: float = 5.0
+@export_range(0.0, TAU, 0.01) var motion_phase: float = 0.0
+@export_range(0.0, 0.2, 0.001) var deformation_amplitude: float = 0.016
+@export_range(0.1, 30.0, 0.1) var deformation_period: float = 6.0
+
+var neutral_position: Vector2 = Vector2.ZERO
+var presented_size: Vector2 = Vector2.ZERO
+var emphasis: float = 0.0
+var _hovered: bool = false
+var _focused: bool = false
+
+
+func _ready() -> void:
+ mouse_entered.connect(_set_hovered.bind(true))
+ mouse_exited.connect(_set_hovered.bind(false))
+ focus_entered.connect(_set_focused.bind(true))
+ focus_exited.connect(_set_focused.bind(false))
+ resized.connect(_update_pivot)
+ _update_pivot()
+ apply_profile()
+ _apply_icon_presentation(neutral_size)
+
+
+func _gui_input(event: InputEvent) -> void:
+ if _adjustment_direction(self) == 0:
+ return
+ var direction: int = 0
+ if event.is_action_pressed(&"ui_left"):
+ direction = -1
+ elif event.is_action_pressed(&"ui_right"):
+ direction = 1
+ if direction == 0:
+ return
+ var adjustment_button: BaseButton = _find_adjustment_button(direction)
+ if adjustment_button == null:
+ return
+ adjustment_button.pressed.emit()
+ accept_event()
+
+
+func _find_adjustment_button(direction: int) -> BaseButton:
+ var parent_node: Node = get_parent()
+ if parent_node == null:
+ return null
+ for child: Node in parent_node.get_children():
+ var button := child as BaseButton
+ if (
+ button != null
+ and button.visible
+ and not button.disabled
+ and _adjustment_direction(button) == direction
+ ):
+ return button
+ return null
+
+
+func _adjustment_direction(button: BaseButton) -> int:
+ var descriptions: Array[String] = [
+ button.text.strip_edges().to_lower(),
+ button.tooltip_text.strip_edges().to_lower(),
+ button.accessibility_name.strip_edges().to_lower(),
+ ]
+ for description: String in descriptions:
+ if description in ["-", "−", "minus", "decrease"]:
+ return -1
+ if description in ["+", "plus", "increase"]:
+ return 1
+ return 0
+
+
+func apply_layout(
+ center: Vector2,
+ bubble_size: Vector2,
+ font_size_ratio: float,
+) -> void:
+ position = center - bubble_size * 0.5
+ size = bubble_size
+ neutral_position = position
+ presented_size = bubble_size
+ _update_pivot()
+ _apply_icon_presentation(bubble_size)
+ var label_control: Control = get_label_control()
+ var font_size := clampi(
+ roundi(minf(bubble_size.x, bubble_size.y) * font_size_ratio),
+ minimum_font_size,
+ maximum_font_size
+ )
+ label_control.add_theme_font_size_override("font_size", font_size)
+
+
+func get_layout_size(layout_scale: float, compact: bool) -> Vector2:
+ var bubble_size: Vector2 = neutral_size * layout_scale
+ if compact:
+ bubble_size.x = maxf(bubble_size.x, compact_minimum_size.x)
+ bubble_size.y = maxf(bubble_size.y, compact_minimum_size.y)
+ if _uses_icon_only_presentation():
+ var diameter: float = maxf(bubble_size.x, bubble_size.y)
+ bubble_size = Vector2(diameter, diameter)
+ return bubble_size
+
+
+func get_authored_anchor(compact: bool) -> Vector2:
+ return compact_anchor if compact else desktop_anchor
+
+
+func get_label_control() -> Control:
+ if not label_control_path.is_empty():
+ var custom_label := get_node_or_null(label_control_path) as Control
+ if custom_label != null:
+ return custom_label
+ return self
+
+
+func _uses_icon_only_presentation() -> bool:
+ return icon != null and text.is_empty() and label_control_path.is_empty()
+
+
+func _apply_icon_presentation(bubble_size: Vector2) -> void:
+ if not _uses_icon_only_presentation():
+ return
+ alignment = HORIZONTAL_ALIGNMENT_CENTER
+ icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
+ expand_icon = true
+ texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ add_theme_constant_override(
+ "icon_max_width",
+ maxi(1, roundi(minf(bubble_size.x, bubble_size.y) * ICON_FILL_RATIO)),
+ )
+
+
+func advance_emphasis(delta: float) -> void:
+ var target: float = 1.0 if _hovered or _focused else 0.0
+ emphasis = move_toward(
+ emphasis,
+ target,
+ profile.emphasis_speed * delta
+ )
+
+
+func calculate_target(
+ elapsed: float,
+ layout_scale: float,
+ motion_scale: float,
+) -> Vector2:
+ var phase: float = (
+ elapsed / motion_period * TAU
+ + motion_phase
+ )
+ var idle_offset := Vector2(
+ sin(phase * 0.73 + motion_phase) * horizontal_amplitude,
+ sin(phase) * vertical_amplitude
+ ) * layout_scale * motion_scale
+ idle_offset.y -= (
+ profile.hover_focus_lift
+ * emphasis
+ * layout_scale
+ )
+ return neutral_position + idle_offset
+
+
+func calculate_visual_scale(
+ elapsed: float,
+ motion_scale: float,
+) -> Vector2:
+ var deformation_phase: float = (
+ elapsed / deformation_period * TAU
+ + motion_phase * 1.37
+ )
+ var deformation_amount: float = (
+ sin(deformation_phase)
+ * deformation_amplitude
+ * motion_scale
+ * lerpf(1.0, profile.emphasized_deformation_scale, emphasis)
+ )
+ if _uses_icon_only_presentation():
+ deformation_amount = 0.0
+ var hover_scale: float = lerpf(
+ 1.0,
+ profile.hover_focus_scale,
+ emphasis
+ )
+ return Vector2(
+ 1.0 + deformation_amount,
+ 1.0 - deformation_amount
+ ) * hover_scale
+
+
+func get_visual_radius(visual_scale: Vector2) -> float:
+ var visual_size: Vector2 = presented_size * visual_scale
+ return (visual_size.x + visual_size.y) * 0.25
+
+
+func apply_presentation(
+ target_position: Vector2,
+ visual_scale: Vector2,
+ position_weight: float,
+) -> void:
+ position = position.lerp(target_position, position_weight)
+ scale = visual_scale
+
+
+func _has_point(point: Vector2) -> bool:
+ var radius: Vector2 = size * 0.5
+ if radius.x <= 0.0 or radius.y <= 0.0:
+ return false
+ var normalized: Vector2 = (point - radius) / radius
+ return normalized.length_squared() <= 1.0
+
+
+func _set_hovered(value: bool) -> void:
+ _hovered = value
+
+
+func _set_focused(value: bool) -> void:
+ _focused = value
+
+
+func _update_pivot() -> void:
+ pivot_offset = size * 0.5
+
+
+func apply_profile() -> void:
+ if profile == null:
+ return
+ add_theme_stylebox_override("normal", profile.make_normal_style())
+ var hover_style: StyleBoxFlat = profile.make_hover_style()
+ add_theme_stylebox_override("hover", hover_style)
+ add_theme_stylebox_override("focus", hover_style)
+ add_theme_stylebox_override("pressed", profile.make_pressed_style())
+ add_theme_stylebox_override("disabled", profile.make_disabled_style())
+ add_theme_color_override("font_color", profile.text_color)
+ add_theme_color_override("font_hover_color", profile.text_hover_color)
+ add_theme_color_override("font_focus_color", profile.text_hover_color)
+ add_theme_color_override("font_pressed_color", profile.text_pressed_color)
+ add_theme_color_override("font_disabled_color", profile.text_disabled_color)
+ var label_control: Control = get_label_control()
+ if label_control != self:
+ label_control.add_theme_color_override("font_color", profile.text_color)
diff --git a/ui/components/bubble_menu/bubble_button.gd.uid b/ui/components/bubble_menu/bubble_button.gd.uid
new file mode 100644
index 0000000..ede0b1d
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_button.gd.uid
@@ -0,0 +1 @@
+uid://d15kke1iwoueg
diff --git a/ui/components/bubble_menu/bubble_button.tscn b/ui/components/bubble_menu/bubble_button.tscn
new file mode 100644
index 0000000..7fdc596
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_button.tscn
@@ -0,0 +1,9 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_button.gd" id="1_button"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="2_profile"]
+
+[node name="BubbleButton" type="Button"]
+custom_minimum_size = Vector2(120, 116)
+script = ExtResource("1_button")
+profile = ExtResource("2_profile")
diff --git a/ui/components/bubble_menu/bubble_cluster.gd b/ui/components/bubble_menu/bubble_cluster.gd
new file mode 100644
index 0000000..0ad2624
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_cluster.gd
@@ -0,0 +1,114 @@
+class_name BubbleCluster
+extends Control
+
+const ControllerFocusNavigationType = preload(
+ "res://ui/controller_focus_navigation.gd"
+)
+
+@export var profile: BubbleMenuProfile
+@export var desktop_reference_size: Vector2 = Vector2(396.0, 318.0)
+@export var compact_reference_size: Vector2 = Vector2(294.0, 200.0)
+@export_range(0.0, 1.0, 0.01) var motion_scale: float = 1.0
+
+var _bubbles: Array[BubbleButton] = []
+var _elapsed: float = 0.0
+var _layout_scale: float = 1.0
+
+
+func configure(bubbles: Array[BubbleButton]) -> void:
+ _bubbles = bubbles
+ for index: int in _bubbles.size():
+ var bubble: BubbleButton = _bubbles[index]
+ if bubble.profile == null:
+ bubble.profile = profile
+ bubble.apply_profile()
+
+
+func apply_layout(field_size: Vector2, compact: bool) -> void:
+ _layout_scale = minf(
+ field_size.x / desktop_reference_size.x,
+ field_size.y / desktop_reference_size.y
+ )
+ var authored_extent: Vector2 = desktop_reference_size * _layout_scale
+ var layout_origin := Vector2(
+ (field_size.x - authored_extent.x) * 0.5,
+ (field_size.y - authored_extent.y) * 0.5
+ )
+ for bubble: BubbleButton in _bubbles:
+ var bubble_size: Vector2 = bubble.get_layout_size(
+ _layout_scale,
+ compact
+ )
+ var center: Vector2 = (
+ layout_origin
+ + bubble.get_authored_anchor(compact) * _layout_scale
+ )
+ bubble.apply_layout(
+ center,
+ bubble_size,
+ profile.font_size_ratio
+ )
+ ControllerFocusNavigationType.configure_spatial_neighbors(_bubbles)
+
+
+func advance_motion(delta: float) -> void:
+ _elapsed = fmod(_elapsed + delta, 120.0)
+ var targets: Array[Vector2] = []
+ var visual_radii: Array[float] = []
+ var visual_scales: Array[Vector2] = []
+ for bubble: BubbleButton in _bubbles:
+ bubble.advance_emphasis(delta)
+ var target: Vector2 = bubble.calculate_target(
+ _elapsed,
+ _layout_scale,
+ motion_scale
+ )
+ var visual_scale: Vector2 = bubble.calculate_visual_scale(
+ _elapsed,
+ motion_scale
+ )
+ targets.append(target)
+ visual_scales.append(visual_scale)
+ visual_radii.append(bubble.get_visual_radius(visual_scale))
+ for first_index: int in _bubbles.size():
+ for second_index: int in range(first_index + 1, _bubbles.size()):
+ var first_center: Vector2 = (
+ targets[first_index]
+ + _bubbles[first_index].presented_size * 0.5
+ )
+ var second_center: Vector2 = (
+ targets[second_index]
+ + _bubbles[second_index].presented_size * 0.5
+ )
+ var center_delta: Vector2 = second_center - first_center
+ var distance: float = center_delta.length()
+ var desired_distance: float = (
+ visual_radii[first_index]
+ + visual_radii[second_index]
+ + profile.contact_gap * _layout_scale
+ )
+ if distance >= desired_distance:
+ continue
+ var direction := (
+ center_delta / distance
+ if distance > 0.001
+ else Vector2.RIGHT.rotated(float(first_index + 1))
+ )
+ var correction: Vector2 = (
+ direction
+ * minf(
+ (desired_distance - distance) * 0.5,
+ profile.maximum_separation * _layout_scale
+ )
+ )
+ targets[first_index] -= correction
+ targets[second_index] += correction
+ var position_weight: float = 1.0 - exp(
+ -profile.position_response * delta
+ )
+ for index: int in _bubbles.size():
+ _bubbles[index].apply_presentation(
+ targets[index],
+ visual_scales[index],
+ position_weight
+ )
diff --git a/ui/components/bubble_menu/bubble_cluster.gd.uid b/ui/components/bubble_menu/bubble_cluster.gd.uid
new file mode 100644
index 0000000..08becd9
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_cluster.gd.uid
@@ -0,0 +1 @@
+uid://cvqif55pw6r32
diff --git a/ui/components/bubble_menu/bubble_confirmation_page.gd b/ui/components/bubble_menu/bubble_confirmation_page.gd
new file mode 100644
index 0000000..fde0f34
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_confirmation_page.gd
@@ -0,0 +1,247 @@
+class_name BubbleConfirmationPage
+extends Control
+
+signal confirmed
+signal cancelled
+
+enum InitialFocus {
+ CONFIRM,
+ CANCEL,
+}
+
+@export var cluster_path: NodePath = ^"BubbleCluster"
+@export var message_path: NodePath = ^"BubbleCluster/MessageBubble"
+@export var message_label_path: NodePath = (
+ ^"BubbleCluster/MessageBubble/MessageLabel"
+)
+@export var confirm_path: NodePath = ^"BubbleCluster/ConfirmButton"
+@export var confirm_label_path: NodePath = (
+ ^"BubbleCluster/ConfirmButton/ConfirmLabel"
+)
+@export var cancel_path: NodePath = ^"BubbleCluster/CancelButton"
+@export var cancel_label_path: NodePath = (
+ ^"BubbleCluster/CancelButton/CancelLabel"
+)
+@export var maximum_layout_size: Vector2 = Vector2(720.0, 520.0)
+@export var compact_maximum_layout_size: Vector2 = Vector2(544.0, 400.0)
+@export var compact_width_threshold: float = 680.0
+@export var compact_height_threshold: float = 500.0
+@export_range(0.0, 128.0, 1.0) var transition_safe_margin: float = 24.0
+
+var _cluster: BubbleCluster
+var _message: BubbleButton
+var _message_label: Label
+var _confirm_button: BubbleButton
+var _confirm_label: Label
+var _cancel_button: BubbleButton
+var _cancel_label: Label
+var _bubbles: Array[BubbleButton] = []
+var _initial_focus: InitialFocus = InitialFocus.CONFIRM
+var _resting_cluster_position: Vector2 = Vector2.ZERO
+var _transition: Tween
+var _transition_generation: int = 0
+var _is_transitioning: bool = false
+
+
+func _ready() -> void:
+ _cluster = get_node(cluster_path) as BubbleCluster
+ _message = get_node(message_path) as BubbleButton
+ _message_label = get_node(message_label_path) as Label
+ _confirm_button = get_node(confirm_path) as BubbleButton
+ _confirm_label = get_node(confirm_label_path) as Label
+ _cancel_button = get_node(cancel_path) as BubbleButton
+ _cancel_label = get_node(cancel_label_path) as Label
+ _bubbles = [_message, _confirm_button, _cancel_button]
+ _cluster.configure(_bubbles)
+ _message.focus_mode = Control.FOCUS_NONE
+ _message.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _confirm_button.pressed.connect(confirmed.emit)
+ _cancel_button.pressed.connect(cancelled.emit)
+ _configure_focus()
+ resized.connect(_update_layout)
+ call_deferred("_update_layout")
+ hide_page()
+
+
+func configure(
+ message_text: String,
+ confirm_text: String,
+ cancel_text: String = "cancel",
+ initial_focus: InitialFocus = InitialFocus.CONFIRM,
+) -> void:
+ _message_label.text = message_text
+ _confirm_label.text = confirm_text
+ _cancel_label.text = cancel_text
+ _initial_focus = initial_focus
+
+
+func transition_in(duration: float, completed: Callable) -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ show()
+ _is_transitioning = true
+ _set_interactive(false)
+ set_process(true)
+ _cluster.position = Vector2(
+ _resting_cluster_position.x,
+ size.y + transition_safe_margin
+ )
+ var generation: int = _transition_generation
+ _transition = create_tween()
+ _transition.tween_property(
+ _cluster,
+ "position",
+ _resting_cluster_position,
+ duration
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _transition.finished.connect(
+ _finish_transition_in.bind(generation, completed),
+ CONNECT_ONE_SHOT
+ )
+
+
+func transition_out(duration: float, completed: Callable) -> void:
+ if not visible or _is_transitioning:
+ return
+ _transition_generation += 1
+ _cancel_transition()
+ _is_transitioning = true
+ _set_interactive(false)
+ var generation: int = _transition_generation
+ _transition = create_tween()
+ _transition.tween_property(
+ _cluster,
+ "position:y",
+ -_cluster.size.y - transition_safe_margin,
+ duration
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _transition.finished.connect(
+ _finish_transition_out.bind(generation, completed),
+ CONNECT_ONE_SHOT
+ )
+
+
+func hide_page() -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ _is_transitioning = false
+ _set_interactive(false)
+ set_process(false)
+ hide()
+ if _cluster != null:
+ _cluster.position = _resting_cluster_position
+
+
+func lock_interaction() -> void:
+ _set_interactive(false)
+
+
+func is_transitioning() -> bool:
+ return _is_transitioning
+
+
+func focus_initial() -> void:
+ var target: BubbleButton = (
+ _cancel_button
+ if _initial_focus == InitialFocus.CANCEL
+ else _confirm_button
+ )
+ if target.visible and target.focus_mode != Control.FOCUS_NONE:
+ target.grab_focus()
+
+
+func _process(delta: float) -> void:
+ if visible:
+ _cluster.advance_motion(delta)
+
+
+func _update_layout() -> void:
+ if not is_node_ready() or _cluster == null:
+ return
+ var compact: bool = false
+ var layout_maximum: Vector2 = (
+ compact_maximum_layout_size
+ if compact
+ else maximum_layout_size
+ )
+ var field_size := Vector2(
+ minf(size.x, layout_maximum.x),
+ minf(size.y, layout_maximum.y)
+ )
+ field_size.x = maxf(1.0, field_size.x)
+ field_size.y = maxf(1.0, field_size.y)
+ _resting_cluster_position = (size - field_size) * 0.5
+ if not _is_transitioning:
+ _cluster.position = _resting_cluster_position
+ _cluster.size = field_size
+ _cluster.apply_layout(field_size, compact)
+
+
+func _configure_focus() -> void:
+ var confirm_to_cancel: NodePath = _confirm_button.get_path_to(
+ _cancel_button
+ )
+ var cancel_to_confirm: NodePath = _cancel_button.get_path_to(
+ _confirm_button
+ )
+ _confirm_button.focus_neighbor_left = confirm_to_cancel
+ _confirm_button.focus_neighbor_right = confirm_to_cancel
+ _confirm_button.focus_neighbor_top = confirm_to_cancel
+ _confirm_button.focus_neighbor_bottom = confirm_to_cancel
+ _cancel_button.focus_neighbor_left = cancel_to_confirm
+ _cancel_button.focus_neighbor_right = cancel_to_confirm
+ _cancel_button.focus_neighbor_top = cancel_to_confirm
+ _cancel_button.focus_neighbor_bottom = cancel_to_confirm
+
+
+func _set_interactive(interactive: bool) -> void:
+ mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ for bubble: BubbleButton in [_confirm_button, _cancel_button]:
+ if not interactive and bubble.has_focus():
+ bubble.release_focus()
+ bubble.focus_mode = (
+ Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
+ )
+ bubble.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _finish_transition_in(
+ generation: int,
+ completed: Callable,
+) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _transition = null
+ _is_transitioning = false
+ _cluster.position = _resting_cluster_position
+ _set_interactive(true)
+ focus_initial()
+ completed.call()
+
+
+func _finish_transition_out(
+ generation: int,
+ completed: Callable,
+) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _transition = null
+ _is_transitioning = false
+ hide()
+ _cluster.position = _resting_cluster_position
+ set_process(false)
+ completed.call()
+
+
+func _cancel_transition() -> void:
+ if _transition != null:
+ _transition.kill()
+ _transition = null
diff --git a/ui/components/bubble_menu/bubble_confirmation_page.gd.uid b/ui/components/bubble_menu/bubble_confirmation_page.gd.uid
new file mode 100644
index 0000000..ef9111c
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_confirmation_page.gd.uid
@@ -0,0 +1 @@
+uid://c5q6yypya7fr1
diff --git a/ui/components/bubble_menu/bubble_confirmation_page.tscn b/ui/components/bubble_menu/bubble_confirmation_page.tscn
new file mode 100644
index 0000000..8f3e0fe
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_confirmation_page.tscn
@@ -0,0 +1,139 @@
+[gd_scene load_steps=7 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_confirmation_page.gd" id="1_page"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_button.tscn" id="2_bubble"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="3_cluster"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_menu_profile.gd" id="4_profile_script"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="5_profile"]
+
+[sub_resource type="Resource" id="BubbleMenuProfile_confirmation_action"]
+script = ExtResource("4_profile_script")
+normal_border_width = 3
+emphasized_border_width = 4
+
+[sub_resource type="Resource" id="BubbleMenuProfile_confirmation_message"]
+script = ExtResource("4_profile_script")
+corner_radius = 256
+
+[node name="BubbleConfirmationPage" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("1_page")
+
+[node name="BubbleCluster" type="Control" parent="."]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("3_cluster")
+profile = ExtResource("5_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(544, 400)
+
+[node name="MessageBubble" parent="BubbleCluster" instance=ExtResource("2_bubble")]
+custom_minimum_size = Vector2(0, 0)
+profile = SubResource("BubbleMenuProfile_confirmation_message")
+neutral_size = Vector2(356, 350)
+desktop_anchor = Vector2(360, 214)
+compact_anchor = Vector2(360, 196)
+compact_minimum_size = Vector2(292, 288)
+label_control_path = NodePath("MessageLabel")
+minimum_font_size = 15
+maximum_font_size = 22
+horizontal_amplitude = 0.8
+vertical_amplitude = 2.2
+motion_period = 6.8
+motion_phase = 0.45
+deformation_amplitude = 0.008
+deformation_period = 7.4
+
+[node name="MessageLabel" type="Label" parent="BubbleCluster/MessageBubble"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 38.0
+offset_top = 36.0
+offset_right = -38.0
+offset_bottom = -36.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
+theme_override_constants/line_spacing = -2
+text = "confirm?"
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
+
+[node name="ConfirmButton" parent="BubbleCluster" instance=ExtResource("2_bubble")]
+custom_minimum_size = Vector2(0, 0)
+profile = SubResource("BubbleMenuProfile_confirmation_action")
+neutral_size = Vector2(142, 136)
+desktop_anchor = Vector2(258, 432)
+compact_anchor = Vector2(210, 432)
+compact_minimum_size = Vector2(108, 104)
+label_control_path = NodePath("ConfirmLabel")
+minimum_font_size = 14
+maximum_font_size = 22
+horizontal_amplitude = 1.7
+vertical_amplitude = 4.2
+motion_period = 5.6
+motion_phase = 2.15
+deformation_amplitude = 0.014
+deformation_period = 5.9
+
+[node name="ConfirmLabel" type="Label" parent="BubbleCluster/ConfirmButton"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 14.0
+offset_top = 14.0
+offset_right = -14.0
+offset_bottom = -14.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+text = "confirm"
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
+
+[node name="CancelButton" parent="BubbleCluster" instance=ExtResource("2_bubble")]
+custom_minimum_size = Vector2(0, 0)
+neutral_size = Vector2(124, 120)
+desktop_anchor = Vector2(462, 426)
+compact_anchor = Vector2(510, 426)
+compact_minimum_size = Vector2(98, 94)
+label_control_path = NodePath("CancelLabel")
+minimum_font_size = 14
+maximum_font_size = 20
+horizontal_amplitude = 1.5
+vertical_amplitude = 3.8
+motion_period = 5.1
+motion_phase = 4.0
+deformation_amplitude = 0.015
+deformation_period = 5.4
+
+[node name="CancelLabel" type="Label" parent="BubbleCluster/CancelButton"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 12.0
+offset_top = 12.0
+offset_right = -12.0
+offset_bottom = -12.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+text = "cancel"
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
diff --git a/ui/components/bubble_menu/bubble_content_shell.gd b/ui/components/bubble_menu/bubble_content_shell.gd
new file mode 100644
index 0000000..f20bfb5
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_content_shell.gd
@@ -0,0 +1,49 @@
+class_name BubbleContentShell
+extends PanelContainer
+
+@export var profile: BubbleMenuProfile
+@export_range(0.0, 96.0, 1.0) var content_margin: float = 24.0
+@export_range(0, 512, 1) var corner_radius: int = 72
+@export_range(0, 12, 1) var border_width: int = 3
+
+var _background_visible: bool = true
+
+
+func _ready() -> void:
+ apply_profile()
+
+
+func apply_profile() -> void:
+ if profile == null:
+ return
+ var style := StyleBoxFlat.new()
+ style.bg_color = profile.normal_fill
+ style.border_color = profile.normal_border
+ style.border_width_left = border_width
+ style.border_width_top = border_width
+ style.border_width_right = border_width
+ style.border_width_bottom = border_width
+ style.corner_radius_top_left = corner_radius
+ style.corner_radius_top_right = corner_radius
+ style.corner_radius_bottom_right = corner_radius
+ style.corner_radius_bottom_left = corner_radius
+ style.content_margin_left = content_margin
+ style.content_margin_top = content_margin
+ style.content_margin_right = content_margin
+ style.content_margin_bottom = content_margin
+ add_theme_stylebox_override("panel", style)
+
+
+func set_background_visible(background_visible: bool) -> void:
+ _background_visible = background_visible
+ if not is_node_ready() or profile == null:
+ return
+ if _background_visible:
+ apply_profile()
+ return
+ var transparent_style := StyleBoxEmpty.new()
+ transparent_style.content_margin_left = content_margin
+ transparent_style.content_margin_top = content_margin
+ transparent_style.content_margin_right = content_margin
+ transparent_style.content_margin_bottom = content_margin
+ add_theme_stylebox_override("panel", transparent_style)
diff --git a/ui/components/bubble_menu/bubble_content_shell.gd.uid b/ui/components/bubble_menu/bubble_content_shell.gd.uid
new file mode 100644
index 0000000..fbbdbbf
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_content_shell.gd.uid
@@ -0,0 +1 @@
+uid://bsplayercontentshell
diff --git a/ui/components/bubble_menu/bubble_content_shell.tscn b/ui/components/bubble_menu/bubble_content_shell.tscn
new file mode 100644
index 0000000..2feac61
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_content_shell.tscn
@@ -0,0 +1,8 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_content_shell.gd" id="1_shell"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="2_profile"]
+
+[node name="BubbleContentShell" type="PanelContainer"]
+script = ExtResource("1_shell")
+profile = ExtResource("2_profile")
diff --git a/ui/components/bubble_menu/bubble_information_panel.gd b/ui/components/bubble_menu/bubble_information_panel.gd
new file mode 100644
index 0000000..22989a9
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_information_panel.gd
@@ -0,0 +1,21 @@
+class_name BubbleInformationPanel
+extends PanelContainer
+
+@export var profile: BubbleMenuProfile
+@export_range(0.0, 96.0, 1.0) var content_margin: float = 20.0
+@export_range(0, 512, 1) var corner_radius: int = 96
+
+
+func _ready() -> void:
+ if profile == null:
+ return
+ var style: StyleBoxFlat = profile.make_normal_style()
+ style.corner_radius_top_left = corner_radius
+ style.corner_radius_top_right = corner_radius
+ style.corner_radius_bottom_left = corner_radius
+ style.corner_radius_bottom_right = corner_radius
+ style.content_margin_left = content_margin
+ style.content_margin_top = content_margin
+ style.content_margin_right = content_margin
+ style.content_margin_bottom = content_margin
+ add_theme_stylebox_override("panel", style)
diff --git a/ui/components/bubble_menu/bubble_information_panel.gd.uid b/ui/components/bubble_menu/bubble_information_panel.gd.uid
new file mode 100644
index 0000000..7586d5c
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_information_panel.gd.uid
@@ -0,0 +1 @@
+uid://bubbleinformationpanel
diff --git a/ui/components/bubble_menu/bubble_information_panel.tscn b/ui/components/bubble_menu/bubble_information_panel.tscn
new file mode 100644
index 0000000..df6619c
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_information_panel.tscn
@@ -0,0 +1,9 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_information_panel.gd" id="1_panel"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="2_profile"]
+
+[node name="BubbleInformationPanel" type="PanelContainer"]
+mouse_filter = 1
+script = ExtResource("1_panel")
+profile = ExtResource("2_profile")
diff --git a/ui/components/bubble_menu/bubble_menu_profile.gd b/ui/components/bubble_menu/bubble_menu_profile.gd
new file mode 100644
index 0000000..a78cbd1
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_menu_profile.gd
@@ -0,0 +1,91 @@
+class_name BubbleMenuProfile
+extends Resource
+
+@export_group("Typography")
+@export_range(0.01, 1.0, 0.001) var font_size_ratio: float = 0.17
+
+@export_group("Interaction")
+@export_range(1.0, 1.2, 0.001) var hover_focus_scale: float = 1.035
+@export_range(0.0, 16.0, 0.1) var hover_focus_lift: float = 2.0
+@export_range(0.1, 30.0, 0.1) var emphasis_speed: float = 10.0
+@export_range(0.0, 1.0, 0.01) var emphasized_deformation_scale: float = 0.6
+
+@export_group("Cluster")
+@export_range(0.1, 30.0, 0.1) var position_response: float = 8.0
+@export_range(0.0, 24.0, 0.1) var contact_gap: float = 3.0
+@export_range(0.0, 24.0, 0.1) var maximum_separation: float = 3.0
+
+@export_group("Shape")
+@export_range(0.0, 32.0, 0.5) var content_margin: float = 8.0
+@export_range(0, 512, 1) var corner_radius: int = 128
+@export_range(0, 12, 1) var normal_border_width: int = 2
+@export_range(0, 12, 1) var emphasized_border_width: int = 3
+
+@export_group("Colors")
+@export var normal_fill: Color = Color(0.82, 0.93, 0.96, 0.96)
+@export var normal_border: Color = Color(0.91, 0.98, 1.0, 0.85)
+@export var hover_fill: Color = Color(0.91, 0.975, 0.985, 1.0)
+@export var hover_border: Color = Color(1.0, 1.0, 1.0, 0.95)
+@export var pressed_fill: Color = Color(0.7, 0.87, 0.92, 1.0)
+@export var pressed_border: Color = Color(0.94, 0.99, 1.0, 1.0)
+@export var disabled_fill: Color = Color(0.66, 0.77, 0.8, 0.78)
+@export var disabled_border: Color = Color(0.81, 0.9, 0.92, 0.55)
+@export var text_color: Color = Color(0.035, 0.145, 0.22, 1.0)
+@export var text_hover_color: Color = Color(0.025, 0.12, 0.19, 1.0)
+@export var text_pressed_color: Color = Color(0.025, 0.11, 0.17, 1.0)
+@export var text_disabled_color: Color = Color(0.16, 0.25, 0.29, 0.72)
+
+
+func make_normal_style() -> StyleBoxFlat:
+ return _make_style(
+ normal_fill,
+ normal_border,
+ normal_border_width
+ )
+
+
+func make_hover_style() -> StyleBoxFlat:
+ return _make_style(
+ hover_fill,
+ hover_border,
+ emphasized_border_width
+ )
+
+
+func make_pressed_style() -> StyleBoxFlat:
+ return _make_style(
+ pressed_fill,
+ pressed_border,
+ emphasized_border_width
+ )
+
+
+func make_disabled_style() -> StyleBoxFlat:
+ return _make_style(
+ disabled_fill,
+ disabled_border,
+ normal_border_width
+ )
+
+
+func _make_style(
+ fill_color: Color,
+ outline_color: Color,
+ outline_width: int,
+) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.content_margin_left = content_margin
+ style.content_margin_top = content_margin
+ style.content_margin_right = content_margin
+ style.content_margin_bottom = content_margin
+ style.bg_color = fill_color
+ style.border_width_left = outline_width
+ style.border_width_top = outline_width
+ style.border_width_right = outline_width
+ style.border_width_bottom = outline_width
+ style.border_color = outline_color
+ style.corner_radius_top_left = corner_radius
+ style.corner_radius_top_right = corner_radius
+ style.corner_radius_bottom_right = corner_radius
+ style.corner_radius_bottom_left = corner_radius
+ return style
diff --git a/ui/components/bubble_menu/bubble_menu_profile.gd.uid b/ui/components/bubble_menu/bubble_menu_profile.gd.uid
new file mode 100644
index 0000000..e709b29
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_menu_profile.gd.uid
@@ -0,0 +1 @@
+uid://ccliyvoivfimr
diff --git a/ui/components/bubble_menu/bubble_menu_profile.tres b/ui/components/bubble_menu/bubble_menu_profile.tres
new file mode 100644
index 0000000..c77b02c
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_menu_profile.tres
@@ -0,0 +1,6 @@
+[gd_resource type="Resource" script_class="BubbleMenuProfile" load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_menu_profile.gd" id="1_profile"]
+
+[resource]
+script = ExtResource("1_profile")
diff --git a/ui/components/bubble_menu/bubble_status_bubble.gd b/ui/components/bubble_menu/bubble_status_bubble.gd
new file mode 100644
index 0000000..dc43d5c
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_status_bubble.gd
@@ -0,0 +1,26 @@
+class_name BubbleStatusBubble
+extends PanelContainer
+
+@export var profile: BubbleMenuProfile
+
+@onready var _heading: Label = %Heading
+@onready var _value: Label = %Value
+@onready var _currency_icon: TextureRect = %CurrencyIcon
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ if profile != null:
+ add_theme_stylebox_override("panel", profile.make_normal_style())
+
+
+func set_content(heading: String, value: String) -> void:
+ _heading.text = heading
+ _value.text = value
+ _currency_icon.visible = false
+
+
+func set_currency_amount(heading: String, amount: int) -> void:
+ _heading.text = heading
+ _value.text = str(amount)
+ _currency_icon.visible = true
diff --git a/ui/components/bubble_menu/bubble_status_bubble.gd.uid b/ui/components/bubble_menu/bubble_status_bubble.gd.uid
new file mode 100644
index 0000000..b67ba57
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_status_bubble.gd.uid
@@ -0,0 +1 @@
+uid://bubblestatusbubble
diff --git a/ui/components/bubble_menu/bubble_status_bubble.tscn b/ui/components/bubble_menu/bubble_status_bubble.tscn
new file mode 100644
index 0000000..b898418
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_status_bubble.tscn
@@ -0,0 +1,51 @@
+[gd_scene load_steps=4 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_status_bubble.gd" id="1_status"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="2_profile"]
+[ext_resource type="Texture2D" path="res://items/icons/shop/32_currency.png" id="3_currency"]
+
+[node name="BubbleStatusBubble" type="PanelContainer"]
+custom_minimum_size = Vector2(112, 62)
+mouse_filter = 2
+script = ExtResource("1_status")
+profile = ExtResource("2_profile")
+
+[node name="Labels" type="VBoxContainer" parent="."]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/separation = 0
+alignment = 1
+
+[node name="Heading" type="Label" parent="Labels"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_font_sizes/font_size = 13
+text = "status"
+horizontal_alignment = 1
+
+[node name="ValueRow" type="HBoxContainer" parent="Labels"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/separation = 3
+alignment = 1
+
+[node name="CurrencyIcon" type="TextureRect" parent="Labels/ValueRow"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(18, 18)
+layout_mode = 2
+size_flags_vertical = 4
+mouse_filter = 2
+texture = ExtResource("3_currency")
+expand_mode = 1
+stretch_mode = 5
+texture_filter = 1
+
+[node name="Value" type="Label" parent="Labels/ValueRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_font_sizes/font_size = 18
+text = "0"
+horizontal_alignment = 1
diff --git a/ui/components/bubble_menu/bubble_transition_flurry.gd b/ui/components/bubble_menu/bubble_transition_flurry.gd
new file mode 100644
index 0000000..49a2c59
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_transition_flurry.gd
@@ -0,0 +1,195 @@
+class_name BubbleTransitionFlurry
+extends Control
+
+const BUBBLE_TEXTURES: Array[Texture2D] = [
+ preload("res://ui/assets/title/bubbles/bubble1.png"),
+ preload("res://ui/assets/title/bubbles/bubble2.png"),
+ preload("res://ui/assets/title/bubbles/bubble3.png"),
+]
+const BUBBLE_COUNT_MIN: int = 4
+const BUBBLE_COUNT_MAX: int = 5
+const BUBBLE_SCALE_MIN: float = 0.65
+const BUBBLE_SCALE_MAX: float = 1.15
+const BUBBLE_OPACITY_MIN: float = 0.55
+const BUBBLE_OPACITY_MAX: float = 0.90
+const BUBBLE_DRIFT_MIN: float = 10.0
+const BUBBLE_DRIFT_MAX: float = 45.0
+const BUBBLE_WOBBLE_MIN: float = 3.0
+const BUBBLE_WOBBLE_MAX: float = 9.0
+const BUBBLE_TRAVEL_DURATION_MIN: float = 7.0
+const BUBBLE_TRAVEL_DURATION_MAX: float = 14.0
+const BUBBLE_EDGE_MARGIN: float = 16.0
+const BURST_X_MIN: float = 0.36
+const BURST_X_MAX: float = 0.64
+const BURST_Y_MIN: float = 0.60
+const BURST_Y_MAX: float = 0.78
+const MAX_ACTIVE_BUBBLES: int = 30
+
+var _rng := RandomNumberGenerator.new()
+var _generation: int = 0
+var _bubbles: Array[TextureRect] = []
+var _tweens: Dictionary[int, Tween] = {}
+
+
+func _ready() -> void:
+ _rng.randomize()
+
+
+func emit_flurry() -> void:
+ if not visible or BUBBLE_TEXTURES.is_empty():
+ return
+ var bubble_count: int = _rng.randi_range(
+ BUBBLE_COUNT_MIN,
+ BUBBLE_COUNT_MAX
+ )
+ for _bubble_index: int in bubble_count:
+ if not _spawn_bubble(
+ _rng.randf_range(BURST_X_MIN, BURST_X_MAX),
+ _rng.randf_range(BURST_Y_MIN, BURST_Y_MAX)
+ ):
+ break
+
+
+func clear_flurries() -> void:
+ _generation += 1
+ for tween: Tween in _tweens.values():
+ if tween != null and tween.is_valid():
+ tween.kill()
+ _tweens.clear()
+ for bubble: TextureRect in _bubbles:
+ if is_instance_valid(bubble):
+ bubble.queue_free()
+ _bubbles.clear()
+
+
+func get_active_bubble_count() -> int:
+ return _bubbles.size()
+
+
+func _spawn_bubble(normalized_x: float, start_y_ratio: float) -> bool:
+ if _bubbles.size() >= MAX_ACTIVE_BUBBLES:
+ return false
+ var layer_size: Vector2 = size
+ if layer_size.x <= 1.0 or layer_size.y <= 1.0:
+ return false
+ var texture: Texture2D = BUBBLE_TEXTURES[
+ _rng.randi_range(0, BUBBLE_TEXTURES.size() - 1)
+ ]
+ var presentation_size: Vector2 = texture.get_size() * _rng.randf_range(
+ BUBBLE_SCALE_MIN,
+ BUBBLE_SCALE_MAX
+ )
+ var bubble := TextureRect.new()
+ bubble.name = "TransitionBubble"
+ bubble.texture = texture
+ bubble.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ bubble.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ bubble.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bubble.size = presentation_size
+ bubble.modulate.a = _rng.randf_range(
+ BUBBLE_OPACITY_MIN,
+ BUBBLE_OPACITY_MAX
+ )
+ add_child(bubble)
+ _bubbles.append(bubble)
+
+ var drift_direction: float = (
+ 1.0 if _rng.randi_range(0, 1) == 1 else -1.0
+ )
+ var horizontal_drift: float = _rng.randf_range(
+ BUBBLE_DRIFT_MIN,
+ BUBBLE_DRIFT_MAX
+ ) * drift_direction
+ var wobble_amplitude: float = _rng.randf_range(
+ BUBBLE_WOBBLE_MIN,
+ BUBBLE_WOBBLE_MAX
+ )
+ var wobble_cycles: float = _rng.randf_range(0.8, 1.6)
+ var wobble_phase: float = _rng.randf_range(0.0, TAU)
+ var full_start_y: float = (
+ layer_size.y + presentation_size.y + BUBBLE_EDGE_MARGIN
+ )
+ var start_y: float = (
+ start_y_ratio * layer_size.y - presentation_size.y * 0.5
+ )
+ var end_y: float = -presentation_size.y - BUBBLE_EDGE_MARGIN
+ var full_distance: float = maxf(full_start_y - end_y, 1.0)
+ var remaining_distance: float = maxf(start_y - end_y, 1.0)
+ var travel_duration: float = _rng.randf_range(
+ BUBBLE_TRAVEL_DURATION_MIN,
+ BUBBLE_TRAVEL_DURATION_MAX
+ ) * remaining_distance / full_distance
+ var generation: int = _generation
+ _update_bubble(
+ 0.0,
+ bubble,
+ normalized_x,
+ start_y_ratio,
+ horizontal_drift,
+ wobble_amplitude,
+ wobble_cycles,
+ wobble_phase,
+ generation
+ )
+ var tween: Tween = create_tween()
+ _tweens[bubble.get_instance_id()] = tween
+ tween.tween_method(
+ _update_bubble.bind(
+ bubble,
+ normalized_x,
+ start_y_ratio,
+ horizontal_drift,
+ wobble_amplitude,
+ wobble_cycles,
+ wobble_phase,
+ generation
+ ),
+ 0.0,
+ 1.0,
+ travel_duration
+ )
+ tween.finished.connect(
+ _finish_bubble.bind(bubble, generation),
+ CONNECT_ONE_SHOT
+ )
+ return true
+
+
+func _update_bubble(
+ progress: float,
+ bubble: TextureRect,
+ normalized_x: float,
+ start_y_ratio: float,
+ horizontal_drift: float,
+ wobble_amplitude: float,
+ wobble_cycles: float,
+ wobble_phase: float,
+ generation: int,
+) -> void:
+ if generation != _generation or not is_instance_valid(bubble):
+ return
+ var start_y: float = start_y_ratio * size.y - bubble.size.y * 0.5
+ var end_y: float = -bubble.size.y - BUBBLE_EDGE_MARGIN
+ var wobble: float = sin(
+ wobble_phase + progress * TAU * wobble_cycles
+ ) * wobble_amplitude
+ bubble.position = Vector2(
+ normalized_x * size.x
+ + horizontal_drift * progress
+ + wobble
+ - bubble.size.x * 0.5,
+ lerpf(start_y, end_y, progress)
+ )
+
+
+func _finish_bubble(bubble: TextureRect, generation: int) -> void:
+ if generation != _generation or not is_instance_valid(bubble):
+ return
+ _tweens.erase(bubble.get_instance_id())
+ _bubbles.erase(bubble)
+ bubble.queue_free()
+
+
+func _exit_tree() -> void:
+ clear_flurries()
diff --git a/ui/components/bubble_menu/bubble_transition_flurry.gd.uid b/ui/components/bubble_menu/bubble_transition_flurry.gd.uid
new file mode 100644
index 0000000..1c56e59
--- /dev/null
+++ b/ui/components/bubble_menu/bubble_transition_flurry.gd.uid
@@ -0,0 +1 @@
+uid://bxqtrj8vujks3
diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd
new file mode 100644
index 0000000..be47c17
--- /dev/null
+++ b/ui/components/bubble_menu/cooler_fish_sprite.gd
@@ -0,0 +1,187 @@
+class_name CoolerFishSprite
+extends Button
+
+@onready var _visual_root: Control = %VisualRoot
+@onready var _fish_shadow: TextureRect = %FishShadow
+@onready var _fish_texture: TextureRect = %FishTexture
+@onready var _favorite_marker: Label = %FavoriteMarker
+
+var catch_id: StringName
+var _display_name: String = ""
+var _neutral_position := Vector2.ZERO
+var _target_position := Vector2.ZERO
+var _motion_phase: float = 0.0
+var _depth_scale: float = 1.0
+var _batch_selected: bool = false
+var _focused_catch: bool = false
+var _hovered: bool = false
+var _quality_color := Color.WHITE
+
+
+func _ready() -> void:
+ set_meta(&"controller_focus_inversion_disabled", true)
+ resized.connect(_update_visual_pivot)
+ focus_entered.connect(_refresh_style)
+ focus_exited.connect(_refresh_style)
+ mouse_entered.connect(func() -> void:
+ _hovered = true
+ _refresh_style()
+ )
+ mouse_exited.connect(func() -> void:
+ _hovered = false
+ _refresh_style()
+ )
+ _update_visual_pivot()
+ _refresh_style()
+
+
+func configure(
+ identity: StringName,
+ display_name: String,
+ texture: Texture2D,
+ phase: float,
+ depth_scale: float,
+ quality_color: Color,
+) -> void:
+ catch_id = identity
+ _display_name = display_name
+ _fish_texture.texture = texture
+ _fish_shadow.texture = texture
+ _motion_phase = phase
+ _depth_scale = depth_scale
+ _quality_color = quality_color
+ _refresh_style()
+ tooltip_text = "%s · drag to a hotbar slot" % _display_name
+
+
+func _get_drag_data(_at_position: Vector2) -> Variant:
+ if catch_id.is_empty() or disabled or _fish_texture.texture == null:
+ return null
+ var preview := VBoxContainer.new()
+ preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ preview.add_theme_constant_override("separation", 2)
+ var texture := TextureRect.new()
+ texture.custom_minimum_size = Vector2(72.0, 52.0)
+ texture.texture = _fish_texture.texture
+ texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ texture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ preview.add_child(texture)
+ var label := Label.new()
+ label.text = _display_name
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ label.add_theme_font_size_override("font_size", 12)
+ preview.add_child(label)
+ set_drag_preview(preview)
+ return {
+ "kind": "cooler_fish",
+ "catch_id": String(catch_id),
+ }
+
+
+func set_target_position(target: Vector2, immediate: bool = false) -> void:
+ _target_position = target
+ if immediate:
+ _neutral_position = target
+ position = _neutral_position
+
+
+func set_item_state(
+ batch_selected: bool,
+ focused_catch: bool,
+ favorite: bool,
+) -> void:
+ _batch_selected = batch_selected
+ _focused_catch = focused_catch
+ _favorite_marker.visible = favorite
+ _refresh_style()
+
+
+func advance_presentation(delta: float, elapsed: float) -> void:
+ var response: float = 1.0 - exp(-9.0 * delta)
+ _neutral_position = _neutral_position.lerp(_target_position, response)
+ position = _neutral_position
+ var bob: float = sin(elapsed * TAU / 7.2 + _motion_phase) * 1.1
+ var tilt: float = sin(elapsed * TAU / 9.0 + _motion_phase * 1.31) * 0.018
+ var interaction_lift: float = 0.0
+ if _batch_selected:
+ interaction_lift += 3.0
+ if _focused_catch or has_focus():
+ interaction_lift += 1.5
+ elif _hovered:
+ interaction_lift += 0.8
+ _visual_root.position = Vector2(0.0, bob - interaction_lift)
+ _visual_root.rotation = tilt
+ var emphasis: float = 1.0
+ if _batch_selected:
+ emphasis += 0.085
+ if _focused_catch or has_focus():
+ emphasis += 0.035
+ elif _hovered:
+ emphasis += 0.045
+ _visual_root.scale = Vector2.ONE * _depth_scale * emphasis
+
+
+func _has_point(point: Vector2) -> bool:
+ var radius: float = minf(size.x, size.y) * 0.48
+ if radius <= 0.0:
+ return false
+ return (point - size * 0.5).length_squared() <= radius * radius
+
+
+func _update_visual_pivot() -> void:
+ _visual_root.pivot_offset = size * 0.5
+
+
+func _refresh_style() -> void:
+ var idle := StyleBoxEmpty.new()
+ var normal := _make_quality_style(_quality_color, 2, 0.25)
+ var hover := _make_quality_style(
+ _quality_color.lightened(0.08), 4, 0.34
+ )
+ var selected_fill: Color = (
+ _quality_color.lightened(0.12)
+ if _batch_selected
+ else _quality_color
+ )
+ var selected := _make_quality_style(
+ selected_fill,
+ 6 if _batch_selected else 4,
+ 0.46 if _batch_selected else 0.34,
+ )
+ var circle_visible: bool = (
+ _batch_selected
+ or _hovered
+ or has_focus()
+ )
+ var base_style: StyleBox = idle
+ if circle_visible:
+ base_style = (
+ selected
+ if _batch_selected or _focused_catch
+ else normal
+ )
+ add_theme_stylebox_override(
+ "normal",
+ base_style,
+ )
+ add_theme_stylebox_override("hover", selected if _batch_selected else hover)
+ add_theme_stylebox_override("focus", selected)
+ add_theme_stylebox_override("pressed", selected)
+
+
+func _make_quality_style(
+ fill: Color,
+ shadow_size: int,
+ shadow_alpha: float,
+) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = fill
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(128)
+ style.shadow_color = Color(0.01, 0.045, 0.06, shadow_alpha)
+ style.shadow_size = shadow_size
+ style.shadow_offset = Vector2(0.0, 2.0)
+ return style
diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd.uid b/ui/components/bubble_menu/cooler_fish_sprite.gd.uid
new file mode 100644
index 0000000..82d429e
--- /dev/null
+++ b/ui/components/bubble_menu/cooler_fish_sprite.gd.uid
@@ -0,0 +1 @@
+uid://dscoolerfishsprite
diff --git a/ui/components/bubble_menu/cooler_fish_sprite.tscn b/ui/components/bubble_menu/cooler_fish_sprite.tscn
new file mode 100644
index 0000000..74ad599
--- /dev/null
+++ b/ui/components/bubble_menu/cooler_fish_sprite.tscn
@@ -0,0 +1,75 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/cooler_fish_sprite.gd" id="1_script"]
+
+[node name="CoolerFishSprite" type="Button"]
+custom_minimum_size = Vector2(72, 72)
+focus_mode = 1
+mouse_default_cursor_shape = 2
+script = ExtResource("1_script")
+
+[node name="VisualRoot" type="Control" parent="."]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+pivot_offset = Vector2(36, 36)
+
+[node name="FishShadow" type="TextureRect" parent="VisualRoot"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 10.0
+offset_top = 11.0
+offset_right = -4.0
+offset_bottom = -3.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+modulate = Color(0.015, 0.055, 0.07, 0.32)
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="FishTexture" type="TextureRect" parent="VisualRoot"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 7.0
+offset_top = 7.0
+offset_right = -7.0
+offset_bottom = -7.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="FavoriteMarker" type="Label" parent="VisualRoot"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -22.0
+offset_top = 0.0
+offset_right = 0.0
+offset_bottom = 22.0
+grow_horizontal = 0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.92, 0.68, 0.16, 1)
+theme_override_colors/font_outline_color = Color(0.035, 0.145, 0.22, 1)
+theme_override_constants/outline_size = 2
+theme_override_font_sizes/font_size = 16
+text = "★"
+horizontal_alignment = 1
diff --git a/ui/components/bubble_menu/cooler_water.gdshader b/ui/components/bubble_menu/cooler_water.gdshader
new file mode 100644
index 0000000..f5e4608
--- /dev/null
+++ b/ui/components/bubble_menu/cooler_water.gdshader
@@ -0,0 +1,207 @@
+shader_type canvas_item;
+render_mode unshaded;
+
+uniform vec4 water_top : source_color = vec4(0.055, 0.38, 0.50, 1.0);
+uniform vec4 water_bottom : source_color = vec4(0.018, 0.16, 0.25, 1.0);
+uniform vec4 caustic_color : source_color = vec4(0.66, 0.91, 0.95, 1.0);
+uniform vec2 pattern_density = vec2(38.0, 21.0);
+uniform vec2 pattern_drift = vec2(0.012, 0.018);
+uniform float mark_opacity_min : hint_range(0.0, 0.1) = 0.022;
+uniform float mark_opacity_max : hint_range(0.0, 0.1) = 0.065;
+uniform sampler2D decorative_bubble_1 : source_color, filter_nearest, repeat_disable;
+uniform sampler2D decorative_bubble_2 : source_color, filter_nearest, repeat_disable;
+uniform sampler2D decorative_bubble_3 : source_color, filter_nearest, repeat_disable;
+uniform float decorative_bubble_opacity : hint_range(0.0, 1.0) = 0.48;
+uniform vec2 rendered_size = vec2(784.0, 394.0);
+uniform float corner_radius : hint_range(0.0, 96.0) = 29.0;
+uniform float corner_softness : hint_range(0.0, 3.0) = 0.75;
+
+
+float cell_hash(vec2 cell) {
+ return fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+
+float block_mark(vec2 point, vec2 half_size) {
+ return (
+ step(abs(point.x), half_size.x)
+ * step(abs(point.y), half_size.y)
+ );
+}
+
+
+vec4 sample_decorative_bubble(
+ sampler2D artwork,
+ vec2 uv,
+ vec2 rect_size,
+ vec2 center,
+ float logical_size
+) {
+ vec2 artwork_uv = (
+ (uv - center) * rect_size / logical_size
+ + vec2(0.5)
+ );
+ float inside = (
+ step(0.0, artwork_uv.x)
+ * step(artwork_uv.x, 1.0)
+ * step(0.0, artwork_uv.y)
+ * step(artwork_uv.y, 1.0)
+ );
+ return texture(artwork, clamp(artwork_uv, vec2(0.0), vec2(1.0))) * inside;
+}
+
+
+vec2 decorative_bubble_center(
+ float base_x,
+ float phase,
+ float speed,
+ float drift_phase
+) {
+ float progress = fract(phase + TIME * speed);
+ return vec2(
+ base_x + sin(TIME * 0.21 + drift_phase) * 0.012,
+ 1.10 - progress * 1.24
+ );
+}
+
+
+void fragment() {
+ vec2 uv = UV;
+ vec3 base = mix(water_top.rgb, water_bottom.rgb, smoothstep(0.0, 1.0, uv.y));
+
+ vec2 flowing_grid = (uv + TIME * pattern_drift) * pattern_density;
+ float row = floor(flowing_grid.y);
+ flowing_grid.x += mod(row, 2.0) * 0.43;
+ vec2 cell = floor(flowing_grid);
+ vec2 local = fract(flowing_grid) - vec2(0.5);
+ vec2 jitter = vec2(
+ cell_hash(cell + vec2(3.7, 9.1)),
+ cell_hash(cell + vec2(7.3, 2.9))
+ ) - vec2(0.5);
+ local -= jitter * vec2(0.52, 0.46);
+ vec2 stepped_local = (
+ floor((local + vec2(0.5)) * 8.0) / 8.0
+ - vec2(0.5)
+ );
+ float shape_key = cell_hash(cell + vec2(13.1, 5.7));
+ float size_key = cell_hash(cell + vec2(1.9, 17.3));
+ vec2 mark_half_size = mix(
+ vec2(0.10, 0.07),
+ vec2(0.18, 0.12),
+ size_key
+ );
+ float mark = block_mark(stepped_local, mark_half_size);
+ if (shape_key < 0.34) {
+ mark = max(
+ mark,
+ block_mark(
+ stepped_local - vec2(mark_half_size.x * 0.8, -0.11),
+ vec2(mark_half_size.x * 0.52, 0.045)
+ )
+ );
+ } else if (shape_key < 0.67) {
+ mark *= 1.0 - block_mark(
+ stepped_local - vec2(mark_half_size.x * 0.7, 0.0),
+ vec2(0.045, 0.045)
+ );
+ } else {
+ mark = max(
+ mark,
+ block_mark(
+ stepped_local + vec2(mark_half_size.x * 0.75, 0.10),
+ vec2(0.055, 0.04)
+ )
+ );
+ }
+ float occupancy = step(
+ 0.22,
+ cell_hash(cell + vec2(19.7, 23.3))
+ );
+ float mark_opacity = mix(
+ mark_opacity_min,
+ mark_opacity_max,
+ cell_hash(cell + vec2(29.9, 11.5))
+ );
+ base += caustic_color.rgb * mark * occupancy * mark_opacity;
+
+ float sheen = (1.0 - smoothstep(0.0, 0.12, uv.y)) * 0.11;
+ base += caustic_color.rgb * sheen;
+
+ vec2 rect_size = max(rendered_size, vec2(1.0));
+ float desktop_bubble_slots = step(260.0, rect_size.y);
+ vec4 authored_bubbles = vec4(0.0);
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_1,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.12, 0.05, 0.020, 0.4),
+ 22.0
+ );
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_2,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.27, 0.42, 0.016, 2.1),
+ 28.0
+ );
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_3,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.41, 0.76, 0.018, 4.0),
+ 19.0
+ );
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_1,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.56, 0.22, 0.015, 5.2),
+ 25.0
+ );
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_3,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.69, 0.58, 0.019, 1.3),
+ 30.0
+ );
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_2,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.82, 0.89, 0.017, 3.1),
+ 21.0
+ ) * desktop_bubble_slots;
+ authored_bubbles += sample_decorative_bubble(
+ decorative_bubble_1,
+ uv,
+ rect_size,
+ decorative_bubble_center(0.92, 0.34, 0.014, 6.0),
+ 24.0
+ ) * desktop_bubble_slots;
+ float authored_alpha = clamp(
+ authored_bubbles.a * decorative_bubble_opacity,
+ 0.0,
+ 0.72
+ );
+ vec3 authored_color = (
+ authored_bubbles.rgb
+ / max(authored_bubbles.a, 0.001)
+ );
+ base = mix(base, authored_color, authored_alpha);
+
+ vec2 half_size = rect_size * 0.5;
+ float radius = min(corner_radius, min(half_size.x, half_size.y));
+ vec2 rounded_offset = (
+ abs(uv * rect_size - half_size)
+ - (half_size - vec2(radius))
+ );
+ float edge_distance = (
+ length(max(rounded_offset, vec2(0.0)))
+ + min(max(rounded_offset.x, rounded_offset.y), 0.0)
+ - radius
+ );
+ float softness = max(corner_softness, 0.001);
+ float rounded_alpha = 1.0 - smoothstep(-softness, softness, edge_distance);
+ COLOR = vec4(base, rounded_alpha);
+}
diff --git a/ui/components/bubble_menu/cooler_water.gdshader.uid b/ui/components/bubble_menu/cooler_water.gdshader.uid
new file mode 100644
index 0000000..389d753
--- /dev/null
+++ b/ui/components/bubble_menu/cooler_water.gdshader.uid
@@ -0,0 +1 @@
+uid://dejhqrp2nij1
diff --git a/ui/components/bubble_menu/logbook_entry.gd b/ui/components/bubble_menu/logbook_entry.gd
new file mode 100644
index 0000000..0c22a85
--- /dev/null
+++ b/ui/components/bubble_menu/logbook_entry.gd
@@ -0,0 +1,103 @@
+class_name LogbookEntry
+extends PanelContainer
+
+var _discovered: bool = false
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _apply_paper_style(_discovered)
+
+
+func configure(
+ artwork: Texture2D,
+ species_name: String,
+ rarity_name: String,
+ owned_count: int,
+ discovered: bool,
+) -> void:
+ _discovered = discovered
+ var artwork_control := get_node(
+ ^"Margin/Content/Artwork"
+ ) as TextureRect
+ var name_control := get_node(
+ ^"Margin/Content/Text/NameLabel"
+ ) as Label
+ var rarity_control := get_node(
+ ^"Margin/Content/Text/RarityLabel"
+ ) as Label
+ var owned_control := get_node(
+ ^"Margin/Content/Text/OwnedLabel"
+ ) as Label
+ artwork_control.texture = artwork
+ artwork_control.modulate = (
+ Color.WHITE
+ if discovered
+ else Color(0.20, 0.17, 0.13, 0.48)
+ )
+ name_control.text = species_name if discovered else "???"
+ rarity_control.text = rarity_name if discovered else "undiscovered"
+ owned_control.text = (
+ "owned: %d" % owned_count if discovered else "not recorded"
+ )
+ owned_control.visible = true
+ _apply_paper_style(discovered)
+
+
+func apply_compact_layout(compact: bool) -> void:
+ var artwork_control := get_node(
+ ^"Margin/Content/Artwork"
+ ) as TextureRect
+ var name_control := get_node(
+ ^"Margin/Content/Text/NameLabel"
+ ) as Label
+ var rarity_control := get_node(
+ ^"Margin/Content/Text/RarityLabel"
+ ) as Label
+ var owned_control := get_node(
+ ^"Margin/Content/Text/OwnedLabel"
+ ) as Label
+ custom_minimum_size = (
+ Vector2(236.0, 108.0) if compact else Vector2(214.0, 150.0)
+ )
+ artwork_control.custom_minimum_size = (
+ Vector2(92.0, 54.0) if compact else Vector2(128.0, 76.0)
+ )
+ name_control.add_theme_font_size_override(
+ "font_size",
+ 16 if compact else 18,
+ )
+ rarity_control.add_theme_font_size_override(
+ "font_size",
+ 11 if compact else 13,
+ )
+ owned_control.add_theme_font_size_override(
+ "font_size",
+ 11 if compact else 13,
+ )
+
+
+func _apply_paper_style(discovered: bool) -> void:
+ if not is_node_ready():
+ return
+ var style := StyleBoxFlat.new()
+ style.bg_color = (
+ Color(0.965, 0.925, 0.80, 0.78)
+ if discovered
+ else Color(0.89, 0.84, 0.72, 0.58)
+ )
+ style.border_color = (
+ Color(0.50, 0.39, 0.25, 0.62)
+ if discovered
+ else Color(0.39, 0.34, 0.27, 0.42)
+ )
+ style.border_width_bottom = 2
+ style.corner_radius_top_left = 18
+ style.corner_radius_top_right = 12
+ style.corner_radius_bottom_right = 16
+ style.corner_radius_bottom_left = 10
+ style.content_margin_left = 8.0
+ style.content_margin_top = 6.0
+ style.content_margin_right = 8.0
+ style.content_margin_bottom = 6.0
+ add_theme_stylebox_override("panel", style)
diff --git a/ui/components/bubble_menu/logbook_entry.gd.uid b/ui/components/bubble_menu/logbook_entry.gd.uid
new file mode 100644
index 0000000..30525da
--- /dev/null
+++ b/ui/components/bubble_menu/logbook_entry.gd.uid
@@ -0,0 +1 @@
+uid://dclogbookentry2026
diff --git a/ui/components/bubble_menu/logbook_entry.tscn b/ui/components/bubble_menu/logbook_entry.tscn
new file mode 100644
index 0000000..34778a3
--- /dev/null
+++ b/ui/components/bubble_menu/logbook_entry.tscn
@@ -0,0 +1,66 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/logbook_entry.gd" id="1_script"]
+
+[node name="LogbookEntry" type="PanelContainer"]
+custom_minimum_size = Vector2(214, 150)
+mouse_filter = 2
+script = ExtResource("1_script")
+
+[node name="Margin" type="MarginContainer" parent="."]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 5
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 5
+theme_override_constants/margin_bottom = 4
+
+[node name="Content" type="HBoxContainer" parent="Margin"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/separation = 7
+
+[node name="Artwork" type="TextureRect" parent="Margin/Content"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(128, 76)
+layout_mode = 2
+size_flags_horizontal = 3
+mouse_filter = 2
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="Text" type="VBoxContainer" parent="Margin/Content"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+mouse_filter = 2
+theme_override_constants/separation = 2
+
+[node name="NameLabel" type="Label" parent="Margin/Content/Text"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.20, 0.145, 0.085, 1)
+theme_override_font_sizes/font_size = 18
+text = "species"
+horizontal_alignment = 1
+autowrap_mode = 2
+
+[node name="RarityLabel" type="Label" parent="Margin/Content/Text"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.34, 0.25, 0.15, 1)
+theme_override_font_sizes/font_size = 13
+text = "common"
+horizontal_alignment = 1
+
+[node name="OwnedLabel" type="Label" parent="Margin/Content/Text"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.34, 0.25, 0.15, 1)
+theme_override_font_sizes/font_size = 13
+text = "owned: 0"
+horizontal_alignment = 1
diff --git a/ui/components/bubble_menu/notepad_ink_action.gd b/ui/components/bubble_menu/notepad_ink_action.gd
new file mode 100644
index 0000000..85ec1b2
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_action.gd
@@ -0,0 +1,104 @@
+class_name NotepadInkAction
+extends Button
+
+@export var ink_color := Color(0.19, 0.16, 0.12, 1.0)
+@export var disabled_ink_color := Color(0.31, 0.28, 0.23, 0.46)
+@export var allow_persistent_mark: bool = false:
+ set(value):
+ allow_persistent_mark = value
+ if not allow_persistent_mark:
+ persistent_mark = false
+@export var persistent_mark: bool = false:
+ set(value):
+ persistent_mark = value and allow_persistent_mark
+ _refresh_ink()
+
+@onready var _ink_outline: NotepadInkOutline = %InkOutline
+
+var _hovered: bool = false
+var _focused: bool = false
+var _pressed: bool = false
+
+
+func _ready() -> void:
+ mouse_entered.connect(_set_hovered.bind(true))
+ mouse_exited.connect(_set_hovered.bind(false))
+ focus_entered.connect(_set_focused.bind(true))
+ focus_exited.connect(_set_focused.bind(false))
+ button_down.connect(_set_pressed.bind(true))
+ button_up.connect(_set_pressed.bind(false))
+ gui_input.connect(_on_gui_input)
+ _apply_paper_style()
+ _refresh_ink()
+
+
+func refresh_ink_state() -> void:
+ _apply_text_color()
+ _refresh_ink()
+
+
+func _set_hovered(value: bool) -> void:
+ _hovered = value
+ _refresh_ink()
+
+
+func _set_focused(value: bool) -> void:
+ _focused = value
+ _refresh_ink()
+
+
+func _set_pressed(value: bool) -> void:
+ _pressed = value
+ _refresh_ink()
+
+
+func _on_gui_input(event: InputEvent) -> void:
+ if (
+ event is InputEventMouseButton
+ and event.button_index == MOUSE_BUTTON_LEFT
+ and not event.pressed
+ ):
+ call_deferred("_finish_mouse_activation")
+
+
+func _finish_mouse_activation() -> void:
+ _pressed = false
+ if has_focus():
+ release_focus()
+ _refresh_ink()
+
+
+func _refresh_ink() -> void:
+ if not is_node_ready():
+ return
+ var strength: float = 0.0
+ if not disabled:
+ if persistent_mark:
+ strength = 0.78
+ if _hovered or _focused:
+ strength = maxf(strength, 0.92)
+ if _pressed:
+ strength = 1.0
+ _ink_outline.set_mark_strength(strength)
+
+
+func _apply_paper_style() -> void:
+ var empty := StyleBoxEmpty.new()
+ for state: StringName in [
+ &"normal",
+ &"hover",
+ &"pressed",
+ &"focus",
+ &"disabled",
+ ]:
+ add_theme_stylebox_override(state, empty)
+ _apply_text_color()
+
+
+func _apply_text_color() -> void:
+ var color: Color = disabled_ink_color if disabled else ink_color
+ add_theme_color_override("font_color", color)
+ add_theme_color_override("font_hover_color", color.darkened(0.08))
+ add_theme_color_override("font_focus_color", color.darkened(0.08))
+ add_theme_color_override("font_pressed_color", color.darkened(0.16))
+ add_theme_color_override("font_disabled_color", disabled_ink_color)
diff --git a/ui/components/bubble_menu/notepad_ink_action.gd.uid b/ui/components/bubble_menu/notepad_ink_action.gd.uid
new file mode 100644
index 0000000..120d2f6
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_action.gd.uid
@@ -0,0 +1 @@
+uid://b42bnpyhxn35k
diff --git a/ui/components/bubble_menu/notepad_ink_action.tscn b/ui/components/bubble_menu/notepad_ink_action.tscn
new file mode 100644
index 0000000..4ed4b6e
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_action.tscn
@@ -0,0 +1,23 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/notepad_ink_action.gd" id="1_action"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/notepad_ink_outline.gd" id="2_outline"]
+
+[node name="NotepadInkAction" type="Button"]
+custom_minimum_size = Vector2(104, 46)
+focus_mode = 1
+mouse_default_cursor_shape = 2
+flat = true
+script = ExtResource("1_action")
+
+[node name="InkOutline" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("2_outline")
diff --git a/ui/components/bubble_menu/notepad_ink_choice.gd b/ui/components/bubble_menu/notepad_ink_choice.gd
new file mode 100644
index 0000000..1578587
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_choice.gd
@@ -0,0 +1,262 @@
+class_name NotepadInkChoice
+extends Control
+
+signal item_selected(index: int)
+
+@export var ink_color := Color(0.19, 0.16, 0.12, 1.0)
+@export_range(0.0, 16.0, 0.25) var outline_padding: float = 3.0:
+ set(value):
+ outline_padding = value
+ if is_node_ready():
+ _ink_outline.padding = outline_padding
+ _ink_outline.queue_redraw()
+@export var outline_outsets := Vector4.ZERO:
+ set(value):
+ outline_outsets = value
+ if is_node_ready():
+ _apply_outline_geometry()
+
+@onready var _ink_outline: NotepadInkOutline = %InkOutline
+@onready var _displayed_value: Label = %DisplayedValue
+@onready var _choice_panel: Control = %ChoicePanel
+@onready var _choice_buttons: Array[Button] = [
+ %CatchOrderChoice,
+ %NameChoice,
+ %RarityChoice,
+]
+
+var disabled: bool = false:
+ set(value):
+ disabled = value
+ if is_node_ready():
+ _apply_interaction_state()
+var _items: Array[String] = []
+var _item_ids: Array[int] = []
+var _selected_index: int = 0
+var _hovered: bool = false
+var _focused: bool = false
+var _opened_with_mouse: bool = false
+
+
+func _ready() -> void:
+ _ink_outline.padding = outline_padding
+ _apply_outline_geometry()
+ mouse_entered.connect(_set_hovered.bind(true))
+ mouse_exited.connect(_set_hovered.bind(false))
+ focus_entered.connect(_set_focused.bind(true))
+ focus_exited.connect(_set_focused.bind(false))
+ gui_input.connect(_on_display_gui_input)
+ for index: int in _choice_buttons.size():
+ var button: Button = _choice_buttons[index]
+ button.pressed.connect(_select_index.bind(index))
+ button.gui_input.connect(_on_choice_gui_input.bind(index))
+ _choice_panel.hide()
+ _apply_paper_style()
+ _apply_interaction_state()
+
+
+func add_item(item_text: String, item_id: int = -1) -> void:
+ if _items.size() >= _choice_buttons.size():
+ return
+ var index: int = _items.size()
+ _items.append(item_text)
+ _item_ids.append(item_id if item_id >= 0 else index)
+ _choice_buttons[index].text = item_text
+ _choice_buttons[index].show()
+ if index == _selected_index:
+ _displayed_value.text = item_text
+
+
+func select(index: int) -> void:
+ if index < 0 or index >= _items.size():
+ return
+ _selected_index = index
+ _displayed_value.text = _items[index]
+
+
+func get_selected_id() -> int:
+ if _selected_index < 0 or _selected_index >= _item_ids.size():
+ return -1
+ return _item_ids[_selected_index]
+
+
+func refresh_ink_state() -> void:
+ _refresh_ink()
+
+
+func set_choice_font_size(font_size: int) -> void:
+ _displayed_value.add_theme_font_size_override("font_size", font_size)
+ for button: Button in _choice_buttons:
+ button.add_theme_font_size_override("font_size", font_size)
+
+
+func close_choices() -> void:
+ if not _choice_panel.visible:
+ return
+ _choice_panel.hide()
+ if _opened_with_mouse:
+ _opened_with_mouse = false
+ if has_focus():
+ release_focus()
+ _refresh_ink()
+
+
+func _apply_outline_geometry() -> void:
+ _ink_outline.offset_left = -outline_outsets.x
+ _ink_outline.offset_top = -outline_outsets.y
+ _ink_outline.offset_right = outline_outsets.z
+ _ink_outline.offset_bottom = outline_outsets.w
+ _ink_outline.queue_redraw()
+
+
+func _draw() -> void:
+ var underline_y: float = size.y - 5.0
+ var underline := PackedVector2Array([
+ Vector2(7.0, underline_y),
+ Vector2(size.x * 0.34, underline_y + 0.7),
+ Vector2(size.x * 0.68, underline_y - 0.5),
+ Vector2(size.x - 8.0, underline_y + 0.2),
+ ])
+ var underline_color: Color = ink_color
+ underline_color.a = 0.48
+ draw_polyline(underline, underline_color, 1.1, true)
+
+
+func _gui_input(event: InputEvent) -> void:
+ if disabled:
+ return
+ if event.is_action_pressed("ui_accept"):
+ _open_choices(false)
+ accept_event()
+ elif event.is_action_pressed("ui_cancel") and _choice_panel.visible:
+ close_choices()
+ accept_event()
+
+
+func _input(event: InputEvent) -> void:
+ if not _choice_panel.visible:
+ return
+ if event.is_action_pressed("ui_cancel"):
+ close_choices()
+ get_viewport().set_input_as_handled()
+ return
+ if (
+ event is InputEventMouseButton
+ and event.button_index == MOUSE_BUTTON_LEFT
+ and event.pressed
+ ):
+ var mouse_position: Vector2 = event.position
+ if (
+ not get_global_rect().has_point(mouse_position)
+ and not _choice_panel.get_global_rect().has_point(mouse_position)
+ ):
+ close_choices()
+
+
+func _on_display_gui_input(event: InputEvent) -> void:
+ if (
+ not disabled
+ and event is InputEventMouseButton
+ and event.button_index == MOUSE_BUTTON_LEFT
+ and event.pressed
+ ):
+ _open_choices(true)
+ accept_event()
+
+
+func _on_choice_gui_input(event: InputEvent, index: int) -> void:
+ if event.is_action_pressed("ui_cancel"):
+ close_choices()
+ get_viewport().set_input_as_handled()
+ elif event.is_action_pressed("ui_up"):
+ _choice_buttons[posmod(index - 1, _choice_buttons.size())].grab_focus()
+ get_viewport().set_input_as_handled()
+ elif event.is_action_pressed("ui_down"):
+ _choice_buttons[(index + 1) % _choice_buttons.size()].grab_focus()
+ get_viewport().set_input_as_handled()
+
+
+func _open_choices(from_mouse: bool) -> void:
+ if disabled or _choice_panel.visible:
+ return
+ _opened_with_mouse = from_mouse
+ _choice_panel.show()
+ _choice_panel.move_to_front()
+ if not from_mouse:
+ _choice_buttons[_selected_index].grab_focus()
+ _refresh_ink()
+
+
+func _select_index(index: int) -> void:
+ if disabled or index < 0 or index >= _items.size():
+ return
+ select(index)
+ close_choices()
+ item_selected.emit(get_selected_id())
+
+
+func _set_hovered(value: bool) -> void:
+ _hovered = value
+ _refresh_ink()
+
+
+func _set_focused(value: bool) -> void:
+ _focused = value
+ _refresh_ink()
+
+
+func _refresh_ink() -> void:
+ if is_node_ready():
+ _ink_outline.set_mark_strength(
+ 0.88 if (_hovered or _focused) and not disabled else 0.0
+ )
+
+
+func _apply_interaction_state() -> void:
+ _displayed_value.modulate.a = 0.42 if disabled else 1.0
+ focus_mode = Control.FOCUS_NONE if disabled else Control.FOCUS_ALL
+ mouse_filter = (
+ Control.MOUSE_FILTER_IGNORE
+ if disabled
+ else Control.MOUSE_FILTER_STOP
+ )
+ for button: Button in _choice_buttons:
+ button.disabled = disabled
+ if disabled:
+ close_choices()
+ _refresh_ink()
+
+
+func _apply_paper_style() -> void:
+ var empty := StyleBoxEmpty.new()
+ var hover := StyleBoxFlat.new()
+ hover.bg_color = Color(0.82, 0.775, 0.63, 1.0)
+ hover.set_border_width_all(0)
+ hover.set_corner_radius_all(0)
+ hover.anti_aliasing = false
+ _displayed_value.add_theme_color_override("font_color", ink_color)
+ for button: Button in _choice_buttons:
+ for state: StringName in [
+ &"normal",
+ &"disabled",
+ ]:
+ button.add_theme_stylebox_override(state, empty)
+ for state: StringName in [
+ &"hover",
+ &"pressed",
+ &"focus",
+ ]:
+ button.add_theme_stylebox_override(state, hover)
+ button.add_theme_color_override("font_color", ink_color)
+ button.add_theme_color_override(
+ "font_hover_color",
+ ink_color.darkened(0.08),
+ )
+ button.add_theme_color_override(
+ "font_focus_color",
+ ink_color.darkened(0.08),
+ )
+ button.add_theme_color_override(
+ "font_pressed_color",
+ ink_color.darkened(0.14),
+ )
diff --git a/ui/components/bubble_menu/notepad_ink_choice.gd.uid b/ui/components/bubble_menu/notepad_ink_choice.gd.uid
new file mode 100644
index 0000000..67a40c0
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_choice.gd.uid
@@ -0,0 +1 @@
+uid://h754d8qatlse
diff --git a/ui/components/bubble_menu/notepad_ink_choice.tscn b/ui/components/bubble_menu/notepad_ink_choice.tscn
new file mode 100644
index 0000000..eb8ca98
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_choice.tscn
@@ -0,0 +1,107 @@
+[gd_scene load_steps=6 format=3]
+
+[ext_resource type="Script" path="res://ui/components/bubble_menu/notepad_ink_choice.gd" id="1_choice"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/notepad_ink_outline.gd" id="2_outline"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_shadow"]
+bg_color = Color(0.1, 0.08, 0.06, 0.24)
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_paper"]
+bg_color = Color(0.93, 0.885, 0.73, 1)
+
+[sub_resource type="StyleBoxEmpty" id="StyleBox_empty"]
+
+[node name="NotepadInkChoice" type="Control"]
+custom_minimum_size = Vector2(124, 38)
+focus_mode = 1
+mouse_default_cursor_shape = 2
+script = ExtResource("1_choice")
+
+[node name="DisplayedValue" type="Label" parent="."]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+text = "catch order"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="InkOutline" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("2_outline")
+
+[node name="ChoicePanel" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+z_index = 30
+layout_mode = 0
+offset_left = -8.0
+offset_top = 40.0
+offset_right = 132.0
+offset_bottom = 139.0
+mouse_filter = 1
+
+[node name="Shadow" type="Panel" parent="ChoicePanel"]
+layout_mode = 0
+offset_left = 3.0
+offset_top = 4.0
+offset_right = 143.0
+offset_bottom = 103.0
+mouse_filter = 2
+theme_override_styles/panel = SubResource("StyleBox_shadow")
+
+[node name="Paper" type="Panel" parent="ChoicePanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_styles/panel = SubResource("StyleBox_paper")
+
+[node name="Choices" type="VBoxContainer" parent="ChoicePanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
+theme_override_constants/separation = 0
+
+[node name="CatchOrderChoice" type="Button" parent="ChoicePanel/Choices"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(140, 33)
+layout_mode = 2
+focus_mode = 1
+theme_override_styles/normal = SubResource("StyleBox_empty")
+text = "catch order"
+
+[node name="NameChoice" type="Button" parent="ChoicePanel/Choices"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(140, 33)
+layout_mode = 2
+focus_mode = 1
+theme_override_styles/normal = SubResource("StyleBox_empty")
+text = "name"
+
+[node name="RarityChoice" type="Button" parent="ChoicePanel/Choices"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(140, 33)
+layout_mode = 2
+focus_mode = 1
+theme_override_styles/normal = SubResource("StyleBox_empty")
+text = "rarity"
diff --git a/ui/components/bubble_menu/notepad_ink_outline.gd b/ui/components/bubble_menu/notepad_ink_outline.gd
new file mode 100644
index 0000000..b7efd56
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_outline.gd
@@ -0,0 +1,64 @@
+class_name NotepadInkOutline
+extends Control
+
+@export var ink_color := Color(0.20, 0.17, 0.13, 0.9)
+@export_range(0.5, 6.0, 0.1) var stroke_width: float = 2.0
+@export_range(0.0, 16.0, 0.5) var padding: float = 3.0
+@export_range(0, 1000000, 1) var shape_seed: int = 17
+
+var _mark_strength: float = 0.0
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+
+
+func set_mark_strength(strength: float) -> void:
+ _mark_strength = clampf(strength, 0.0, 1.0)
+ visible = _mark_strength > 0.001
+ queue_redraw()
+
+
+func _draw() -> void:
+ if _mark_strength <= 0.001:
+ return
+ var center: Vector2 = size * 0.5
+ var radius := Vector2(
+ maxf(size.x * 0.5 - padding, 1.0),
+ maxf(size.y * 0.5 - padding, 1.0),
+ )
+ var primary := _build_rough_loop(center, radius, 0)
+ var secondary := _build_rough_loop(
+ center + Vector2(0.7, -0.45),
+ radius - Vector2(1.2, 0.8),
+ 41,
+ )
+ var primary_color: Color = ink_color
+ primary_color.a *= _mark_strength
+ var secondary_color: Color = ink_color
+ secondary_color.a *= _mark_strength * 0.36
+ draw_polyline(primary, primary_color, stroke_width, true)
+ draw_polyline(secondary, secondary_color, maxf(stroke_width * 0.62, 0.8), true)
+
+
+func _build_rough_loop(
+ center: Vector2,
+ radius: Vector2,
+ seed_offset: int,
+) -> PackedVector2Array:
+ const POINT_COUNT: int = 26
+ var points := PackedVector2Array()
+ for index: int in POINT_COUNT:
+ var angle: float = TAU * float(index) / float(POINT_COUNT)
+ var wobble: float = (
+ sin(float(shape_seed + seed_offset) * 0.73 + float(index) * 2.17)
+ + sin(float(shape_seed + seed_offset) * 0.31 + float(index) * 4.03)
+ * 0.45
+ )
+ var radial_scale: float = 1.0 + wobble * 0.018
+ points.append(
+ center
+ + Vector2(cos(angle), sin(angle)) * radius * radial_scale
+ )
+ points.append(points[0])
+ return points
diff --git a/ui/components/bubble_menu/notepad_ink_outline.gd.uid b/ui/components/bubble_menu/notepad_ink_outline.gd.uid
new file mode 100644
index 0000000..ac7d0ac
--- /dev/null
+++ b/ui/components/bubble_menu/notepad_ink_outline.gd.uid
@@ -0,0 +1 @@
+uid://dwh3kpogaelxo
diff --git a/ui/components/currency_amount.gd b/ui/components/currency_amount.gd
new file mode 100644
index 0000000..bda2f7e
--- /dev/null
+++ b/ui/components/currency_amount.gd
@@ -0,0 +1,48 @@
+class_name CurrencyAmount
+extends HBoxContainer
+
+@export var amount: int = 0:
+ set(value):
+ amount = value
+ _display_text = str(value)
+ _refresh_amount()
+@export_range(8.0, 64.0, 1.0) var icon_size: float = 18.0:
+ set(value):
+ icon_size = value
+ _refresh_icon_size()
+
+@onready var _icon: TextureRect = %Icon
+@onready var _amount_label: Label = %Amount
+
+var _display_text: String = "0"
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _refresh_icon_size()
+ _refresh_amount()
+
+
+func set_amount(value: int) -> void:
+ amount = value
+
+
+func set_amount_text(value: String) -> void:
+ _display_text = value
+ _refresh_amount()
+
+
+func get_amount_label() -> Label:
+ if _amount_label != null:
+ return _amount_label
+ return get_node_or_null("Amount") as Label
+
+
+func _refresh_amount() -> void:
+ if _amount_label != null:
+ _amount_label.text = _display_text
+
+
+func _refresh_icon_size() -> void:
+ if _icon != null:
+ _icon.custom_minimum_size = Vector2.ONE * icon_size
diff --git a/ui/components/currency_amount.gd.uid b/ui/components/currency_amount.gd.uid
new file mode 100644
index 0000000..dfb2290
--- /dev/null
+++ b/ui/components/currency_amount.gd.uid
@@ -0,0 +1 @@
+uid://b7jx040ujcol
diff --git a/ui/components/currency_amount.tscn b/ui/components/currency_amount.tscn
new file mode 100644
index 0000000..00c8ace
--- /dev/null
+++ b/ui/components/currency_amount.tscn
@@ -0,0 +1,29 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://ui/components/currency_amount.gd" id="1_script"]
+[ext_resource type="Texture2D" path="res://items/icons/shop/32_currency.png" id="2_coin"]
+
+[node name="CurrencyAmount" type="HBoxContainer"]
+mouse_filter = 2
+theme_override_constants/separation = 3
+alignment = 1
+script = ExtResource("1_script")
+
+[node name="Icon" type="TextureRect" parent="."]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(18, 18)
+layout_mode = 2
+size_flags_vertical = 4
+mouse_filter = 2
+texture = ExtResource("2_coin")
+expand_mode = 1
+stretch_mode = 5
+texture_filter = 1
+
+[node name="Amount" type="Label" parent="."]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 4
+mouse_filter = 2
+text = "0"
+vertical_alignment = 1
diff --git a/ui/components/inventory_notepad.gd b/ui/components/inventory_notepad.gd
new file mode 100644
index 0000000..16be9f9
--- /dev/null
+++ b/ui/components/inventory_notepad.gd
@@ -0,0 +1,88 @@
+class_name InventoryNotepad
+extends PanelContainer
+
+const INK_COLOR := Color(0.16, 0.16, 0.14, 1.0)
+const CONTENT_MARGIN_LEFT := 18.0
+const CONTENT_MARGIN_TOP := 98.0
+const CONTENT_MARGIN_RIGHT := 18.0
+const CONTENT_MARGIN_BOTTOM := 16.0
+const HANDWRITTEN_FONT: Font = preload("res://ui/fonts/seattle_avenue.otf")
+const NOTEPAD_TEXTURE: Texture2D = preload("res://art/ui/ui_notepad.png")
+const NOTEPAD_CANONICAL_OVERSCAN: float = 1.2
+const NOTEPAD_ART_OFFSET := Vector2(10.0, 20.0)
+
+@export var title_text := "notes"
+@export var inset_content := true
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_PASS
+ apply_handwritten_to(self)
+ add_theme_stylebox_override("panel", make_layout_style(inset_content))
+ resized.connect(queue_redraw)
+ queue_redraw()
+
+
+static func apply_handwritten_to(root: Control) -> void:
+ root.add_theme_font_override("font", HANDWRITTEN_FONT)
+ for descendant: Node in root.find_children("*", "Control", true, false):
+ var control := descendant as Control
+ control.add_theme_font_override(
+ "font", HANDWRITTEN_FONT
+ )
+ if control is Label:
+ control.add_theme_color_override("font_color", INK_COLOR)
+
+
+func _draw() -> void:
+ draw_texture_rect(NOTEPAD_TEXTURE, get_art_rect(), false)
+
+ var font: Font = HANDWRITTEN_FONT
+ draw_string(
+ font,
+ Vector2(20.0, 58.0),
+ title_text,
+ HORIZONTAL_ALIGNMENT_CENTER,
+ size.x - 40.0,
+ 20,
+ INK_COLOR,
+ )
+
+
+func get_art_rect() -> Rect2:
+ return get_art_rect_for_host(size)
+
+
+static func get_art_rect_for_host(host_size: Vector2) -> Rect2:
+ # Source pixels establish the artwork aspect ratio and sampling quality. The
+ # canonical host rect, not the source dimensions, owns its displayed size.
+ var source_size: Vector2 = NOTEPAD_TEXTURE.get_size()
+ if (
+ host_size.x <= 0.0
+ or host_size.y <= 0.0
+ or source_size.x <= 0.0
+ or source_size.y <= 0.0
+ ):
+ return Rect2()
+ var fit_scale: float = minf(
+ host_size.x / source_size.x,
+ host_size.y / source_size.y,
+ )
+ var rendered_size: Vector2 = (
+ source_size * fit_scale * NOTEPAD_CANONICAL_OVERSCAN
+ )
+ return Rect2(
+ (host_size - rendered_size) * 0.5 + NOTEPAD_ART_OFFSET,
+ rendered_size,
+ )
+
+
+static func make_layout_style(with_content_margins: bool = true) -> StyleBoxFlat:
+ var paper := StyleBoxFlat.new()
+ paper.bg_color = Color.TRANSPARENT
+ if with_content_margins:
+ paper.content_margin_left = CONTENT_MARGIN_LEFT
+ paper.content_margin_top = CONTENT_MARGIN_TOP
+ paper.content_margin_right = CONTENT_MARGIN_RIGHT
+ paper.content_margin_bottom = CONTENT_MARGIN_BOTTOM
+ return paper
diff --git a/ui/components/inventory_notepad.gd.uid b/ui/components/inventory_notepad.gd.uid
new file mode 100644
index 0000000..97903f6
--- /dev/null
+++ b/ui/components/inventory_notepad.gd.uid
@@ -0,0 +1 @@
+uid://cox2ounxqoqig
diff --git a/ui/components/logbook_portrait.gd b/ui/components/logbook_portrait.gd
new file mode 100644
index 0000000..ef23922
--- /dev/null
+++ b/ui/components/logbook_portrait.gd
@@ -0,0 +1,79 @@
+class_name LogbookPortrait
+extends TextureRect
+
+const ENTRY_FRAME_SIZE := Vector2(86.0, 40.0)
+const DETAIL_FRAME_SIZE := Vector2(240.0, 132.0)
+
+static var _normalized_textures: Dictionary[String, Texture2D] = {}
+
+var source_texture: Texture2D
+
+
+func _init() -> void:
+ expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ focus_mode = Control.FOCUS_NONE
+ size_flags_horizontal = Control.SIZE_SHRINK_CENTER
+ size_flags_vertical = Control.SIZE_SHRINK_CENTER
+
+
+func configure(
+ portrait_texture: Texture2D,
+ frame_size: Vector2,
+ portrait_material: Material = null,
+) -> void:
+ source_texture = portrait_texture
+ custom_minimum_size = frame_size
+ material = portrait_material
+ texture = _normalize_visible_bounds(portrait_texture)
+
+
+func configure_fitted(
+ portrait_texture: Texture2D,
+ maximum_size: Vector2,
+ portrait_material: Material = null,
+) -> void:
+ source_texture = portrait_texture
+ material = portrait_material
+ texture = _normalize_visible_bounds(portrait_texture)
+ if texture == null:
+ custom_minimum_size = Vector2.ZERO
+ return
+ var texture_size: Vector2 = texture.get_size()
+ if texture_size.x <= 0.0 or texture_size.y <= 0.0:
+ custom_minimum_size = Vector2.ZERO
+ return
+ var fit_scale: float = minf(
+ maximum_size.x / texture_size.x,
+ maximum_size.y / texture_size.y,
+ )
+ custom_minimum_size = texture_size * fit_scale
+
+
+static func _normalize_visible_bounds(
+ portrait_texture: Texture2D,
+) -> Texture2D:
+ if portrait_texture == null:
+ return null
+ var cache_key: String = portrait_texture.resource_path
+ if cache_key.is_empty():
+ cache_key = "instance_%d" % portrait_texture.get_instance_id()
+ var cached: Texture2D = _normalized_textures.get(cache_key) as Texture2D
+ if cached != null:
+ return cached
+ var image: Image = portrait_texture.get_image()
+ if image == null or image.is_empty():
+ _normalized_textures[cache_key] = portrait_texture
+ return portrait_texture
+ var full_rect := Rect2i(Vector2i.ZERO, image.get_size())
+ var visible_rect: Rect2i = image.get_used_rect()
+ if visible_rect.size == Vector2i.ZERO or visible_rect == full_rect:
+ _normalized_textures[cache_key] = portrait_texture
+ return portrait_texture
+ var normalized := AtlasTexture.new()
+ normalized.atlas = portrait_texture
+ normalized.region = Rect2(visible_rect)
+ _normalized_textures[cache_key] = normalized
+ return normalized
diff --git a/ui/components/logbook_portrait.gd.uid b/ui/components/logbook_portrait.gd.uid
new file mode 100644
index 0000000..280b7ec
--- /dev/null
+++ b/ui/components/logbook_portrait.gd.uid
@@ -0,0 +1 @@
+uid://qkduawll2pm2
diff --git a/ui/components/organizer_tab.gd b/ui/components/organizer_tab.gd
new file mode 100644
index 0000000..3625b9a
--- /dev/null
+++ b/ui/components/organizer_tab.gd
@@ -0,0 +1,197 @@
+class_name OrganizerTab
+extends Button
+
+const PALETTE: Array[Color] = [
+ Color("b9deea"),
+ Color("3d9ca0"),
+ Color("4f9b72"),
+]
+const TEXT_COLOR := Color("102b35")
+const RISE_DURATION: float = UIMotion.UTILITY_ENTER_DURATION
+const INACTIVE_SETTLE_Y: float = 7.0
+const SELECTED_SETTLE_Y: float = 1.0
+const HOVER_SETTLE_Y: float = 5.0
+const HIDDEN_SETTLE_Y: float = 30.0
+const CAP_HEIGHT: float = 22.0
+const FONT_SIZE: int = 15
+
+@export_range(0, 2, 1) var palette_index: int = 0:
+ set(value):
+ palette_index = clampi(value, 0, PALETTE.size() - 1)
+ queue_redraw()
+
+var _motion_tween: Tween
+var _hovered: bool = false
+var _motion_ready: bool = false
+var _visual_y: float = INACTIVE_SETTLE_Y
+var _selected: bool = false
+
+
+func _ready() -> void:
+ toggle_mode = true
+ _selected = button_pressed
+ add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ add_theme_font_size_override("font_size", FONT_SIZE)
+ for color_name: StringName in [
+ &"font_color",
+ &"font_hover_color",
+ &"font_focus_color",
+ &"font_pressed_color",
+ &"font_disabled_color",
+ ]:
+ add_theme_color_override(color_name, Color.TRANSPARENT)
+ mouse_entered.connect(_set_hovered.bind(true))
+ mouse_exited.connect(_set_hovered.bind(false))
+ focus_entered.connect(refresh_state)
+ focus_exited.connect(refresh_state)
+ toggled.connect(_on_toggled)
+ _apply_empty_styles()
+ call_deferred("_initialize_motion")
+
+
+func refresh_state(animate: bool = true) -> void:
+ queue_redraw()
+ if _motion_ready:
+ _move_to_target(animate)
+
+
+func set_selected(value: bool, animate: bool = true) -> void:
+ _selected = value
+ set_pressed_no_signal(value)
+ refresh_state(animate)
+
+
+func animate_entrance(delay: float = 0.0) -> void:
+ _cancel_motion()
+ _motion_ready = true
+ _set_visual_y(HIDDEN_SETTLE_Y)
+ _motion_tween = create_tween()
+ if delay > 0.0:
+ _motion_tween.tween_interval(delay)
+ _motion_tween.tween_method(
+ _set_visual_y, _visual_y, _target_y(), RISE_DURATION
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
+ _motion_tween.finished.connect(_clear_motion_tween, CONNECT_ONE_SHOT)
+
+
+func settle_for_close() -> void:
+ _cancel_motion()
+ if not _motion_ready:
+ return
+ _motion_tween = create_tween()
+ _motion_tween.tween_method(
+ _set_visual_y,
+ _visual_y,
+ HIDDEN_SETTLE_Y,
+ UIMotion.UTILITY_EXIT_DURATION,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
+ _motion_tween.finished.connect(_clear_motion_tween, CONNECT_ONE_SHOT)
+
+
+func _initialize_motion() -> void:
+ _motion_ready = true
+ _set_visual_y(_target_y())
+
+
+func _on_toggled(is_pressed: bool) -> void:
+ if _selected and not is_pressed:
+ # A selected organizer tab is a page marker, not a collapsible toggle.
+ # Restore without another signal before any lower-state frame is drawn.
+ set_pressed_no_signal(true)
+ refresh_state(false)
+ return
+ _selected = is_pressed
+ refresh_state()
+
+
+func _set_hovered(hovered: bool) -> void:
+ _hovered = hovered
+ refresh_state()
+
+
+func _target_y() -> float:
+ if button_pressed:
+ return SELECTED_SETTLE_Y
+ if _hovered or has_focus():
+ return HOVER_SETTLE_Y
+ return INACTIVE_SETTLE_Y
+
+
+func _move_to_target(animate: bool) -> void:
+ _cancel_motion()
+ if not animate:
+ _set_visual_y(_target_y())
+ return
+ _motion_tween = create_tween()
+ _motion_tween.tween_method(
+ _set_visual_y, _visual_y, _target_y(), RISE_DURATION
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
+ _motion_tween.finished.connect(_clear_motion_tween, CONNECT_ONE_SHOT)
+
+
+func _draw() -> void:
+ var base_color: Color = PALETTE[palette_index]
+ base_color = (
+ base_color.lightened(0.06)
+ if button_pressed
+ else base_color.darkened(0.07)
+ )
+ if disabled:
+ base_color = base_color.lerp(Color("8a978f"), 0.62)
+ elif has_focus():
+ base_color = base_color.lightened(0.12)
+ elif _hovered:
+ base_color = base_color.lightened(0.08)
+ var width: float = size.x
+ var height: float = size.y
+ var points := PackedVector2Array([
+ Vector2(10.0, _visual_y),
+ Vector2(width - 10.0, _visual_y),
+ Vector2(width - 5.0, _visual_y + 5.0),
+ Vector2(width - 5.0, _visual_y + 17.0),
+ Vector2(width, _visual_y + 26.0),
+ Vector2(width, _visual_y + height),
+ Vector2(0.0, _visual_y + height),
+ Vector2(0.0, _visual_y + 26.0),
+ Vector2(5.0, _visual_y + 17.0),
+ Vector2(5.0, _visual_y + 5.0),
+ ])
+ draw_colored_polygon(points, base_color)
+ var font: Font = UtilityPageStyle.TuffyFont
+ var ink: Color = Color(TEXT_COLOR, 0.52) if disabled else TEXT_COLOR
+ var baseline: float = _visual_y + CAP_HEIGHT * 0.5 + FONT_SIZE * 0.35
+ draw_string(
+ font,
+ Vector2(0.0, baseline),
+ text,
+ HORIZONTAL_ALIGNMENT_CENTER,
+ size.x,
+ FONT_SIZE,
+ ink,
+ )
+
+
+func _apply_empty_styles() -> void:
+ for style_name: StringName in [
+ &"normal",
+ &"hover",
+ &"focus",
+ &"pressed",
+ &"disabled",
+ ]:
+ add_theme_stylebox_override(style_name, StyleBoxEmpty.new())
+
+
+func _set_visual_y(value: float) -> void:
+ _visual_y = value
+ queue_redraw()
+
+
+func _cancel_motion() -> void:
+ if _motion_tween != null:
+ _motion_tween.kill()
+ _motion_tween = null
+
+
+func _clear_motion_tween() -> void:
+ _motion_tween = null
diff --git a/ui/components/organizer_tab.gd.uid b/ui/components/organizer_tab.gd.uid
new file mode 100644
index 0000000..bc47750
--- /dev/null
+++ b/ui/components/organizer_tab.gd.uid
@@ -0,0 +1 @@
+uid://bkk8b2rdqaoa2
diff --git a/ui/controller_focus_navigation.gd b/ui/controller_focus_navigation.gd
new file mode 100644
index 0000000..cd93796
--- /dev/null
+++ b/ui/controller_focus_navigation.gd
@@ -0,0 +1,57 @@
+class_name ControllerFocusNavigation
+extends RefCounted
+
+const PERPENDICULAR_WEIGHT: float = 2.5
+const MINIMUM_FORWARD_DISTANCE: float = 0.5
+
+
+static func configure_spatial_neighbors(controls: Array) -> void:
+ var candidates: Array[Control] = []
+ for item: Variant in controls:
+ var control := item as Control
+ if control == null or not control.is_visible_in_tree():
+ continue
+ candidates.append(control)
+ for control: Control in candidates:
+ control.focus_neighbor_left = _neighbor_path(
+ control, candidates, Vector2.LEFT
+ )
+ control.focus_neighbor_right = _neighbor_path(
+ control, candidates, Vector2.RIGHT
+ )
+ control.focus_neighbor_top = _neighbor_path(
+ control, candidates, Vector2.UP
+ )
+ control.focus_neighbor_bottom = _neighbor_path(
+ control, candidates, Vector2.DOWN
+ )
+
+
+static func _neighbor_path(
+ origin: Control,
+ candidates: Array[Control],
+ direction: Vector2,
+) -> NodePath:
+ var origin_center: Vector2 = origin.get_global_rect().get_center()
+ var best: Control = null
+ var best_score: float = INF
+ for candidate: Control in candidates:
+ if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE:
+ continue
+ var delta: Vector2 = (
+ candidate.get_global_rect().get_center() - origin_center
+ )
+ var forward_distance: float = delta.dot(direction)
+ if forward_distance <= MINIMUM_FORWARD_DISTANCE:
+ continue
+ var perpendicular_distance: float = absf(delta.cross(direction))
+ var score: float = (
+ forward_distance
+ + perpendicular_distance * PERPENDICULAR_WEIGHT
+ )
+ if score < best_score:
+ best = candidate
+ best_score = score
+ if best == null:
+ return NodePath()
+ return origin.get_path_to(best)
diff --git a/ui/controller_focus_navigation.gd.uid b/ui/controller_focus_navigation.gd.uid
new file mode 100644
index 0000000..d13ed12
--- /dev/null
+++ b/ui/controller_focus_navigation.gd.uid
@@ -0,0 +1 @@
+uid://dfb6bobetmujn
diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd
new file mode 100644
index 0000000..7223ca7
--- /dev/null
+++ b/ui/controller_focus_presentation.gd
@@ -0,0 +1,99 @@
+class_name ControllerFocusPresentation
+extends Node
+
+const CONTROLLER_MOTION_THRESHOLD: float = 0.35
+const INVERSION_DISABLED_META: StringName = &"controller_focus_inversion_disabled"
+
+var _controller_active: bool = false
+var _focused_item: CanvasItem
+var _original_material: Material
+var _inversion_material: ShaderMaterial
+
+
+func _ready() -> void:
+ var inversion_shader := Shader.new()
+ inversion_shader.code = """
+shader_type canvas_item;
+render_mode unshaded;
+
+void fragment() {
+ vec4 source = texture(TEXTURE, UV) * COLOR;
+ COLOR = vec4(vec3(1.0) - source.rgb, source.a);
+}
+"""
+ _inversion_material = ShaderMaterial.new()
+ _inversion_material.shader = inversion_shader
+ get_viewport().gui_focus_changed.connect(_on_focus_changed)
+ set_process_input(true)
+ set_process(true)
+
+
+func _exit_tree() -> void:
+ _restore_focused_item()
+
+
+func _input(event: InputEvent) -> void:
+ if event is InputEventJoypadButton:
+ if (event as InputEventJoypadButton).pressed:
+ _set_controller_active(true)
+ elif event is InputEventJoypadMotion:
+ if absf((event as InputEventJoypadMotion).axis_value) >= (
+ CONTROLLER_MOTION_THRESHOLD
+ ):
+ _set_controller_active(true)
+ elif event is InputEventMouseMotion:
+ _set_controller_active(false)
+ elif event is InputEventMouseButton:
+ if (event as InputEventMouseButton).pressed:
+ _set_controller_active(false)
+ elif event is InputEventKey:
+ if (event as InputEventKey).pressed:
+ _set_controller_active(false)
+
+
+func _process(_delta: float) -> void:
+ if not is_instance_valid(_focused_item):
+ _focused_item = null
+ _original_material = null
+ return
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if (
+ focus_owner != _focused_item
+ or not focus_owner.is_visible_in_tree()
+ or focus_owner.focus_mode == Control.FOCUS_NONE
+ or bool(focus_owner.get_meta(INVERSION_DISABLED_META, false))
+ ):
+ _apply_to_focus(focus_owner)
+
+
+func _set_controller_active(active: bool) -> void:
+ if _controller_active == active:
+ return
+ _controller_active = active
+ _apply_to_focus(get_viewport().gui_get_focus_owner())
+
+
+func _on_focus_changed(control: Control) -> void:
+ _apply_to_focus(control)
+
+
+func _apply_to_focus(control: Control) -> void:
+ _restore_focused_item()
+ if (
+ not _controller_active
+ or control == null
+ or not control.is_visible_in_tree()
+ or control.focus_mode == Control.FOCUS_NONE
+ or bool(control.get_meta(INVERSION_DISABLED_META, false))
+ ):
+ return
+ _focused_item = control
+ _original_material = _focused_item.material
+ _focused_item.material = _inversion_material
+
+
+func _restore_focused_item() -> void:
+ if is_instance_valid(_focused_item):
+ _focused_item.material = _original_material
+ _focused_item = null
+ _original_material = null
diff --git a/ui/controller_focus_presentation.gd.uid b/ui/controller_focus_presentation.gd.uid
new file mode 100644
index 0000000..29b0a72
--- /dev/null
+++ b/ui/controller_focus_presentation.gd.uid
@@ -0,0 +1 @@
+uid://b2ccvcx1rfd1x
diff --git a/ui/controller_focus_recovery.gd b/ui/controller_focus_recovery.gd
new file mode 100644
index 0000000..bcaf6c8
--- /dev/null
+++ b/ui/controller_focus_recovery.gd
@@ -0,0 +1,277 @@
+class_name ControllerFocusRecovery
+extends Node
+
+const CONTROLLER_AXIS_THRESHOLD: float = 0.35
+const SEMANTIC_MATCH_BONUS: float = 1000000.0
+const KEYBOARD_NAVIGATION_ACTIONS: Array[StringName] = [
+ &"ui_accept",
+ &"ui_cancel",
+ &"ui_up",
+ &"ui_down",
+ &"ui_left",
+ &"ui_right",
+ &"ui_focus_next",
+ &"ui_focus_prev",
+ &"ui_page_up",
+ &"ui_page_down",
+ &"ui_home",
+ &"ui_end",
+]
+
+var _controller_active: bool = false
+var _focus_navigation_active: bool = false
+var _last_focus_center: Vector2 = Vector2.ZERO
+var _last_focus_key: String = ""
+var _scope_chain: Array[WeakRef] = []
+var _pending_focus: WeakRef
+var _pointer_button_down: bool = false
+var _recovery_generation: int = 0
+
+
+func _ready() -> void:
+ set_process_input(true)
+ set_process(true)
+ get_viewport().gui_focus_changed.connect(_on_gui_focus_changed)
+
+
+func _exit_tree() -> void:
+ var viewport := get_viewport()
+ if viewport.gui_focus_changed.is_connected(_on_gui_focus_changed):
+ viewport.gui_focus_changed.disconnect(_on_gui_focus_changed)
+
+
+func _input(event: InputEvent) -> void:
+ if event is InputEventJoypadButton:
+ var button_event := event as InputEventJoypadButton
+ if button_event.pressed:
+ _pointer_button_down = false
+ _controller_active = true
+ _focus_navigation_active = true
+ if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER:
+ _recovery_generation += 1
+ _scope_chain.clear()
+ _pending_focus = null
+ return
+ _request_pending_focus()
+ return
+ if event is InputEventJoypadMotion:
+ if absf((event as InputEventJoypadMotion).axis_value) >= (
+ CONTROLLER_AXIS_THRESHOLD
+ ):
+ _pointer_button_down = false
+ _controller_active = true
+ _focus_navigation_active = true
+ _request_pending_focus()
+ return
+ if event is InputEventMouseMotion:
+ _pointer_button_down = (
+ (event as InputEventMouseMotion).button_mask != 0
+ )
+ _leave_focus_navigation(not _pointer_button_down)
+ return
+ if event is InputEventMouseButton:
+ var mouse_button := event as InputEventMouseButton
+ if mouse_button.button_index in [
+ MOUSE_BUTTON_LEFT,
+ MOUSE_BUTTON_RIGHT,
+ MOUSE_BUTTON_MIDDLE,
+ ]:
+ _pointer_button_down = mouse_button.pressed
+ _leave_focus_navigation(false)
+ if not mouse_button.pressed:
+ _release_current_pointer_focus.call_deferred()
+ return
+ if event is InputEventKey and (event as InputEventKey).pressed:
+ _pointer_button_down = false
+ _controller_active = false
+ if _is_keyboard_navigation_event(event):
+ _focus_navigation_active = true
+ _request_pending_focus()
+ else:
+ _leave_focus_navigation()
+
+
+func _process(_delta: float) -> void:
+ if (
+ _controller_active
+ and not _scope_chain.is_empty()
+ and get_viewport().gui_get_focus_owner() == null
+ ):
+ _recover_focus(_recovery_generation)
+
+
+func _on_gui_focus_changed(control: Control) -> void:
+ _recovery_generation += 1
+ if control != null:
+ if _focus_navigation_active:
+ _pending_focus = null
+ if _controller_active and control is BaseButton:
+ _remember_focus(control)
+ return
+ if _keeps_pointer_focus(control):
+ return
+ if _is_focusable(control):
+ _pending_focus = weakref(control)
+ _release_focus_if_inactive.call_deferred(control)
+ return
+ if not _controller_active or _scope_chain.is_empty():
+ return
+ var generation: int = _recovery_generation
+ _recover_focus.call_deferred(generation)
+
+
+func _remember_focus(control: Control) -> void:
+ _last_focus_center = control.get_global_rect().get_center()
+ _last_focus_key = _semantic_key(control)
+ _scope_chain.clear()
+ var recovery_root: Node = get_parent()
+ var ancestor: Node = control.get_parent()
+ while ancestor != null:
+ if ancestor is Control:
+ _scope_chain.append(weakref(ancestor))
+ if ancestor == recovery_root:
+ break
+ ancestor = ancestor.get_parent()
+
+
+func _recover_focus(generation: int) -> void:
+ if generation != _recovery_generation:
+ return
+ if not _controller_active or get_viewport().gui_get_focus_owner() != null:
+ return
+ for scope_reference: WeakRef in _scope_chain:
+ var scope := scope_reference.get_ref() as Control
+ if (
+ scope == null
+ or not is_instance_valid(scope)
+ or not scope.is_inside_tree()
+ ):
+ continue
+ if not scope.is_visible_in_tree():
+ _scope_chain.clear()
+ return
+ var replacement := _best_replacement_in(scope)
+ if replacement != null:
+ replacement.grab_focus()
+ else:
+ _scope_chain.clear()
+ return
+ _scope_chain.clear()
+
+
+func _best_replacement_in(scope: Control) -> Control:
+ var best: Control = null
+ var best_score: float = INF
+ for node: Node in scope.find_children("*", "Control", true, false):
+ var candidate := node as Control
+ if not _is_focusable(candidate):
+ continue
+ var distance: float = candidate.get_global_rect().get_center().distance_squared_to(
+ _last_focus_center
+ )
+ if not _last_focus_key.is_empty() and _semantic_key(candidate) == _last_focus_key:
+ distance -= SEMANTIC_MATCH_BONUS
+ if distance < best_score:
+ best = candidate
+ best_score = distance
+ return best
+
+
+func _is_focusable(control: Control) -> bool:
+ if (
+ control == null
+ or not control.is_inside_tree()
+ or not control.is_visible_in_tree()
+ or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL]
+ ):
+ return false
+ var button := control as BaseButton
+ return button == null or not button.disabled
+
+
+func _semantic_key(control: Control) -> String:
+ if control.has_meta(&"controller_focus_key"):
+ return str(control.get_meta(&"controller_focus_key"))
+ var button := control as Button
+ var label: String = button.text if button != null else ""
+ var tooltip: String = control.tooltip_text
+ tooltip = tooltip.trim_suffix(" (show variants)")
+ tooltip = tooltip.trim_suffix(" (hide variants)")
+ return "%s|%s|%s" % [control.get_class(), label, tooltip]
+
+
+func _leave_focus_navigation(release_focus: bool = true) -> void:
+ _controller_active = false
+ _focus_navigation_active = false
+ _recovery_generation += 1
+ _scope_chain.clear()
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if focus_owner == null or _keeps_pointer_focus(focus_owner):
+ return
+ if _is_focusable(focus_owner):
+ _pending_focus = weakref(focus_owner)
+ if release_focus:
+ _release_focus_if_inactive.call_deferred(focus_owner)
+
+
+func _release_focus_if_inactive(control: Control) -> void:
+ if (
+ _focus_navigation_active
+ or _pointer_button_down
+ or control == null
+ or not is_instance_valid(control)
+ or get_viewport().gui_get_focus_owner() != control
+ or _keeps_pointer_focus(control)
+ ):
+ return
+ get_viewport().gui_release_focus()
+
+
+func _release_current_pointer_focus() -> void:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if focus_owner != null:
+ _release_focus_if_inactive(focus_owner)
+
+
+func _request_pending_focus() -> void:
+ if (
+ not _focus_navigation_active
+ or _pending_focus == null
+ or get_viewport().gui_get_focus_owner() != null
+ ):
+ return
+ _restore_pending_focus.call_deferred(_recovery_generation)
+
+
+func _restore_pending_focus(generation: int) -> void:
+ if (
+ generation != _recovery_generation
+ or not _focus_navigation_active
+ or _pending_focus == null
+ or get_viewport().gui_get_focus_owner() != null
+ ):
+ return
+ var target := _pending_focus.get_ref() as Control
+ if not _is_focusable(target):
+ _pending_focus = null
+ return
+ _pending_focus = null
+ target.grab_focus()
+
+
+func _is_keyboard_navigation_event(event: InputEvent) -> bool:
+ if event.is_action_pressed(&"open_backpack"):
+ return false
+ if (
+ event.is_action_pressed(&"ui_cancel")
+ and get_viewport().gui_get_focus_owner() == null
+ ):
+ return false
+ for action: StringName in KEYBOARD_NAVIGATION_ACTIONS:
+ if event.is_action_pressed(action):
+ return true
+ return false
+
+
+func _keeps_pointer_focus(control: Control) -> bool:
+ return control is LineEdit or control is TextEdit
diff --git a/ui/controller_focus_recovery.gd.uid b/ui/controller_focus_recovery.gd.uid
new file mode 100644
index 0000000..b7f8ce9
--- /dev/null
+++ b/ui/controller_focus_recovery.gd.uid
@@ -0,0 +1 @@
+uid://c7cw61ethr3pt
diff --git a/ui/controller_mapping_panel.gd b/ui/controller_mapping_panel.gd
new file mode 100644
index 0000000..42fdf92
--- /dev/null
+++ b/ui/controller_mapping_panel.gd
@@ -0,0 +1,566 @@
+class_name ControllerMappingPanel
+extends Control
+
+signal closed
+
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
+const CAPTURE_NEUTRAL_SECONDS: float = 0.22
+const CANCEL_COMBO_HOLD_SECONDS: float = 1.25
+
+var _mapping_manager: ControllerMappingManagerType
+var _binding_buttons: Dictionary = {}
+var _controller_label: Label
+var _instruction_label: Label
+var _progress_label: Label
+var _auto_map_button: Button
+var _reset_button: Button
+var _close_button: Button
+var _capturing_role: StringName = &""
+var _auto_map_active: bool = false
+var _auto_map_index: int = -1
+var _auto_map_draft: Dictionary = {}
+var _capture_device_id: int = 0
+var _waiting_for_neutral: bool = false
+var _neutral_elapsed: float = 0.0
+var _cancel_left_bumper_pressed: bool = false
+var _cancel_right_bumper_pressed: bool = false
+var _cancel_combo_elapsed: float = 0.0
+
+
+func _ready() -> void:
+ set_process_input(true)
+ set_process(true)
+ _build_interface()
+ hide()
+
+
+func setup(mapping_manager: ControllerMappingManagerType) -> void:
+ _mapping_manager = mapping_manager
+ if not _mapping_manager.active_controller_changed.is_connected(
+ _on_active_controller_changed
+ ):
+ _mapping_manager.active_controller_changed.connect(
+ _on_active_controller_changed
+ )
+ if not _mapping_manager.active_profile_changed.is_connected(
+ _refresh_bindings
+ ):
+ _mapping_manager.active_profile_changed.connect(_refresh_bindings)
+ if not _mapping_manager.controller_input_observed.is_connected(
+ _on_controller_input_observed
+ ):
+ _mapping_manager.controller_input_observed.connect(
+ _on_controller_input_observed
+ )
+ _refresh_bindings()
+
+
+func open_panel() -> void:
+ if _mapping_manager == null:
+ return
+ _cancel_capture()
+ _mapping_manager.recalibrate_active_device()
+ show()
+ _refresh_bindings()
+ _auto_map_button.grab_focus()
+
+
+func is_open() -> bool:
+ return visible
+
+
+func is_capturing() -> bool:
+ return visible and not _capturing_role.is_empty()
+
+
+func request_back() -> void:
+ if not visible:
+ return
+ if not _capturing_role.is_empty():
+ _cancel_capture()
+ return
+ close_panel()
+
+
+func close_panel() -> void:
+ _cancel_capture()
+ hide()
+ closed.emit()
+
+
+func _input(event: InputEvent) -> void:
+ if not visible or _mapping_manager == null:
+ return
+ if _capturing_role.is_empty():
+ return
+ var key_event := event as InputEventKey
+ if (
+ key_event != null
+ and key_event.pressed
+ and not key_event.echo
+ and key_event.keycode == KEY_ESCAPE
+ ):
+ get_viewport().set_input_as_handled()
+ _cancel_capture()
+ _progress_label.text = "controller mapping cancelled"
+ return
+
+
+func _process(delta: float) -> void:
+ if (
+ not visible
+ or _mapping_manager == null
+ or _capturing_role.is_empty()
+ ):
+ return
+ if _cancel_left_bumper_pressed and _cancel_right_bumper_pressed:
+ _cancel_combo_elapsed += delta
+ _progress_label.text = "keep holding both bumpers to cancel"
+ if _cancel_combo_elapsed >= CANCEL_COMBO_HOLD_SECONDS:
+ _cancel_capture()
+ _progress_label.text = "controller mapping cancelled"
+ return
+ _cancel_combo_elapsed = 0.0
+ if _waiting_for_neutral:
+ if not _mapping_manager.are_capture_inputs_neutral(_capture_device_id):
+ _neutral_elapsed = 0.0
+ return
+ _neutral_elapsed += delta
+ if _neutral_elapsed < CAPTURE_NEUTRAL_SECONDS:
+ return
+ _waiting_for_neutral = false
+ _neutral_elapsed = 0.0
+ _refresh_capture_prompt()
+ return
+ var pressed_button: int = _mapping_manager.get_pressed_capture_button(
+ _capture_device_id
+ )
+ if pressed_button < 0:
+ return
+ var button_event := InputEventJoypadButton.new()
+ button_event.device = _capture_device_id
+ button_event.button_index = pressed_button as JoyButton
+ button_event.pressed = true
+ _try_capture_event(button_event)
+
+
+func _on_controller_input_observed(event: InputEvent) -> void:
+ if not visible or _mapping_manager == null:
+ return
+ if _capturing_role.is_empty():
+ return
+ get_viewport().set_input_as_handled()
+ var button_event := event as InputEventJoypadButton
+ var motion_event := event as InputEventJoypadMotion
+ var event_device: int = -1
+ if button_event != null:
+ event_device = button_event.device
+ elif motion_event != null:
+ event_device = motion_event.device
+ else:
+ return
+ if event_device != _capture_device_id:
+ return
+ _track_cancel_combo_button(button_event)
+ if _waiting_for_neutral:
+ return
+ _try_capture_event(event)
+
+
+func _try_capture_event(event: InputEvent) -> void:
+ var binding: Dictionary = _mapping_manager.binding_from_event(
+ _capturing_role,
+ event,
+ )
+ if binding.is_empty():
+ return
+ if not _mapping_manager.validate_binding(_capturing_role, binding):
+ return
+ _accept_captured_binding(binding)
+
+
+func _build_interface() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ mouse_filter = Control.MOUSE_FILTER_STOP
+ z_index = 100
+ UtilityPageStyleType.apply_page(self)
+
+ var frame := PanelContainer.new()
+ frame.set_anchors_preset(Control.PRESET_FULL_RECT)
+ frame.offset_left = 18.0
+ frame.offset_top = 18.0
+ frame.offset_right = -18.0
+ frame.offset_bottom = -18.0
+ frame.add_theme_stylebox_override(
+ "panel",
+ UtilityPageStyleType.rounded_style(
+ UtilityPageStyleType.OCEAN_PANEL_DEEP,
+ 20,
+ ),
+ )
+ add_child(frame)
+
+ var outer_margin := MarginContainer.new()
+ outer_margin.add_theme_constant_override("margin_left", 24)
+ outer_margin.add_theme_constant_override("margin_top", 20)
+ outer_margin.add_theme_constant_override("margin_right", 24)
+ outer_margin.add_theme_constant_override("margin_bottom", 20)
+ frame.add_child(outer_margin)
+
+ var page := VBoxContainer.new()
+ page.add_theme_constant_override("separation", 12)
+ outer_margin.add_child(page)
+
+ var heading_row := HBoxContainer.new()
+ heading_row.add_theme_constant_override("separation", 12)
+ page.add_child(heading_row)
+
+ var title := Label.new()
+ title.text = "controller mapping"
+ title.add_theme_font_size_override("font_size", 28)
+ title.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY
+ )
+ title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ heading_row.add_child(title)
+
+ _controller_label = Label.new()
+ _controller_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ _controller_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _controller_label.add_theme_font_size_override("font_size", 15)
+ _controller_label.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
+ )
+ heading_row.add_child(_controller_label)
+
+ _instruction_label = Label.new()
+ _instruction_label.text = (
+ "auto-map walks through NETfishing's controller actions. "
+ + "select any row afterward to override it; the mapped back "
+ + "control cancels capture."
+ )
+ _instruction_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _instruction_label.add_theme_font_size_override("font_size", 15)
+ _instruction_label.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
+ )
+ page.add_child(_instruction_label)
+
+ _progress_label = Label.new()
+ _progress_label.custom_minimum_size.y = 34.0
+ _progress_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _progress_label.add_theme_font_size_override("font_size", 18)
+ _progress_label.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY
+ )
+ page.add_child(_progress_label)
+
+ var scroll := ScrollContainer.new()
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ page.add_child(scroll)
+
+ var role_grid := GridContainer.new()
+ role_grid.columns = 2
+ role_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ role_grid.add_theme_constant_override("h_separation", 12)
+ role_grid.add_theme_constant_override("v_separation", 7)
+ scroll.add_child(role_grid)
+
+ for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
+ var role_label := Label.new()
+ role_label.text = ControllerMappingManagerType.ROLE_LABELS.get(
+ role,
+ str(role),
+ )
+ role_label.custom_minimum_size = Vector2(330.0, 42.0)
+ role_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ role_label.add_theme_font_size_override("font_size", 16)
+ role_label.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY
+ )
+ role_grid.add_child(role_label)
+
+ var binding_button := Button.new()
+ binding_button.custom_minimum_size = Vector2(210.0, 42.0)
+ binding_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ binding_button.focus_mode = Control.FOCUS_ALL
+ binding_button.tooltip_text = (
+ "change " + str(ControllerMappingManagerType.ROLE_LABELS.get(
+ role,
+ str(role),
+ ))
+ )
+ UtilityPageStyleType.apply_compact_ocean_button(binding_button)
+ binding_button.pressed.connect(_begin_manual_capture.bind(role))
+ role_grid.add_child(binding_button)
+ _binding_buttons[role] = binding_button
+
+ var footer := HBoxContainer.new()
+ footer.alignment = BoxContainer.ALIGNMENT_END
+ footer.add_theme_constant_override("separation", 10)
+ page.add_child(footer)
+
+ _auto_map_button = Button.new()
+ _auto_map_button.text = "auto-map controller"
+ _auto_map_button.tooltip_text = (
+ "walk through every input on a standard xbox-style controller"
+ )
+ UtilityPageStyleType.apply_ocean_button(_auto_map_button)
+ _auto_map_button.pressed.connect(_begin_auto_map)
+ footer.add_child(_auto_map_button)
+
+ _reset_button = Button.new()
+ _reset_button.text = "restore defaults"
+ _reset_button.tooltip_text = "remove this controller's custom mapping"
+ UtilityPageStyleType.apply_ocean_button(_reset_button)
+ _reset_button.pressed.connect(_reset_mapping)
+ footer.add_child(_reset_button)
+
+ _close_button = Button.new()
+ _close_button.text = "done"
+ UtilityPageStyleType.apply_ocean_button(_close_button)
+ _close_button.pressed.connect(close_panel)
+ footer.add_child(_close_button)
+
+
+func _begin_auto_map() -> void:
+ if _mapping_manager == null:
+ return
+ _auto_map_active = true
+ _auto_map_index = 0
+ _auto_map_draft = {}
+ _reset_cancel_combo()
+ _capture_device_id = _mapping_manager.get_active_device_id()
+ _set_capture_role(ControllerMappingManagerType.ROLE_ORDER[_auto_map_index])
+
+
+func _begin_manual_capture(role: StringName) -> void:
+ _auto_map_active = false
+ _auto_map_index = -1
+ _auto_map_draft.clear()
+ _reset_cancel_combo()
+ _capture_device_id = _mapping_manager.get_active_device_id()
+ _set_capture_role(role)
+
+
+func _set_capture_role(role: StringName) -> void:
+ _capturing_role = role
+ _waiting_for_neutral = true
+ _neutral_elapsed = 0.0
+ _set_action_buttons_disabled(true)
+ _progress_label.text = "release all controller inputs"
+
+
+func _refresh_capture_prompt() -> void:
+ if _capturing_role.is_empty():
+ return
+ if _auto_map_active:
+ _progress_label.text = (
+ "step %d of %d: %s\n"
+ + "hold both bumpers for %.2f seconds to cancel"
+ ) % [
+ _auto_map_index + 1,
+ ControllerMappingManagerType.ROLE_ORDER.size(),
+ _mapping_manager.get_role_prompt(_capturing_role),
+ CANCEL_COMBO_HOLD_SECONDS,
+ ]
+ return
+ var input_instruction: String = "press any controller button"
+ if _mapping_manager.role_expects_axis(_capturing_role):
+ input_instruction = "move any controller axis"
+ elif _mapping_manager.role_accepts_axis(_capturing_role):
+ input_instruction = "press any button or move any controller axis"
+ _progress_label.text = "%s for %s" % [
+ input_instruction,
+ _mapping_manager.get_role_label(_capturing_role),
+ ]
+
+
+func _accept_captured_binding(binding: Dictionary) -> void:
+ if _auto_map_active:
+ _auto_map_draft[str(_capturing_role)] = binding.duplicate(true)
+ _auto_map_index += 1
+ if _auto_map_index < ControllerMappingManagerType.ROLE_ORDER.size():
+ _set_capture_role(
+ ControllerMappingManagerType.ROLE_ORDER[_auto_map_index]
+ )
+ return
+ var profile_saved: bool = _mapping_manager.replace_active_bindings(
+ _auto_map_draft
+ )
+ _cancel_capture()
+ _progress_label.text = (
+ "controller mapped successfully"
+ if profile_saved else "could not save the controller mapping"
+ )
+ _refresh_bindings()
+ return
+ var role: StringName = _capturing_role
+ var binding_saved: bool = _mapping_manager.set_binding(role, binding)
+ _cancel_capture()
+ _progress_label.text = (
+ "updated " + _mapping_manager.get_role_label(role)
+ if binding_saved else "could not save that controller input"
+ )
+ _refresh_bindings()
+
+func _cancel_capture() -> void:
+ _capturing_role = &""
+ _auto_map_active = false
+ _auto_map_index = -1
+ _auto_map_draft.clear()
+ _waiting_for_neutral = false
+ _neutral_elapsed = 0.0
+ _reset_cancel_combo()
+ _set_action_buttons_disabled(false)
+ if _progress_label != null:
+ _progress_label.text = ""
+
+
+func _track_cancel_combo_button(button_event: InputEventJoypadButton) -> void:
+ if button_event == null:
+ return
+ match button_event.button_index:
+ JOY_BUTTON_LEFT_SHOULDER:
+ _cancel_left_bumper_pressed = button_event.pressed
+ JOY_BUTTON_RIGHT_SHOULDER:
+ _cancel_right_bumper_pressed = button_event.pressed
+ _:
+ return
+ if not (
+ _cancel_left_bumper_pressed and _cancel_right_bumper_pressed
+ ):
+ _cancel_combo_elapsed = 0.0
+
+
+func _reset_cancel_combo() -> void:
+ _cancel_left_bumper_pressed = false
+ _cancel_right_bumper_pressed = false
+ _cancel_combo_elapsed = 0.0
+
+
+func _set_action_buttons_disabled(disabled: bool) -> void:
+ if _auto_map_button != null:
+ _auto_map_button.disabled = disabled
+ if _reset_button != null:
+ _reset_button.disabled = disabled
+ for button_value: Variant in _binding_buttons.values():
+ var button := button_value as Button
+ if button != null:
+ button.disabled = disabled
+
+
+func _reset_mapping() -> void:
+ if _mapping_manager == null:
+ return
+ var reset: bool = _mapping_manager.reset_active_profile()
+ _progress_label.text = (
+ "restored the project controller defaults"
+ if reset else "could not restore controller defaults"
+ )
+ _refresh_bindings()
+
+
+func _refresh_bindings() -> void:
+ if _mapping_manager == null or _controller_label == null:
+ return
+ _controller_label.text = _mapping_manager.get_active_controller_name()
+ var bindings: Dictionary = _mapping_manager.get_active_bindings()
+ for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
+ var button := _binding_buttons.get(role) as Button
+ if button == null:
+ continue
+ var raw_binding: Variant = bindings.get(str(role), {})
+ button.text = (
+ _mapping_manager.binding_label(raw_binding as Dictionary)
+ if typeof(raw_binding) == TYPE_DICTIONARY else "unmapped"
+ )
+ var conflict_role: StringName = _find_binding_conflict(
+ role,
+ raw_binding as Dictionary,
+ bindings,
+ )
+ _apply_binding_button_style(button, not conflict_role.is_empty())
+ button.tooltip_text = (
+ "warning: also assigned to "
+ + _mapping_manager.get_role_label(conflict_role)
+ if not conflict_role.is_empty()
+ else "change " + _mapping_manager.get_role_label(role)
+ )
+ _reset_button.disabled = not _mapping_manager.has_custom_mapping()
+
+
+func _find_binding_conflict(
+ role: StringName,
+ binding: Dictionary,
+ bindings: Dictionary,
+) -> StringName:
+ if binding.is_empty():
+ return &""
+ for other_role: StringName in ControllerMappingManagerType.ROLE_ORDER:
+ if other_role == role:
+ continue
+ var raw_other: Variant = bindings.get(str(other_role), {})
+ if typeof(raw_other) != TYPE_DICTIONARY:
+ continue
+ var other := raw_other as Dictionary
+ if _bindings_conflict(role, binding, other_role, other):
+ return other_role
+ return &""
+
+
+func _bindings_conflict(
+ first_role: StringName,
+ first: Dictionary,
+ second_role: StringName,
+ second: Dictionary,
+) -> bool:
+ var binding_kind: String = str(first.get("kind", ""))
+ if binding_kind != str(second.get("kind", "")):
+ return false
+ if binding_kind == "button":
+ return int(first.get("button", -1)) == int(
+ second.get("button", -1)
+ )
+ if binding_kind != "axis":
+ return false
+ if int(first.get("axis", -1)) != int(second.get("axis", -1)):
+ return false
+ var opposite_trigger_halves: bool = (
+ first_role in ControllerMappingManagerType.TRIGGER_ROLES
+ and second_role in ControllerMappingManagerType.TRIGGER_ROLES
+ and signf(float(first.get("direction", 0.0)))
+ != signf(float(second.get("direction", 0.0)))
+ )
+ return not opposite_trigger_halves
+
+
+func _apply_binding_button_style(button: Button, has_conflict: bool) -> void:
+ UtilityPageStyleType.apply_compact_ocean_button(button)
+ if not has_conflict:
+ return
+ var colors: Dictionary = {
+ &"normal": UtilityPageStyleType.OCEAN_DANGER,
+ &"hover": UtilityPageStyleType.OCEAN_DANGER.lightened(0.12),
+ &"pressed": UtilityPageStyleType.OCEAN_DANGER.darkened(0.12),
+ &"focus": UtilityPageStyleType.OCEAN_DANGER.lightened(0.12),
+ }
+ for state: StringName in colors:
+ var style := UtilityPageStyleType.ocean_button_style(
+ colors[state] as Color
+ )
+ style.content_margin_left = 10.0
+ style.content_margin_right = 10.0
+ style.content_margin_top = 4.0
+ style.content_margin_bottom = 4.0
+ button.add_theme_stylebox_override(state, style)
+
+
+func _on_active_controller_changed(_controller_name: String) -> void:
+ if not _capturing_role.is_empty():
+ _cancel_capture()
+ _refresh_bindings()
diff --git a/ui/controller_mapping_panel.gd.uid b/ui/controller_mapping_panel.gd.uid
new file mode 100644
index 0000000..41f5e25
--- /dev/null
+++ b/ui/controller_mapping_panel.gd.uid
@@ -0,0 +1 @@
+uid://b0rl6lgpc0h85
diff --git a/ui/controller_virtual_cursor.gd b/ui/controller_virtual_cursor.gd
new file mode 100644
index 0000000..f56bb3e
--- /dev/null
+++ b/ui/controller_virtual_cursor.gd
@@ -0,0 +1,40 @@
+class_name ControllerVirtualCursor
+extends Control
+
+const CURSOR_SIZE: Vector2 = Vector2(28.0, 28.0)
+const OUTER_COLOR: Color = Color(0.02, 0.10, 0.14, 0.96)
+const INNER_COLOR: Color = Color(0.34, 0.86, 0.91, 1.0)
+const CENTER_COLOR: Color = Color(0.96, 1.0, 1.0, 1.0)
+
+
+func _ready() -> void:
+ size = CURSOR_SIZE
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ focus_mode = Control.FOCUS_NONE
+ visible = false
+ queue_redraw()
+
+
+func set_pointer_position(pointer_position: Vector2) -> void:
+ position = pointer_position - CURSOR_SIZE * 0.5
+
+
+func _draw() -> void:
+ var center: Vector2 = CURSOR_SIZE * 0.5
+ draw_circle(center, 8.0, OUTER_COLOR)
+ draw_circle(center, 5.0, INNER_COLOR)
+ draw_circle(center, 1.75, CENTER_COLOR)
+ draw_line(Vector2(center.x, 0.0), Vector2(center.x, 6.0), CENTER_COLOR, 2.0)
+ draw_line(
+ Vector2(center.x, CURSOR_SIZE.y - 6.0),
+ Vector2(center.x, CURSOR_SIZE.y),
+ CENTER_COLOR,
+ 2.0,
+ )
+ draw_line(Vector2(0.0, center.y), Vector2(6.0, center.y), CENTER_COLOR, 2.0)
+ draw_line(
+ Vector2(CURSOR_SIZE.x - 6.0, center.y),
+ Vector2(CURSOR_SIZE.x, center.y),
+ CENTER_COLOR,
+ 2.0,
+ )
diff --git a/ui/controller_virtual_cursor.gd.uid b/ui/controller_virtual_cursor.gd.uid
new file mode 100644
index 0000000..309a5c4
--- /dev/null
+++ b/ui/controller_virtual_cursor.gd.uid
@@ -0,0 +1 @@
+uid://dehegl7ccn057
diff --git a/ui/currency_presentation.gd b/ui/currency_presentation.gd
new file mode 100644
index 0000000..22fb32a
--- /dev/null
+++ b/ui/currency_presentation.gd
@@ -0,0 +1,28 @@
+class_name CurrencyPresentation
+extends RefCounted
+
+const ICON_PATH: String = (
+ "res://items/icons/shop/32_currency.png"
+)
+const AMOUNT_SCENE: PackedScene = preload(
+ "res://ui/components/currency_amount.tscn"
+)
+
+
+static func instantiate_amount(
+ value: int,
+ icon_size: float = 18.0,
+) -> CurrencyAmount:
+ var display := AMOUNT_SCENE.instantiate() as CurrencyAmount
+ display.icon_size = icon_size
+ display.amount = value
+ return display
+
+
+static func bbcode_amount(value: int, icon_size: int = 18) -> String:
+ return "[img=%dx%d]%s[/img] %d" % [
+ icon_size,
+ icon_size,
+ ICON_PATH,
+ value,
+ ]
diff --git a/ui/currency_presentation.gd.uid b/ui/currency_presentation.gd.uid
new file mode 100644
index 0000000..210c2b8
--- /dev/null
+++ b/ui/currency_presentation.gd.uid
@@ -0,0 +1 @@
+uid://bcvy1l2jqqbmt
diff --git a/ui/emote_radial_menu.gd b/ui/emote_radial_menu.gd
new file mode 100644
index 0000000..c081e27
--- /dev/null
+++ b/ui/emote_radial_menu.gd
@@ -0,0 +1,153 @@
+class_name EmoteRadialMenu
+extends Control
+
+signal emote_selected(emote_id: StringName)
+
+const BubbleButtonScene: PackedScene = preload(
+ "res://ui/components/bubble_menu/bubble_button.tscn"
+)
+const BubbleProfile: BubbleMenuProfile = preload(
+ "res://ui/components/bubble_menu/bubble_menu_profile.tres"
+)
+const SECTOR_COUNT: int = 8
+const SIT_SECTOR: int = 0
+const RING_RADIUS: float = 172.0
+const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0)
+const CONTROLLER_SELECTION_DEADZONE: float = 0.35
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+
+var _buttons: Array[BubbleButton] = []
+var _is_open: bool = false
+var _selected_sector: int = SIT_SECTOR
+var _controller_selection_mode: bool = false
+var _controller_mapping_manager: ControllerMappingManagerType
+
+
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+
+
+func _ready() -> void:
+ visible = false
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ for sector: int in SECTOR_COUNT:
+ var bubble: BubbleButton = BubbleButtonScene.instantiate() as BubbleButton
+ bubble.profile = BubbleProfile
+ bubble.focus_mode = Control.FOCUS_NONE
+ bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bubble.text = "Sit" if sector == SIT_SECTOR else ""
+ add_child(bubble)
+ _buttons.append(bubble)
+ _apply_selection_styles()
+
+
+func handle_input(event: InputEvent, can_open: bool) -> bool:
+ if not event.is_action("open_emotes"):
+ return false
+ var key_event: InputEventKey = event as InputEventKey
+ if key_event != null and key_event.echo:
+ return false
+ if event.is_pressed():
+ if _is_open or not can_open:
+ return _is_open
+ open_menu(event is InputEventJoypadButton)
+ return true
+ if not _is_open:
+ return false
+ var selected: int = _selected_sector
+ close_menu()
+ if selected == SIT_SECTOR:
+ emote_selected.emit(&"sit")
+ return true
+
+
+func open_menu(controller_selection: bool = false) -> void:
+ _is_open = true
+ _selected_sector = SIT_SECTOR
+ _controller_selection_mode = controller_selection
+ visible = true
+ _layout_bubbles()
+ _apply_selection_styles()
+
+
+func close_menu() -> void:
+ _is_open = false
+ visible = false
+
+
+func is_open() -> bool:
+ return _is_open
+
+
+func _process(_delta: float) -> void:
+ if not _is_open:
+ return
+ _layout_bubbles()
+ _update_selection()
+
+
+func _layout_bubbles() -> void:
+ var center: Vector2 = size * 0.5
+ for sector: int in SECTOR_COUNT:
+ var angle: float = -PI * 0.5 + TAU * float(sector) / float(SECTOR_COUNT)
+ var bubble_center: Vector2 = center + Vector2.from_angle(angle) * RING_RADIUS
+ var bubble: BubbleButton = _buttons[sector]
+ bubble.position = bubble_center - BUBBLE_SIZE * 0.5
+ bubble.size = BUBBLE_SIZE
+ bubble.pivot_offset = BUBBLE_SIZE * 0.5
+
+
+func _update_selection() -> void:
+ var stick: Vector2 = _get_selection_stick()
+ if stick.length() >= CONTROLLER_SELECTION_DEADZONE:
+ _select_sector_from_offset(stick)
+ return
+ if _controller_selection_mode:
+ return
+ _update_mouse_selection()
+
+
+func _get_selection_stick() -> Vector2:
+ if _controller_mapping_manager != null:
+ return Vector2(
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X
+ ),
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_Y
+ ),
+ )
+ return Vector2(
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_X),
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y),
+ )
+
+
+func _update_mouse_selection() -> void:
+ var offset: Vector2 = get_local_mouse_position() - size * 0.5
+ if offset.length() < 24.0:
+ return
+ _select_sector_from_offset(offset)
+
+
+func _select_sector_from_offset(offset: Vector2) -> void:
+ var angle: float = fposmod(offset.angle() + PI * 0.5 + PI / 8.0, TAU)
+ var sector: int = int(floor(angle / (TAU / float(SECTOR_COUNT))))
+ if sector == _selected_sector:
+ return
+ _selected_sector = sector
+ _apply_selection_styles()
+
+
+func _apply_selection_styles() -> void:
+ for sector: int in _buttons.size():
+ var bubble: BubbleButton = _buttons[sector]
+ bubble.apply_profile()
+ if sector == _selected_sector:
+ bubble.add_theme_stylebox_override(
+ "normal", BubbleProfile.make_hover_style()
+ )
diff --git a/ui/emote_radial_menu.gd.uid b/ui/emote_radial_menu.gd.uid
new file mode 100644
index 0000000..1a1f6a0
--- /dev/null
+++ b/ui/emote_radial_menu.gd.uid
@@ -0,0 +1 @@
+uid://d3emoteradialmenu
diff --git a/ui/emote_radial_menu.tscn b/ui/emote_radial_menu.tscn
new file mode 100644
index 0000000..6960a74
--- /dev/null
+++ b/ui/emote_radial_menu.tscn
@@ -0,0 +1,14 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/emote_radial_menu.gd" id="1_emote"]
+
+[node name="EmoteRadialMenu" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+z_index = 90
+script = ExtResource("1_emote")
diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd
index 1e7dfdd..d8678b7 100644
--- a/ui/fishing_shop.gd
+++ b/ui/fishing_shop.gd
@@ -4,10 +4,6 @@ extends Control
const INPUT_OWNER: StringName = &"fishing_shop"
const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
-const FishCatchType = preload("res://fish/fish_catch.gd")
-const FishInventoryType = preload("res://inventory/fish_inventory.gd")
-const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
-const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
@@ -21,14 +17,34 @@ const PlayerType = preload("res://player/player.gd")
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
+const PlayerArtUnlocksType = preload(
+ "res://progression/player_art_unlocks.gd"
+)
+const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const ShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
-const FishBatchSelectionType = preload(
- "res://ui/fish_batch_selection.gd"
+const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
+const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
+const UIMotionType = preload("res://ui/ui_motion.gd")
+const FALLBACK_SUPPLY_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/x_light.png"
+)
+const ART_KIT_MARKER_ICON: Texture2D = preload(
+ "res://items/icons/art/art_kit_marker.png"
+)
+const ART_KIT_MARKER_SHADER: Shader = preload(
+ "res://ui/art_kit_marker_icon.gdshader"
+)
+const CURRENCY_ICON: Texture2D = preload(
+ "res://items/icons/shop/32_currency.png"
)
signal menu_visibility_changed(is_open: bool)
+signal menu_exit_started
+signal sell_fish_requested
+signal shop_cooler_return_requested
+signal shop_cooler_confirmation_cancel_requested
enum CloseReason {
USER,
@@ -38,38 +54,60 @@ enum CloseReason {
TEARDOWN,
}
+enum ShopSection {
+ UPGRADES,
+ BAIT,
+ SNACKS,
+ EQUIPMENT,
+ ART_SUPPLIES,
+ SELL_FISH,
+}
+
+const SHOP_SECTION_LABELS: Array[String] = [
+ "Upgrades",
+ "Bait and Lures",
+ "Snacks",
+ "Equipment",
+ "Art Supplies",
+ "Sell Fish",
+]
+const SUPPLY_ICON_GRID_COLUMNS: int = 9
+const SUPPLY_ICON_TILE_SIZE := Vector2(72.0, 72.0)
+const SUPPLY_ICON_HOST_SIZE := Vector2(72.0, 84.0)
+const SUPPLY_ICON_GRID_SEPARATION: int = 8
+const SUPPLY_PRICE_COLOR := Color("c3dfe6")
+const SUPPLY_PRICE_FONT_SIZE: int = 15
+const SUPPLY_PRICE_Y: float = 60.0
+const SUPPLY_PRICE_HEIGHT: float = 24.0
+const SUPPLY_PRICE_HORIZONTAL_PADDING: float = 12.0
+const SUPPLY_PRICE_ICON_SIZE: float = 18.0
+const SUPPLY_PRICE_ICON_GAP: float = 3.0
+
@onready var _wallet_label: Label = %WalletLabel
-@onready var _fish_list: ItemList = %FishList
-@onready var _sales_title: Label = %SalesTitle
-@onready var _fish_empty: Label = %FishEmpty
-@onready var _fish_texture: TextureRect = %FishTexture
-@onready var _fish_name: Label = %FishName
-@onready var _fish_details: Label = %FishDetails
-@onready var _selection_summary: Label = %SelectionSummary
-@onready var _sell_button: Button = %SellButton
-@onready var _feedback: Label = %Feedback
-@onready var _reel_level: Label = %ReelLevel
-@onready var _reel_effect: Label = %ReelEffect
-@onready var _reel_cost: Label = %ReelCost
+@onready var _shop_panel: PanelContainer = %ShopPanel
+@onready var _shop_panel_margin: MarginContainer = $ShopPanel/Margin
+@onready var _shop_panel_layout: VBoxContainer = $ShopPanel/Margin/Layout
+@onready var _shop_cooler_page: Control = %ShopCoolerPage
+@onready var _shop_cooler_mount: Control = %ShopCoolerMount
+@onready var _shop_body: HBoxContainer = $ShopPanel/Margin/Layout/Body
+@onready var _feedback: RichTextLabel = %Feedback
+@onready var _shop_tab_bar: HBoxContainer = %ShopTabBar
+@onready var _upgrades_content: VBoxContainer = %Upgrades
+@onready var _supplies_content: VBoxContainer = %Supplies
+@onready var _stock_title: Label = %StockTitle
+@onready var _reel_cost: CurrencyAmount = %ReelCost
+@onready var _reel_price_bubble: PanelContainer = %ReelPriceBubble
@onready var _reel_purchase: Button = %ReelPurchase
-@onready var _barrier_level: Label = %BarrierLevel
-@onready var _barrier_effect: Label = %BarrierEffect
-@onready var _barrier_cost: Label = %BarrierCost
+@onready var _barrier_cost: CurrencyAmount = %BarrierCost
+@onready var _barrier_price_bubble: PanelContainer = %BarrierPriceBubble
@onready var _barrier_purchase: Button = %BarrierPurchase
@onready var _supplies_list: VBoxContainer = %SuppliesList
-@onready var _cooler_level: Label = %CoolerLevel
-@onready var _cooler_effect: Label = %CoolerEffect
-@onready var _cooler_cost: Label = %CoolerCost
+@onready var _cooler_cost: CurrencyAmount = %CoolerCost
+@onready var _cooler_price_bubble: PanelContainer = %CoolerPriceBubble
@onready var _cooler_purchase: Button = %CoolerPurchase
-@onready var _confirmation: PanelContainer = %SaleConfirmation
-@onready var _confirmation_text: Label = %ConfirmationText
-@onready var _confirm_sale: Button = %ConfirmSale
-@onready var _cancel_sale: Button = %CancelSale
var _player: PlayerType
-var _inventory: FishInventoryType
var _wallet: PlayerWalletType
-var _sale_service: FishSaleServiceType
var _buyer: FishBuyerProfileType
var _upgrades: PlayerFishingUpgradesType
var _fishing_spot: FishingSpotType
@@ -77,37 +115,179 @@ var _interaction: ShopInteractionType
var _bag: PlayerBagType
var _item_catalog: ItemCatalogType
var _cooler_capacity: PlayerCoolerCapacityType
-var _fish_selection := FishBatchSelectionType.new()
-var _confirmation_catch_ids: Array[StringName] = []
-var _confirmation_generation: int = -1
+var _art_unlocks: PlayerArtUnlocksType
+var _network_shop: NetworkShopService
var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
var _snapshot_stored: bool = false
var _mouse_snapshot_stored: bool = false
-var _generation: int = 0
-var _selection_input_generation: int = 0
var _transaction_in_progress: bool = false
var _closing: bool = false
+var _close_generation: int = 0
+var _close_tween: Tween
+var _cooler_page_active: bool = false
+var _cooler_modal_open: bool = false
+var _shop_section: ShopSection = ShopSection.UPGRADES
+var _shop_tabs: Array[OrganizerTab] = []
func _ready() -> void:
+ UtilityPageStyleType.apply_page(self)
+ _apply_shop_styles()
+ _build_shop_tabs()
%CloseButton.pressed.connect(close_shop)
- _fish_list.item_selected.connect(_on_fish_selected)
- _fish_list.item_clicked.connect(_on_fish_clicked)
- _sell_button.pressed.connect(_open_sale_confirmation)
- _confirm_sale.pressed.connect(_on_confirm_sale)
- _cancel_sale.pressed.connect(_close_sale_confirmation)
_reel_purchase.pressed.connect(_purchase_reel_speed)
_barrier_purchase.pressed.connect(_purchase_barrier_power)
_cooler_purchase.pressed.connect(_purchase_cooler_capacity)
+func _request_shop_cooler() -> bool:
+ if _transaction_in_progress:
+ _set_feedback("Finish the current transaction first.")
+ return false
+ if not _is_transaction_context_valid():
+ _set_feedback("The fishing shop is no longer available.")
+ return false
+ sell_fish_requested.emit()
+ return _cooler_page_active
+
+
+func _focus_shop_section() -> void:
+ _set_feedback("")
+ if _shop_section == ShopSection.UPGRADES:
+ _reel_purchase.grab_focus()
+ return
+ if _shop_section == ShopSection.SELL_FISH:
+ _shop_tabs[int(ShopSection.SELL_FISH)].grab_focus()
+ return
+ for child: Node in _supplies_list.find_children(
+ "*", "Button", true, false
+ ):
+ var stock_button := child as Button
+ if stock_button != null and not stock_button.disabled:
+ stock_button.grab_focus()
+ return
+ var section_index: int = int(_shop_section)
+ if section_index >= 0 and section_index < _shop_tabs.size():
+ _shop_tabs[section_index].grab_focus()
+
+
+func _build_shop_tabs() -> void:
+ _shop_tab_bar.custom_minimum_size.y = 44.0
+ _shop_tab_bar.show()
+ for section_index: int in range(ShopSection.size()):
+ if section_index == ShopSection.SELL_FISH:
+ var tab_group_spacer := Control.new()
+ tab_group_spacer.name = "SellTabSpacer"
+ tab_group_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ tab_group_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _shop_tab_bar.add_child(tab_group_spacer)
+ var tab: OrganizerTab = OrganizerTabType.new()
+ tab.text = SHOP_SECTION_LABELS[section_index]
+ tab.palette_index = section_index
+ tab.custom_minimum_size = Vector2(132.0, 42.0)
+ tab.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ tab.focus_mode = Control.FOCUS_ALL
+ tab.mouse_filter = Control.MOUSE_FILTER_STOP
+ tab.pressed.connect(_select_shop_section.bind(section_index, true))
+ _shop_tab_bar.add_child(tab)
+ tab.show()
+ _shop_tabs.append(tab)
+ _select_shop_section(ShopSection.UPGRADES, false)
+
+
+func _select_shop_section(section_index: int, focus_content: bool) -> void:
+ if _closing:
+ return
+ if section_index == int(_shop_section):
+ if section_index != ShopSection.SELL_FISH:
+ var showing_upgrades := section_index == ShopSection.UPGRADES
+ _upgrades_content.visible = showing_upgrades
+ _supplies_content.visible = not showing_upgrades
+ _update_shop_tab_selection()
+ if focus_content:
+ _focus_shop_section()
+ return
+ if section_index == ShopSection.SELL_FISH:
+ if not _request_shop_cooler():
+ _update_shop_tab_selection()
+ return
+ _shop_section = ShopSection.SELL_FISH
+ _update_shop_tab_selection()
+ return
+ if _cooler_page_active:
+ shop_cooler_return_requested.emit()
+ _shop_section = section_index as ShopSection
+ var showing_upgrades: bool = _shop_section == ShopSection.UPGRADES
+ _upgrades_content.visible = showing_upgrades
+ _supplies_content.visible = not showing_upgrades
+ _update_shop_tab_selection()
+ _refresh_supplies()
+ if focus_content and visible:
+ _focus_shop_section()
+
+
+func _update_shop_tab_selection() -> void:
+ for tab_index: int in range(_shop_tabs.size()):
+ _shop_tabs[tab_index].set_selected(
+ tab_index == int(_shop_section),
+ true,
+ )
+
+
+func _apply_shop_styles() -> void:
+ var shop_panel := $ShopPanel as PanelContainer
+ var shop_panel_style := UtilityPageStyleType.panel_style()
+ shop_panel_style.set_corner_radius_all(24)
+ shop_panel_style.content_margin_left = 0.0
+ shop_panel_style.content_margin_top = 0.0
+ shop_panel_style.content_margin_right = 0.0
+ shop_panel_style.content_margin_bottom = 0.0
+ shop_panel.add_theme_stylebox_override(
+ "panel", shop_panel_style
+ )
+ for panel: PanelContainer in [
+ _reel_price_bubble,
+ _barrier_price_bubble,
+ _cooler_price_bubble,
+ ]:
+ var price_style := UtilityPageStyleType.rounded_style(
+ UtilityPageStyleType.OCEAN_FIELD,
+ 18,
+ )
+ price_style.content_margin_left = 8.0
+ price_style.content_margin_right = 8.0
+ price_style.content_margin_top = 2.0
+ price_style.content_margin_bottom = 2.0
+ panel.add_theme_stylebox_override(
+ "panel", price_style
+ )
+ for price: CurrencyAmount in [
+ _reel_cost,
+ _barrier_cost,
+ _cooler_cost,
+ ]:
+ var amount_label := price.get_amount_label()
+ amount_label.add_theme_color_override(
+ "font_color", SUPPLY_PRICE_COLOR
+ )
+ amount_label.add_theme_font_size_override("font_size", 18)
+ for button: BaseButton in [
+ %CloseButton,
+ _reel_purchase,
+ _barrier_purchase,
+ _cooler_purchase,
+ ]:
+ UtilityPageStyleType.apply_ocean_button(button)
+ _feedback.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
+ )
+
+
func setup(
player: PlayerType,
- inventory: FishInventoryType,
wallet: PlayerWalletType,
- sale_service: FishSaleServiceType,
buyer: FishBuyerProfileType,
upgrades: PlayerFishingUpgradesType,
fishing_spot: FishingSpotType,
@@ -115,11 +295,11 @@ func setup(
bag: PlayerBagType,
item_catalog: ItemCatalogType,
cooler_capacity: PlayerCoolerCapacityType,
+ art_unlocks: PlayerArtUnlocksType,
+ network_shop: NetworkShopService,
) -> void:
_player = player
- _inventory = inventory
_wallet = wallet
- _sale_service = sale_service
_buyer = buyer
_upgrades = upgrades
_fishing_spot = fishing_spot
@@ -127,9 +307,20 @@ func setup(
_bag = bag
_item_catalog = item_catalog
_cooler_capacity = cooler_capacity
- _fish_selection.clear()
- if not _inventory.catches_changed.is_connected(_on_inventory_changed):
- _inventory.catches_changed.connect(_on_inventory_changed)
+ _art_unlocks = art_unlocks
+ _network_shop = network_shop
+ if (
+ _network_shop != null
+ and not _network_shop.local_purchase_pending.is_connected(
+ _on_network_purchase_pending
+ )
+ ):
+ _network_shop.local_purchase_pending.connect(
+ _on_network_purchase_pending
+ )
+ _network_shop.local_purchase_finished.connect(
+ _on_network_purchase_finished
+ )
if not _wallet.balance_changed.is_connected(_on_wallet_changed):
_wallet.balance_changed.connect(_on_wallet_changed)
if not _upgrades.upgrades_changed.is_connected(_on_upgrades_changed):
@@ -140,6 +331,8 @@ func setup(
_on_cooler_capacity_changed
):
_cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed)
+ if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed):
+ _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed)
_refresh_all()
@@ -156,9 +349,14 @@ func open_shop() -> bool:
or _buyer.id != MAIN_SHOP_BUYER_ID
):
return false
- _generation += 1
+ _cancel_close_tween()
+ _close_generation += 1
_closing = false
- _transaction_in_progress = false
+ modulate.a = 1.0
+ _transaction_in_progress = (
+ _network_shop != null
+ and _network_shop.is_local_purchase_pending()
+ )
_prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode
@@ -168,11 +366,13 @@ func open_shop() -> bool:
_player.set_camera_input_enabled(false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
- _feedback.text = ""
- _close_sale_confirmation()
+ _set_feedback("")
+ deactivate_shop_cooler_page()
show()
+ _shop_tab_bar.show()
+ _select_shop_section(ShopSection.UPGRADES, false)
_refresh_all()
- %CloseButton.grab_focus()
+ _reel_purchase.grab_focus()
menu_visibility_changed.emit(true)
return true
@@ -180,26 +380,109 @@ func open_shop() -> bool:
func consume_escape() -> bool:
if not visible:
return false
- if _confirmation.visible:
- _close_sale_confirmation()
- else:
- close_shop()
+ if _closing:
+ return true
+ if _cooler_page_active:
+ if _cooler_modal_open:
+ shop_cooler_confirmation_cancel_requested.emit()
+ else:
+ _select_shop_section(ShopSection.UPGRADES, true)
+ return true
+ close_shop()
return true
+func get_shop_cooler_mount() -> Control:
+ return _shop_cooler_mount
+
+
+func activate_shop_cooler_page() -> void:
+ _cooler_page_active = true
+ _shop_panel.show()
+ _set_shop_panel_background_pointer_blocking(false)
+ _shop_body.hide()
+ _feedback.hide()
+ _shop_cooler_page.show()
+
+
+func deactivate_shop_cooler_page() -> void:
+ if visible:
+ _shop_tab_bar.show()
+ _cooler_page_active = false
+ _cooler_modal_open = false
+ _set_shop_panel_background_pointer_blocking(true)
+ for tab: OrganizerTab in _shop_tabs:
+ tab.disabled = false
+ _shop_cooler_page.hide()
+ _shop_panel.show()
+ _shop_body.show()
+ _feedback.show()
+
+
+func _set_shop_panel_background_pointer_blocking(blocking: bool) -> void:
+ var mouse_filter := (
+ Control.MOUSE_FILTER_STOP
+ if blocking
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _shop_panel.mouse_filter = mouse_filter
+ _shop_panel_margin.mouse_filter = mouse_filter
+ _shop_panel_layout.mouse_filter = mouse_filter
+
+
+func set_shop_cooler_modal_open(is_open: bool) -> void:
+ _cooler_modal_open = is_open
+ for tab: OrganizerTab in _shop_tabs:
+ tab.disabled = is_open
+
+
func close_shop(
reason: CloseReason = CloseReason.USER,
restore_controls: bool = true,
) -> void:
if not visible:
return
+ if _closing:
+ if reason in [
+ CloseReason.WATER_RECOVERY,
+ CloseReason.SESSION_END,
+ CloseReason.TEARDOWN,
+ ]:
+ _finish_close(reason, restore_controls, _close_generation)
+ return
_closing = true
- _generation += 1
_transaction_in_progress = false
- _close_sale_confirmation()
- _fish_selection.clear()
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+ menu_exit_started.emit()
+ _close_generation += 1
+ var generation: int = _close_generation
+ if reason in [CloseReason.USER, CloseReason.RANGE_EXIT]:
+ _close_tween = create_tween()
+ _close_tween.tween_property(
+ self,
+ "modulate:a",
+ 0.0,
+ UIMotionType.PLAYER_MENU_EXIT_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ _close_tween.finished.connect(
+ _finish_close.bind(reason, restore_controls, generation)
+ )
+ return
+ _finish_close(reason, restore_controls, generation)
+
+
+func _finish_close(
+ reason: CloseReason,
+ restore_controls: bool,
+ generation: int,
+) -> void:
+ if generation != _close_generation or not visible:
+ return
+ _cancel_close_tween()
hide()
- get_viewport().gui_release_focus()
+ modulate.a = 1.0
if _fishing_spot != null and is_instance_valid(_fishing_spot):
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
if (
@@ -214,6 +497,14 @@ func close_shop(
_apply_mouse_close_policy(reason)
_closing = false
menu_visibility_changed.emit(false)
+ deactivate_shop_cooler_page()
+
+
+func _cancel_close_tween() -> void:
+ if _close_tween == null:
+ return
+ _close_tween.kill()
+ _close_tween = null
func close_for_range_exit() -> void:
@@ -241,8 +532,6 @@ func _refresh_all() -> void:
if not is_node_ready():
return
_refresh_wallet()
- _refresh_fish_list()
- _refresh_selected_fish()
_refresh_upgrades()
_refresh_supplies()
_refresh_cooler_capacity()
@@ -250,136 +539,9 @@ func _refresh_all() -> void:
func _refresh_wallet() -> void:
_wallet_label.text = (
- "wallet: $%d" % _wallet.get_balance()
+ str(_wallet.get_balance())
if _wallet != null
- else "wallet: $0"
- )
-
-
-func _refresh_fish_list() -> void:
- _fish_list.clear()
- var catches: Array[FishCatchType] = (
- _inventory.get_all_catches() if _inventory != null else []
- )
- _sales_title.text = "cooler %d / %d • full base value" % [
- catches.size(),
- _cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
- ]
- catches.sort_custom(
- func(a: FishCatchType, b: FishCatchType) -> bool:
- return a.catch_sequence > b.catch_sequence
- )
- var visible_ids: Array[StringName] = []
- for fish_catch: FishCatchType in catches:
- if fish_catch != null and fish_catch.is_valid():
- visible_ids.append(fish_catch.catch_id)
- _fish_selection.set_visible_order(visible_ids)
- for fish_catch: FishCatchType in catches:
- if fish_catch == null or not fish_catch.is_valid():
- continue
- var marker: String = ""
- if _fish_selection.is_selected(fish_catch.catch_id):
- marker += "✓ "
- if fish_catch.catch_id == _fish_selection.get_focused_id():
- marker += "◆ "
- if fish_catch.is_favorited:
- marker += "★ "
- var index: int = _fish_list.add_item(
- "%s%s — %.2f lb"
- % [marker, fish_catch.fish.display_name, fish_catch.weight_lb],
- fish_catch.fish.display_texture
- )
- _fish_list.set_item_metadata(index, String(fish_catch.catch_id))
- _fish_list.set_item_tooltip(
- index,
- "%s • %s"
- % [
- fish_catch.fish.get_rarity_name(),
- "favorited" if fish_catch.is_favorited else "available",
- ]
- )
- _fish_empty.visible = _fish_list.item_count == 0
- _fish_list.deselect_all()
- for index: int in range(_fish_list.item_count):
- var catch_id := StringName(str(_fish_list.get_item_metadata(index)))
- if _fish_selection.is_selected(catch_id):
- _fish_list.select(index, false)
-
-
-func _refresh_selected_fish() -> void:
- var fish_catch: FishCatchType = _get_selected_catch()
- _fish_texture.texture = (
- fish_catch.fish.display_texture if fish_catch != null else null
- )
- _fish_name.text = (
- fish_catch.fish.display_name if fish_catch != null else "select a fish"
- )
- if fish_catch == null:
- _fish_details.text = "choose one individual fish from your cooler."
- _update_sale_summary()
- return
- var individual_preview: FishSaleResultType = (
- _sale_service.preview_batch([fish_catch.catch_id], _buyer)
- if _sale_service != null
- else null
- )
- var offer: int = (
- individual_preview.payout if individual_preview != null else -1
- )
- _fish_details.text = (
- "%.2f lb • %s\nbase value: $%d\nmain-shop offer: $%d%s"
- % [
- fish_catch.weight_lb,
- fish_catch.fish.get_rarity_name(),
- fish_catch.sale_value,
- offer,
- "\nfavorited fish cannot be sold."
- if fish_catch.is_favorited
- else "",
- ]
- )
- _update_sale_summary()
-
-
-func _update_sale_summary() -> void:
- var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
- var selected_count: int = selected_ids.size()
- _sell_button.text = (
- "sell fish"
- if selected_count <= 1
- else "sell %d fish" % selected_count
- )
- if selected_count == 0:
- _selection_summary.text = "no fish selected"
- _sell_button.disabled = true
- return
- var preview: FishSaleResultType = (
- _sale_service.preview_batch(selected_ids, _buyer)
- if _sale_service != null
- else null
- )
- _selection_summary.text = (
- "1 fish selected"
- if selected_count == 1
- else "%d fish selected" % selected_count
- )
- if (
- preview != null
- and (
- preview.is_success()
- or preview.status == FishSaleResultType.Status.FAVORITED
- )
- ):
- _selection_summary.text += "\ntotal offer: $%d" % preview.payout
- if preview != null and preview.status == FishSaleResultType.Status.FAVORITED:
- _selection_summary.text += (
- "\nfavorited fish must be removed from the selection."
- )
- _sell_button.disabled = (
- preview == null
- or not preview.is_success()
- or _transaction_in_progress
- or _closing
+ else "0"
)
@@ -388,48 +550,79 @@ func _refresh_upgrades() -> void:
return
var reel_level: int = _upgrades.get_reel_speed_level()
var reel_cost: int = _upgrades.get_next_reel_speed_cost()
- _reel_level.text = "level %d" % reel_level
_reel_purchase.disabled = (
reel_cost < 0
or _transaction_in_progress
or _closing
+ or _network_shop == null
+ or not _network_shop.can_request_purchase()
or not _upgrades.can_purchase_reel_speed(_wallet)
)
+ var reel_effect: String
if reel_cost < 0:
- _reel_effect.text = "%.2f×" % _upgrades.get_reel_speed_multiplier()
- _reel_cost.text = "max"
+ reel_effect = "%.2f× reel speed · maximum level" % (
+ _upgrades.get_reel_speed_multiplier()
+ )
+ _reel_price_bubble.hide()
else:
- _reel_effect.text = (
+ _reel_price_bubble.show()
+ reel_effect = (
"%.2f× → %.2f×"
% [
_upgrades.get_reel_speed_multiplier(),
1.0 + float(reel_level + 1) * 0.10,
]
)
- _reel_cost.text = "$%d" % reel_cost
- _reel_purchase.text = "max" if reel_cost < 0 else "purchase"
+ _reel_cost.set_amount(reel_cost)
+ _reel_purchase.tooltip_text = _upgrade_tooltip(
+ "reel speed",
+ reel_level,
+ reel_effect,
+ )
+ _reel_purchase.accessibility_name = _reel_purchase.tooltip_text
var barrier_level: int = _upgrades.get_barrier_power_level()
var barrier_cost: int = _upgrades.get_next_barrier_power_cost()
- _barrier_level.text = "level %d" % barrier_level
_barrier_purchase.disabled = (
barrier_cost < 0
or _transaction_in_progress
or _closing
+ or _network_shop == null
+ or not _network_shop.can_request_purchase()
or not _upgrades.can_purchase_barrier_power(_wallet)
)
+ var barrier_effect: String
if barrier_cost < 0:
- _barrier_effect.text = "%d damage" % _upgrades.get_barrier_damage()
- _barrier_cost.text = "max"
+ barrier_effect = "%d damage · maximum level" % (
+ _upgrades.get_barrier_damage()
+ )
+ _barrier_price_bubble.hide()
else:
- _barrier_effect.text = (
+ _barrier_price_bubble.show()
+ barrier_effect = (
"%d damage → %d damage"
% [
_upgrades.get_barrier_damage(),
barrier_level + 2,
]
)
- _barrier_cost.text = "$%d" % barrier_cost
- _barrier_purchase.text = "max" if barrier_cost < 0 else "purchase"
+ _barrier_cost.set_amount(barrier_cost)
+ _barrier_purchase.tooltip_text = _upgrade_tooltip(
+ "rod power",
+ barrier_level,
+ barrier_effect,
+ )
+ _barrier_purchase.accessibility_name = _barrier_purchase.tooltip_text
+
+
+func _upgrade_tooltip(
+ upgrade_name: String,
+ level: int,
+ effect: String,
+) -> String:
+ var tooltip := "%s\nlevel %d\n%s" % [upgrade_name, level, effect]
+ if _network_shop == null or not _network_shop.can_request_purchase():
+ tooltip += "\nPurchases are unavailable in this session."
+ return tooltip
func _refresh_supplies() -> void:
@@ -438,31 +631,397 @@ func _refresh_supplies() -> void:
for child: Node in _supplies_list.get_children():
_supplies_list.remove_child(child)
child.queue_free()
- for item_id: StringName in FishingShopStockType.get_stock_item_ids():
+ if _shop_section in [ShopSection.UPGRADES, ShopSection.SELL_FISH]:
+ return
+ _stock_title.text = SHOP_SECTION_LABELS[int(_shop_section)]
+ var stock_item_ids: Array[StringName] = (
+ FishingShopStockType.get_stock_item_ids()
+ )
+ if _shop_section == ShopSection.BAIT:
+ var grouped_item_ids: Array[StringName] = []
+ for item_id: StringName in stock_item_ids:
+ var bait_item: ItemDataType = _item_catalog.get_item_by_id(item_id)
+ if bait_item != null and bait_item.is_bait():
+ grouped_item_ids.append(item_id)
+ for item_id: StringName in stock_item_ids:
+ var lure_item: ItemDataType = _item_catalog.get_item_by_id(item_id)
+ if lure_item != null and lure_item.is_lure():
+ grouped_item_ids.append(item_id)
+ stock_item_ids = grouped_item_ids
+ var current_stock_group: StringName = StringName()
+ var current_stock_grid: GridContainer
+ if _shop_section in [ShopSection.SNACKS, ShopSection.EQUIPMENT]:
+ current_stock_grid = _add_stock_icon_grid()
+ for item_id: StringName in stock_item_ids:
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
- if item == null:
+ if item == null or not _item_belongs_in_current_section(item):
continue
+ if _shop_section == ShopSection.BAIT:
+ var stock_group: StringName = (
+ &"bait" if item.is_bait() else &"lures"
+ )
+ if stock_group != current_stock_group:
+ _add_stock_section(str(stock_group))
+ current_stock_grid = _add_stock_icon_grid()
+ current_stock_group = stock_group
var button := Button.new()
button.custom_minimum_size = Vector2(195, 54)
- button.icon = item.icon
+ button.icon = item.icon if item.icon != null else FALLBACK_SUPPLY_ICON
button.expand_icon = true
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
- button.text = "%s\n$%d • owned %d" % [
+ var owned: int = _bag.get_quantity(item_id)
+ var bait_topoff: bool = FishingShopStockType.is_bait_topoff(item_id)
+ var bait_unlocked: bool = (
+ not bait_topoff or _bag.is_bait_unlocked(item_id)
+ )
+ var quantity: int = FishingShopStockType.get_purchase_quantity(
+ item_id, owned, item.max_stack
+ )
+ var total_cost: int = FishingShopStockType.get_purchase_cost(
+ item_id, quantity, bait_unlocked
+ )
+ var use_icon_tile: bool = (
+ current_stock_grid != null
+ )
+ button.text = "%s\nowned %d" % [
item.display_name,
- FishingShopStockType.get_price(item_id),
- _bag.get_quantity(item_id),
+ owned,
]
- button.tooltip_text = item.description
+ var item_tooltip_text: String = item.description
+ if bait_topoff:
+ if use_icon_tile:
+ button.custom_minimum_size = SUPPLY_ICON_TILE_SIZE
+ button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ button.text = ""
+ else:
+ button.text = (
+ "%s\nunlock and fill" % item.display_name
+ if not bait_unlocked
+ else "%s\n%d/%d owned" % [
+ item.display_name,
+ owned,
+ item.max_stack,
+ ]
+ )
+ item_tooltip_text = (
+ "%s\nunlock and fill to %d/%d\n%s" % [
+ item.display_name,
+ item.max_stack,
+ item.max_stack,
+ item.description,
+ ]
+ if not bait_unlocked
+ else "%s\n%d/%d owned\n%s" % [
+ item.display_name,
+ owned,
+ item.max_stack,
+ item.description,
+ ]
+ )
+ elif use_icon_tile:
+ button.custom_minimum_size = SUPPLY_ICON_TILE_SIZE
+ button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ button.text = ""
+ item_tooltip_text = "%s\nowned %d\n%s" % [
+ item.display_name,
+ owned,
+ item.description,
+ ]
button.disabled = (
_transaction_in_progress
or _closing
- or not _bag.can_add_item(item_id, 1)
- or not _wallet.can_afford(
- FishingShopStockType.get_price(item_id)
- )
+ or _network_shop == null
+ or not _network_shop.can_request_purchase()
+ or quantity <= 0
+ or total_cost < 0
+ or not _bag.can_add_item(item_id, quantity)
+ or not _wallet.can_afford(total_cost)
)
+ button.tooltip_text = (
+ "Purchases are unavailable in this session."
+ if _network_shop == null or not _network_shop.can_request_purchase()
+ else item_tooltip_text
+ )
+ UtilityPageStyleType.apply_ocean_button(button)
button.pressed.connect(_purchase_supply.bind(item_id))
- _supplies_list.add_child(button)
+ if current_stock_grid != null:
+ _add_supply_icon_tile(
+ current_stock_grid,
+ button,
+ (
+ total_cost
+ if bait_topoff and not bait_unlocked
+ else FishingShopStockType.get_price(item_id)
+ ),
+ )
+ else:
+ _supplies_list.add_child(button)
+ if _shop_section == ShopSection.ART_SUPPLIES:
+ _add_stock_section("art kit")
+ var art_kit_grid := _add_stock_icon_grid()
+ _add_art_kit_button(art_kit_grid)
+ _add_stock_section("markers")
+ var marker_grid := _add_stock_icon_grid()
+ for product_id: StringName in ArtShopStockType.MARKER_PRODUCTS:
+ _add_art_upgrade_button(product_id, marker_grid)
+ _add_stock_section("brushes")
+ var brush_grid := _add_stock_icon_grid()
+ for product_id: StringName in ArtShopStockType.BRUSH_PRODUCTS:
+ _add_art_upgrade_button(product_id, brush_grid)
+ _add_stock_section("grids")
+ var canvas_grid := _add_stock_icon_grid()
+ for product_id: StringName in ArtShopStockType.GRID_PRODUCTS:
+ _add_art_upgrade_button(product_id, canvas_grid)
+
+
+func _item_belongs_in_current_section(item: ItemDataType) -> bool:
+ match _shop_section:
+ ShopSection.BAIT:
+ return item.is_bait() or item.is_lure()
+ ShopSection.SNACKS:
+ return item.category == ItemDataType.Category.CONSUMABLE
+ ShopSection.EQUIPMENT:
+ return item.category == ItemDataType.Category.TOOL
+ return false
+
+
+func _add_stock_section(title: String) -> void:
+ var label := Label.new()
+ label.text = title
+ label.add_theme_font_size_override("font_size", 21)
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
+ )
+ _supplies_list.add_child(label)
+
+
+func _add_stock_icon_grid() -> GridContainer:
+ var grid := GridContainer.new()
+ grid.columns = SUPPLY_ICON_GRID_COLUMNS
+ grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ grid.add_theme_constant_override(
+ "h_separation", SUPPLY_ICON_GRID_SEPARATION
+ )
+ grid.add_theme_constant_override(
+ "v_separation", SUPPLY_ICON_GRID_SEPARATION
+ )
+ _supplies_list.add_child(grid)
+ return grid
+
+
+func _add_art_kit_button(parent: GridContainer) -> void:
+ var item: ItemDataType = _item_catalog.get_item_by_id(
+ ArtShopStockType.ART_KIT_ITEM_ID
+ )
+ if item == null:
+ return
+ var owned: bool = _bag.get_quantity(ArtShopStockType.ART_KIT_ITEM_ID) > 0
+ var button: Button = _make_stock_button(
+ "%s\n%s" % [
+ item.display_name,
+ "owned" if owned else "not owned",
+ ],
+ "%s\n%s\n%s" % [
+ item.display_name,
+ "owned" if owned else "not owned",
+ item.description,
+ ],
+ )
+ _configure_supply_icon_tile(button, item.icon)
+ _add_supply_icon_tile(
+ parent,
+ button,
+ ArtShopStockType.ART_KIT_PRICE,
+ )
+ button.disabled = (
+ owned
+ or _transaction_in_progress
+ or _closing
+ or _network_shop == null
+ or not _network_shop.can_request_art_purchase()
+ or not _bag.can_add_item(ArtShopStockType.ART_KIT_ITEM_ID, 1)
+ or not _wallet.can_afford(ArtShopStockType.ART_KIT_PRICE)
+ )
+ button.pressed.connect(_purchase_art_kit)
+
+
+func _add_art_upgrade_button(
+ product_id: StringName,
+ parent: GridContainer,
+) -> void:
+ var kit_owned: bool = (
+ _bag.get_quantity(ArtShopStockType.ART_KIT_ITEM_ID) > 0
+ )
+ var unlocked: bool = _art_unlocks.owns_product(product_id)
+ var button: Button = _make_stock_button(
+ "%s\n%s" % [
+ ArtShopStockType.get_display_name(product_id),
+ "unlocked" if unlocked else "locked",
+ ],
+ "%s\n%s\n%s" % [
+ ArtShopStockType.get_display_name(product_id),
+ "unlocked" if unlocked else "locked",
+ ArtShopStockType.get_description(product_id),
+ ],
+ )
+ var marker_color_id: StringName = (
+ PlayerArtUnlocksType.color_id_for_product(product_id)
+ )
+ if marker_color_id.is_empty():
+ _configure_supply_icon_tile(button, FALLBACK_SUPPLY_ICON)
+ else:
+ _configure_marker_icon_tile(
+ button,
+ SurfaceDrawingPalette.get_color(marker_color_id),
+ )
+ _add_supply_icon_tile(
+ parent,
+ button,
+ ArtShopStockType.UPGRADE_PRICE,
+ )
+ button.disabled = (
+ not kit_owned
+ or unlocked
+ or _transaction_in_progress
+ or _closing
+ or _network_shop == null
+ or not _network_shop.can_request_art_purchase()
+ or not _wallet.can_afford(ArtShopStockType.UPGRADE_PRICE)
+ )
+ button.pressed.connect(_purchase_art_upgrade.bind(product_id))
+
+
+func _make_stock_button(
+ button_text: String,
+ tooltip: String,
+) -> Button:
+ var button := Button.new()
+ button.custom_minimum_size = Vector2(195, 54)
+ button.alignment = HORIZONTAL_ALIGNMENT_LEFT
+ button.text = button_text
+ button.tooltip_text = tooltip
+ UtilityPageStyleType.apply_ocean_button(button)
+ return button
+
+
+func _configure_supply_icon_tile(
+ button: Button,
+ icon: Texture2D,
+) -> void:
+ button.custom_minimum_size = SUPPLY_ICON_TILE_SIZE
+ button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ button.icon = icon if icon != null else FALLBACK_SUPPLY_ICON
+ button.expand_icon = true
+ button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ button.text = ""
+
+
+func _configure_marker_icon_tile(
+ button: Button,
+ marker_color: Color,
+) -> void:
+ button.custom_minimum_size = SUPPLY_ICON_TILE_SIZE
+ button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ button.icon = null
+ button.text = ""
+ var icon := TextureRect.new()
+ icon.name = "MarkerIcon"
+ icon.position = Vector2(4.0, 4.0)
+ icon.size = Vector2(64.0, 64.0)
+ icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ var marker_material := ShaderMaterial.new()
+ marker_material.shader = ART_KIT_MARKER_SHADER
+ marker_material.set_shader_parameter("marker_color", marker_color)
+ icon.material = marker_material
+ icon.texture = ART_KIT_MARKER_ICON
+ button.add_child(icon)
+
+
+func _add_supply_icon_tile(
+ parent: GridContainer,
+ button: Button,
+ price: int,
+) -> void:
+ var tile_host := Control.new()
+ tile_host.custom_minimum_size = SUPPLY_ICON_HOST_SIZE
+ tile_host.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ parent.add_child(tile_host)
+ button.position = Vector2.ZERO
+ button.size = SUPPLY_ICON_TILE_SIZE
+ tile_host.add_child(button)
+ var price_text := str(price)
+ var price_text_width: float = UtilityPageStyleType.TuffyFont.get_string_size(
+ price_text,
+ HORIZONTAL_ALIGNMENT_LEFT,
+ -1.0,
+ SUPPLY_PRICE_FONT_SIZE,
+ ).x
+ var price_width: float = minf(
+ SUPPLY_ICON_TILE_SIZE.x,
+ ceilf(
+ price_text_width
+ + SUPPLY_PRICE_ICON_SIZE
+ + SUPPLY_PRICE_ICON_GAP
+ + SUPPLY_PRICE_HORIZONTAL_PADDING
+ ),
+ )
+ var price_bubble := PanelContainer.new()
+ price_bubble.name = "PriceBubble"
+ price_bubble.position = Vector2(
+ (SUPPLY_ICON_TILE_SIZE.x - price_width) * 0.5,
+ SUPPLY_PRICE_Y,
+ )
+ price_bubble.size = Vector2(price_width, SUPPLY_PRICE_HEIGHT)
+ price_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ price_bubble.z_index = 2
+ var price_style := UtilityPageStyleType.rounded_style(
+ UtilityPageStyleType.OCEAN_FIELD,
+ 12,
+ )
+ price_style.content_margin_left = 6.0
+ price_style.content_margin_right = 6.0
+ price_style.content_margin_top = 0.0
+ price_style.content_margin_bottom = 0.0
+ price_bubble.add_theme_stylebox_override("panel", price_style)
+ tile_host.add_child(price_bubble)
+ var price_row := HBoxContainer.new()
+ price_row.name = "CurrencyAmount"
+ price_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ price_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ price_row.add_theme_constant_override(
+ "separation", int(SUPPLY_PRICE_ICON_GAP)
+ )
+ price_bubble.add_child(price_row)
+ var price_icon := TextureRect.new()
+ price_icon.name = "CurrencyIcon"
+ price_icon.custom_minimum_size = Vector2.ONE * SUPPLY_PRICE_ICON_SIZE
+ price_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ price_icon.texture = CURRENCY_ICON
+ price_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ price_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ price_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ price_row.add_child(price_icon)
+ var price_label := Label.new()
+ price_label.name = "Price"
+ price_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ price_label.text = price_text
+ price_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ price_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ price_label.add_theme_font_override(
+ "font", UtilityPageStyleType.TuffyFont
+ )
+ price_label.add_theme_font_size_override(
+ "font_size", SUPPLY_PRICE_FONT_SIZE
+ )
+ price_label.add_theme_color_override(
+ "font_color", SUPPLY_PRICE_COLOR
+ )
+ price_label.add_theme_constant_override("outline_size", 0)
+ price_row.add_child(price_label)
func _refresh_cooler_capacity() -> void:
@@ -471,151 +1030,33 @@ func _refresh_cooler_capacity() -> void:
var level: int = _cooler_capacity.get_level()
var cost: int = _cooler_capacity.get_next_cost()
var next_capacity: int = _cooler_capacity.get_next_capacity()
- _cooler_level.text = "level %d" % level
_cooler_purchase.disabled = (
cost < 0
or _transaction_in_progress
or _closing
+ or _network_shop == null
+ or not _network_shop.can_request_purchase()
or not _cooler_capacity.can_purchase(_wallet)
)
+ var cooler_effect: String
if cost < 0:
- _cooler_effect.text = "%d fish" % _cooler_capacity.get_capacity()
- _cooler_cost.text = "max"
- _cooler_purchase.text = "max"
+ cooler_effect = "%d fish · maximum level" % (
+ _cooler_capacity.get_capacity()
+ )
+ _cooler_price_bubble.hide()
else:
- _cooler_effect.text = "%d → %d fish" % [
+ _cooler_price_bubble.show()
+ cooler_effect = "%d → %d fish" % [
_cooler_capacity.get_capacity(),
next_capacity,
]
- _cooler_cost.text = "$%d" % cost
- _cooler_purchase.text = "purchase"
-
-
-func _on_fish_selected(index: int) -> void:
- if index < 0 or index >= _fish_list.item_count:
- return
- var input_generation: int = _selection_input_generation
- call_deferred(
- "_apply_keyboard_fish_selection",
- index,
- input_generation
+ _cooler_cost.set_amount(cost)
+ _cooler_purchase.tooltip_text = _upgrade_tooltip(
+ "cooler capacity",
+ level,
+ cooler_effect,
)
-
-
-func _apply_keyboard_fish_selection(
- index: int,
- input_generation: int,
-) -> void:
- if (
- input_generation != _selection_input_generation
- or index < 0
- or index >= _fish_list.item_count
- ):
- return
- var catch_id := StringName(str(_fish_list.get_item_metadata(index)))
- _fish_selection.select_only(catch_id)
- _feedback.text = ""
- _refresh_fish_list()
- _refresh_selected_fish()
-
-
-func _on_fish_clicked(
- index: int,
- _position: Vector2,
- mouse_button_index: int,
-) -> void:
- if (
- mouse_button_index != MOUSE_BUTTON_LEFT
- or index < 0
- or index >= _fish_list.item_count
- ):
- return
- _selection_input_generation += 1
- var catch_id := StringName(str(_fish_list.get_item_metadata(index)))
- _fish_selection.apply_click(
- catch_id,
- Input.is_key_pressed(KEY_CTRL),
- Input.is_key_pressed(KEY_SHIFT)
- )
- _feedback.text = ""
- _refresh_fish_list()
- _refresh_selected_fish()
-
-
-func _open_sale_confirmation() -> void:
- var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
- if not _is_transaction_context_valid() or selected_ids.is_empty():
- _feedback.text = "the fish selection is no longer available."
- _refresh_all()
- return
- var preview: FishSaleResultType = _sale_service.preview_batch(
- selected_ids,
- _buyer
- )
- if not preview.is_success():
- _feedback.text = (
- "favorited fish cannot be sold. "
- + "remove them from the selection first."
- if preview.status == FishSaleResultType.Status.FAVORITED
- else preview.get_message()
- )
- return
- _confirmation_catch_ids = selected_ids.duplicate()
- _confirmation_generation = _generation
- _confirmation_text.text = (
- "sell %d fish to the fishing shop for $%d?"
- % [preview.fish_count, preview.payout]
- )
- _confirmation.show()
- _cancel_sale.grab_focus()
-
-
-func _close_sale_confirmation() -> void:
- _confirmation_catch_ids.clear()
- _confirmation_generation = -1
- _confirmation.hide()
-
-
-func _on_confirm_sale() -> void:
- if _transaction_in_progress:
- return
- var transaction_generation: int = _generation
- var catch_ids: Array[StringName] = _confirmation_catch_ids.duplicate()
- var confirmation_generation: int = _confirmation_generation
- _close_sale_confirmation()
- if (
- catch_ids.is_empty()
- or not _is_transaction_context_valid()
- or transaction_generation != _generation
- or confirmation_generation != transaction_generation
- or _buyer.id != MAIN_SHOP_BUYER_ID
- ):
- _feedback.text = "unable to complete sale."
- return
- _transaction_in_progress = true
- var result: FishSaleResultType = _sale_service.sell_batch(
- catch_ids,
- _buyer
- )
- _transaction_in_progress = false
- if (
- transaction_generation != _generation
- or not visible
- ):
- return
- if result.is_success():
- _feedback.text = (
- "fish sold for $%d."
- if result.fish_count == 1
- else "%d fish sold for $%d." % [
- result.fish_count,
- result.payout,
- ]
- )
- _fish_selection.remove_ids(catch_ids)
- else:
- _feedback.text = result.get_message()
- _refresh_all()
+ _cooler_purchase.accessibility_name = _cooler_purchase.tooltip_text
func _purchase_reel_speed() -> void:
@@ -628,60 +1069,65 @@ func _purchase_barrier_power() -> void:
func _purchase_supply(item_id: StringName) -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
- _feedback.text = "unable to complete purchase."
+ _set_feedback("unable to complete purchase.")
return
- var price: int = FishingShopStockType.get_price(item_id)
- if not _bag.can_add_item(item_id, 1):
- _feedback.text = "bag cannot accept this item."
+ var owned: int = _bag.get_quantity(item_id)
+ var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
+ if item == null:
+ _set_feedback("Purchase could not be completed.")
return
- if not _wallet.can_afford(price):
- _feedback.text = "not enough money."
- return
- var transaction_generation: int = _generation
- _transaction_in_progress = true
- var purchased: bool = FishingShopStockType.purchase_one(
- item_id,
- _wallet,
- _bag,
- _item_catalog
+ var quantity: int = FishingShopStockType.get_purchase_quantity(
+ item_id, owned, item.max_stack
)
- _transaction_in_progress = false
- if transaction_generation != _generation or not visible:
+ if quantity <= 0 or not _bag.can_add_item(item_id, quantity):
+ _set_feedback("Your Bag is full.")
return
- _feedback.text = (
- "item purchased." if purchased else "unable to complete purchase."
+ var total_cost: int = FishingShopStockType.get_purchase_cost(
+ item_id, quantity, _bag.is_bait_unlocked(item_id)
)
- _refresh_all()
+ if total_cost < 0 or not _wallet.can_afford(total_cost):
+ _set_feedback("Insufficient funds.")
+ return
+ if _network_shop == null:
+ _set_feedback("Purchase could not be completed.")
+ return
+ _network_shop.request_supply(item_id)
+
+
+func _purchase_art_kit() -> void:
+ if _network_shop == null or not _is_transaction_context_valid():
+ _set_feedback("Purchase could not be completed.")
+ return
+ _network_shop.request_art_kit()
+
+
+func _purchase_art_upgrade(product_id: StringName) -> void:
+ if _network_shop == null or not _is_transaction_context_valid():
+ _set_feedback("Purchase could not be completed.")
+ return
+ _network_shop.request_art_upgrade(product_id)
func _purchase_cooler_capacity() -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
- _feedback.text = "unable to complete purchase."
+ _set_feedback("unable to complete purchase.")
return
var cost: int = _cooler_capacity.get_next_cost()
if cost < 0:
- _feedback.text = "maximum level reached."
+ _set_feedback("Upgrade is already at maximum.")
return
if not _wallet.can_afford(cost):
- _feedback.text = "not enough money."
+ _set_feedback("Insufficient funds.")
return
- var transaction_generation: int = _generation
- _transaction_in_progress = true
- var purchased: bool = _cooler_capacity.purchase(_wallet)
- _transaction_in_progress = false
- if transaction_generation != _generation or not visible:
+ if _network_shop == null:
+ _set_feedback("Purchase could not be completed.")
return
- _feedback.text = (
- "upgrade purchased."
- if purchased
- else "unable to complete purchase."
- )
- _refresh_all()
+ _network_shop.request_cooler_capacity_upgrade()
func _purchase_upgrade(is_reel_speed: bool) -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
- _feedback.text = "unable to complete purchase."
+ _set_feedback("unable to complete purchase.")
return
var cost: int = (
_upgrades.get_next_reel_speed_cost()
@@ -689,34 +1135,52 @@ func _purchase_upgrade(is_reel_speed: bool) -> void:
else _upgrades.get_next_barrier_power_cost()
)
if cost < 0:
- _feedback.text = "maximum level reached."
+ _set_feedback("Upgrade is already at maximum.")
return
if not _wallet.can_afford(cost):
- _feedback.text = "not enough money."
+ _set_feedback("Insufficient funds.")
return
- var transaction_generation: int = _generation
+ if _network_shop == null:
+ _set_feedback("Purchase could not be completed.")
+ return
+ if is_reel_speed:
+ _network_shop.request_reel_speed_upgrade()
+ else:
+ _network_shop.request_barrier_power_upgrade()
+
+
+func _on_network_purchase_pending(_request_id: String) -> void:
_transaction_in_progress = true
- var purchased: bool = (
- _upgrades.purchase_reel_speed(_wallet)
- if is_reel_speed
- else _upgrades.purchase_barrier_power(_wallet)
- )
+ if visible:
+ _set_feedback("Purchasing…")
+ _refresh_all()
+
+
+func _on_network_purchase_finished(
+ _request_id: String,
+ accepted: bool,
+ message: String,
+ _product_id: StringName,
+ _category: int,
+ _quantity: int,
+ total_cost: int,
+) -> void:
_transaction_in_progress = false
- if transaction_generation != _generation or not visible:
+ if not visible:
return
- _feedback.text = (
- "upgrade purchased."
- if purchased
- else "unable to complete purchase."
+ _set_feedback(
+ (
+ "Purchase complete • %s spent."
+ % CurrencyPresentation.bbcode_amount(total_cost, 18)
+ )
+ if accepted
+ else message
)
_refresh_all()
-func _get_selected_catch() -> FishCatchType:
- var focused_id: StringName = _fish_selection.get_focused_id()
- if _inventory == null or focused_id.is_empty():
- return null
- return _inventory.get_catch_by_id(focused_id)
+func _set_feedback(message: String) -> void:
+ _feedback.text = "[center]%s[/center]" % message
func _is_transaction_context_valid() -> bool:
@@ -733,27 +1197,6 @@ func _is_transaction_context_valid() -> bool:
)
-func _on_inventory_changed() -> void:
- _refresh_fish_list()
- if not _confirmation_catch_ids.is_empty():
- var preview: FishSaleResultType = _sale_service.preview_batch(
- _confirmation_catch_ids,
- _buyer
- )
- if (
- _confirmation_generation != _generation
- or not preview.is_success()
- ):
- _close_sale_confirmation()
- _feedback.text = (
- "favorited fish cannot be sold. "
- + "remove them from the selection first."
- if preview.status == FishSaleResultType.Status.FAVORITED
- else preview.get_message()
- )
- _refresh_selected_fish()
-
-
func _on_wallet_changed(_balance: int, _delta: int) -> void:
_refresh_wallet()
_refresh_upgrades()
@@ -774,7 +1217,10 @@ func _on_bag_changed() -> void:
func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void:
_refresh_cooler_capacity()
- _refresh_fish_list()
+
+
+func _on_art_unlocks_changed(_unlock_mask: int) -> void:
+ _refresh_supplies()
func _apply_mouse_close_policy(reason: CloseReason) -> void:
diff --git a/ui/fishing_shop.tscn b/ui/fishing_shop.tscn
index 11b062b..5d0b9ec 100644
--- a/ui/fishing_shop.tscn
+++ b/ui/fishing_shop.tscn
@@ -1,11 +1,13 @@
-[gd_scene load_steps=7 format=3]
+[gd_scene load_steps=9 format=3]
[ext_resource type="Script" path="res://ui/fishing_shop.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/reel_speed_upgrade.svg" id="3_reel"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/barrier_power_upgrade.svg" id="4_barrier"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/fish_coin.svg" id="5_coin"]
-[ext_resource type="Texture2D" path="res://items/icons/placeholder/cooler_expansion.svg" id="6_cooler"]
+[ext_resource type="Texture2D" path="res://items/icons/shop/64_speed_plus.png" id="3_reel"]
+[ext_resource type="Texture2D" path="res://items/icons/shop/64_power_plus.png" id="4_barrier"]
+[ext_resource type="Texture2D" path="res://items/icons/shop/32_currency.png" id="5_coin"]
+[ext_resource type="Texture2D" path="res://items/icons/equipment/64_cooler_plus.png" id="6_cooler"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_light.png" id="7_close"]
+[ext_resource type="PackedScene" path="res://ui/components/currency_amount.tscn" id="8_currency"]
[node name="FishingShop" type="Control"]
unique_name_in_owner = true
@@ -19,9 +21,10 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme = ExtResource("2_theme")
+theme_override_font_sizes/font_size = 20
script = ExtResource("1_script")
-[node name="Dimmer" type="ColorRect" parent="."]
+[node name="InputBlocker" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -29,28 +32,51 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
-color = Color(0.015, 0.02, 0.03, 0.64)
+
+[node name="ShopCoolerPage" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+z_index = 102
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
+
+[node name="ShopCoolerMount" type="Control" parent="ShopCoolerPage"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
[node name="ShopPanel" type="PanelContainer" parent="."]
+z_index = 101
+unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
-offset_left = -480.0
-offset_top = -310.0
-offset_right = 480.0
-offset_bottom = 310.0
+offset_left = -490.0
+offset_top = -300.0
+offset_right = 490.0
+offset_bottom = 300.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="ShopPanel"]
layout_mode = 2
theme_override_constants/margin_left = 16
-theme_override_constants/margin_top = 14
+theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
-theme_override_constants/margin_bottom = 14
+theme_override_constants/margin_bottom = 12
[node name="Layout" type="VBoxContainer" parent="ShopPanel/Margin"]
layout_mode = 2
@@ -64,157 +90,93 @@ theme_override_constants/separation = 8
layout_mode = 2
size_flags_horizontal = 3
text = "fishing shop"
-theme_override_font_sizes/font_size = 24
+theme_override_font_sizes/font_size = 32
theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
-[node name="CoinIcon" type="TextureRect" parent="ShopPanel/Margin/Layout/Header"]
-custom_minimum_size = Vector2(24, 24)
+[node name="BalanceDisplay" type="HBoxContainer" parent="ShopPanel/Margin/Layout/Header"]
+custom_minimum_size = Vector2(0, 48)
layout_mode = 2
+size_flags_vertical = 4
+theme_override_constants/separation = 6
+alignment = 1
+
+[node name="CoinIcon" type="TextureRect" parent="ShopPanel/Margin/Layout/Header/BalanceDisplay"]
+custom_minimum_size = Vector2(26, 26)
+layout_mode = 2
+size_flags_vertical = 4
texture = ExtResource("5_coin")
expand_mode = 1
stretch_mode = 5
texture_filter = 1
-[node name="WalletLabel" type="Label" parent="ShopPanel/Margin/Layout/Header"]
+[node name="WalletLabel" type="Label" parent="ShopPanel/Margin/Layout/Header/BalanceDisplay"]
unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 32)
layout_mode = 2
-text = "wallet: $0"
+size_flags_vertical = 4
+text = "0"
+vertical_alignment = 1
[node name="CloseButton" type="Button" parent="ShopPanel/Margin/Layout/Header"]
unique_name_in_owner = true
+custom_minimum_size = Vector2(48, 48)
layout_mode = 2
-text = "close"
-custom_minimum_size = Vector2(72, 30)
+tooltip_text = "Close shop"
+flat = true
+icon = ExtResource("7_close")
+expand_icon = true
+icon_max_width = 40
-[node name="Feedback" type="Label" parent="ShopPanel/Margin/Layout"]
+[node name="Feedback" type="RichTextLabel" parent="ShopPanel/Margin/Layout"]
unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 28)
layout_mode = 2
text = ""
-horizontal_alignment = 1
-theme_override_colors/font_color = Color(1, 0.82, 0.4, 1)
+bbcode_enabled = true
+fit_content = true
+scroll_active = false
+theme_override_colors/default_color = Color(1, 0.82, 0.4, 1)
+theme_override_font_sizes/normal_font_size = 20
+
+[node name="ShopTabBar" type="HBoxContainer" parent="."]
+unique_name_in_owner = true
+z_index = 100
+offset_left = 180.0
+offset_top = 26.0
+offset_right = 1100.0
+offset_bottom = 70.0
+alignment = 1
+theme_override_constants/separation = 6
[node name="Body" type="HBoxContainer" parent="ShopPanel/Margin/Layout"]
layout_mode = 2
size_flags_vertical = 3
-theme_override_constants/separation = 12
-
-[node name="FishSales" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body"]
-custom_minimum_size = Vector2(420, 0)
-layout_mode = 2
-size_flags_horizontal = 3
-theme_override_constants/separation = 6
-
-[node name="SalesTitle" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "sell individual cooler fish • full base value"
-theme_override_font_sizes/font_size = 17
-
-[node name="SalesBody" type="HSplitContainer" parent="ShopPanel/Margin/Layout/Body/FishSales"]
-layout_mode = 2
-size_flags_vertical = 3
-split_offset = 220
-theme_override_constants/separation = 8
-
-[node name="ListPanel" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody"]
-custom_minimum_size = Vector2(205, 0)
-layout_mode = 2
-
-[node name="ListMargin" type="MarginContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/ListPanel"]
-layout_mode = 2
-theme_override_constants/margin_left = 7
-theme_override_constants/margin_top = 7
-theme_override_constants/margin_right = 7
-theme_override_constants/margin_bottom = 7
-
-[node name="ListStack" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/ListPanel/ListMargin"]
-layout_mode = 2
-
-[node name="FishEmpty" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/ListPanel/ListMargin/ListStack"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "your cooler is empty."
-horizontal_alignment = 1
-
-[node name="FishList" type="ItemList" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/ListPanel/ListMargin/ListStack"]
-unique_name_in_owner = true
-layout_mode = 2
-size_flags_vertical = 3
-fixed_icon_size = Vector2i(48, 36)
-icon_mode = 1
-select_mode = 1
-same_column_width = true
-
-[node name="FishDetail" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody"]
-custom_minimum_size = Vector2(185, 0)
-layout_mode = 2
-
-[node name="DetailMargin" type="MarginContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail"]
-layout_mode = 2
-theme_override_constants/margin_left = 9
-theme_override_constants/margin_top = 9
-theme_override_constants/margin_right = 9
-theme_override_constants/margin_bottom = 9
-
-[node name="DetailStack" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin"]
-layout_mode = 2
-theme_override_constants/separation = 6
-
-[node name="FishTexture" type="TextureRect" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(160, 90)
-layout_mode = 2
-expand_mode = 1
-stretch_mode = 5
-texture_filter = 1
-
-[node name="FishName" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "select a fish"
-horizontal_alignment = 1
-theme_override_font_sizes/font_size = 19
-
-[node name="FishDetails" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "choose one individual fish from your cooler."
-horizontal_alignment = 1
-autowrap_mode = 2
-
-[node name="SelectionSummary" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "no fish selected"
-horizontal_alignment = 1
-autowrap_mode = 2
-theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
-
-[node name="SellButton" type="Button" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"]
-unique_name_in_owner = true
-layout_mode = 2
-disabled = true
-text = "sell selected"
+theme_override_constants/separation = 0
[node name="Upgrades" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(270, 0)
layout_mode = 2
+size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="UpgradeTitle" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades"]
layout_mode = 2
text = "fishing upgrades"
-theme_override_font_sizes/font_size = 17
+theme_override_font_sizes/font_size = 23
[node name="Supplies" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(210, 0)
layout_mode = 2
+size_flags_horizontal = 3
theme_override_constants/separation = 6
-[node name="Title" type="Label" parent="ShopPanel/Margin/Layout/Body/Supplies"]
+[node name="StockTitle" type="Label" parent="ShopPanel/Margin/Layout/Body/Supplies"]
+unique_name_in_owner = true
layout_mode = 2
-text = "supplies"
-theme_override_font_sizes/font_size = 17
+text = "shop stock"
+theme_override_font_sizes/font_size = 23
[node name="Scroll" type="ScrollContainer" parent="ShopPanel/Margin/Layout/Body/Supplies"]
layout_mode = 2
@@ -227,205 +189,102 @@ layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 5
-[node name="ReelCard" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades"]
+[node name="UpgradeScroll" type="ScrollContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades"]
layout_mode = 2
+size_flags_vertical = 3
+horizontal_scroll_mode = 0
-[node name="Margin" type="MarginContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard"]
-layout_mode = 2
-theme_override_constants/margin_left = 9
-theme_override_constants/margin_top = 9
-theme_override_constants/margin_right = 9
-theme_override_constants/margin_bottom = 9
-
-[node name="Row" type="HBoxContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 9
-
-[node name="Icon" type="TextureRect" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row"]
-custom_minimum_size = Vector2(48, 48)
-layout_mode = 2
-texture = ExtResource("3_reel")
-expand_mode = 1
-stretch_mode = 5
-texture_filter = 1
-
-[node name="Data" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row"]
+[node name="UpgradeGrid" type="GridContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll"]
+unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
+theme_override_constants/h_separation = 20
+theme_override_constants/v_separation = 20
+columns = 3
-[node name="Name" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"]
+[node name="ReelTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"]
+custom_minimum_size = Vector2(144, 168)
layout_mode = 2
-text = "reel speed"
-theme_override_font_sizes/font_size = 17
+mouse_filter = 2
-[node name="ReelLevel" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"]
+[node name="ReelPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/ReelTile"]
+unique_name_in_owner = true
+offset_right = 144.0
+offset_bottom = 144.0
+tooltip_text = "reel speed"
+icon = ExtResource("3_reel")
+expand_icon = true
+icon_max_width = 128
+
+[node name="ReelPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/ReelTile"]
+unique_name_in_owner = true
+z_index = 2
+offset_left = 22.0
+offset_top = 128.0
+offset_right = 122.0
+offset_bottom = 160.0
+mouse_filter = 2
+
+[node name="ReelCost" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/ReelTile/ReelPriceBubble" instance=ExtResource("8_currency")]
unique_name_in_owner = true
layout_mode = 2
-text = "level 0"
+amount = 50
+icon_size = 24.0
-[node name="ReelEffect" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"]
+[node name="BarrierTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"]
+custom_minimum_size = Vector2(144, 168)
+layout_mode = 2
+mouse_filter = 2
+
+[node name="BarrierPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BarrierTile"]
+unique_name_in_owner = true
+offset_right = 144.0
+offset_bottom = 144.0
+tooltip_text = "rod power"
+icon = ExtResource("4_barrier")
+expand_icon = true
+icon_max_width = 128
+
+[node name="BarrierPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BarrierTile"]
+unique_name_in_owner = true
+z_index = 2
+offset_left = 22.0
+offset_top = 128.0
+offset_right = 122.0
+offset_bottom = 160.0
+mouse_filter = 2
+
+[node name="BarrierCost" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BarrierTile/BarrierPriceBubble" instance=ExtResource("8_currency")]
unique_name_in_owner = true
layout_mode = 2
-text = "1.00× → 1.10×"
+amount = 100
+icon_size = 24.0
-[node name="ReelCost" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"]
+[node name="CoolerTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"]
+custom_minimum_size = Vector2(144, 168)
+layout_mode = 2
+mouse_filter = 2
+
+[node name="CoolerPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile"]
+unique_name_in_owner = true
+offset_right = 144.0
+offset_bottom = 144.0
+tooltip_text = "cooler capacity"
+icon = ExtResource("6_cooler")
+expand_icon = true
+icon_max_width = 128
+
+[node name="CoolerPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile"]
+unique_name_in_owner = true
+z_index = 2
+offset_left = 22.0
+offset_top = 128.0
+offset_right = 122.0
+offset_bottom = 160.0
+mouse_filter = 2
+
+[node name="CoolerCost" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile/CoolerPriceBubble" instance=ExtResource("8_currency")]
unique_name_in_owner = true
layout_mode = 2
-text = "$50"
-
-[node name="ReelPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "purchase"
-
-[node name="BarrierCard" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades"]
-layout_mode = 2
-
-[node name="Margin" type="MarginContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard"]
-layout_mode = 2
-theme_override_constants/margin_left = 9
-theme_override_constants/margin_top = 9
-theme_override_constants/margin_right = 9
-theme_override_constants/margin_bottom = 9
-
-[node name="Row" type="HBoxContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 9
-
-[node name="Icon" type="TextureRect" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row"]
-custom_minimum_size = Vector2(48, 48)
-layout_mode = 2
-texture = ExtResource("4_barrier")
-expand_mode = 1
-stretch_mode = 5
-texture_filter = 1
-
-[node name="Data" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row"]
-layout_mode = 2
-size_flags_horizontal = 3
-
-[node name="Name" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"]
-layout_mode = 2
-text = "barrier power"
-theme_override_font_sizes/font_size = 17
-
-[node name="BarrierLevel" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "level 0"
-
-[node name="BarrierEffect" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "1 damage → 2 damage"
-
-[node name="BarrierCost" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "$100"
-
-[node name="BarrierPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "purchase"
-
-[node name="CoolerCard" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades"]
-layout_mode = 2
-
-[node name="Margin" type="MarginContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard"]
-layout_mode = 2
-theme_override_constants/margin_left = 9
-theme_override_constants/margin_top = 9
-theme_override_constants/margin_right = 9
-theme_override_constants/margin_bottom = 9
-
-[node name="Row" type="HBoxContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 9
-
-[node name="Icon" type="TextureRect" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row"]
-custom_minimum_size = Vector2(48, 48)
-layout_mode = 2
-texture = ExtResource("6_cooler")
-expand_mode = 1
-stretch_mode = 5
-texture_filter = 1
-
-[node name="Data" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row"]
-layout_mode = 2
-size_flags_horizontal = 3
-
-[node name="Name" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"]
-layout_mode = 2
-text = "cooler capacity"
-theme_override_font_sizes/font_size = 17
-
-[node name="CoolerLevel" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "level 0"
-
-[node name="CoolerEffect" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "12 → 18 fish"
-
-[node name="CoolerCost" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "$75"
-
-[node name="CoolerPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "purchase"
-
-[node name="SaleConfirmation" type="PanelContainer" parent="."]
-unique_name_in_owner = true
-visible = false
-z_index = 10
-layout_mode = 1
-anchors_preset = 8
-anchor_left = 0.5
-anchor_top = 0.5
-anchor_right = 0.5
-anchor_bottom = 0.5
-offset_left = -220.0
-offset_top = -90.0
-offset_right = 220.0
-offset_bottom = 90.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="Margin" type="MarginContainer" parent="SaleConfirmation"]
-layout_mode = 2
-theme_override_constants/margin_left = 18
-theme_override_constants/margin_top = 16
-theme_override_constants/margin_right = 18
-theme_override_constants/margin_bottom = 16
-
-[node name="Stack" type="VBoxContainer" parent="SaleConfirmation/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 12
-
-[node name="ConfirmationText" type="Label" parent="SaleConfirmation/Margin/Stack"]
-unique_name_in_owner = true
-layout_mode = 2
-autowrap_mode = 2
-horizontal_alignment = 1
-
-[node name="Buttons" type="HBoxContainer" parent="SaleConfirmation/Margin/Stack"]
-layout_mode = 2
-alignment = 1
-theme_override_constants/separation = 8
-
-[node name="ConfirmSale" type="Button" parent="SaleConfirmation/Margin/Stack/Buttons"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "sell fish"
-theme_type_variation = &"DangerButton"
-
-[node name="CancelSale" type="Button" parent="SaleConfirmation/Margin/Stack/Buttons"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "cancel"
+amount = 75
+icon_size = 24.0
diff --git a/ui/fonts/Seattle-Avenue-LICENSE.txt b/ui/fonts/Seattle-Avenue-LICENSE.txt
new file mode 100644
index 0000000..22cdbca
--- /dev/null
+++ b/ui/fonts/Seattle-Avenue-LICENSE.txt
@@ -0,0 +1,23 @@
+Seattle Avenue Regular
+Creator: JLH Fonts
+Status: Public domain — dedicated by the author/creator
+
+Source record retrieved 2026-08-12:
+https://www.publicdomainfiles.com/show_file.php?id=13486266913972
+
+The source page states that the file was dedicated to the public domain by the
+author/creator and may be freely reproduced, distributed, transmitted, used,
+modified, built upon, or otherwise exploited for commercial or noncommercial
+purposes.
+
+Additional longstanding attribution and public-domain listing:
+https://www.dafont.com/seattle-avenue.font
+
+Repository font file:
+ui/fonts/seattle_avenue.otf
+
+Repository font SHA-256:
+ee288a4a3c67a5339141bc08b14e7da84b7657d73ec1655c03869285c8af6324
+
+This record documents the published public-domain dedication and creator
+attribution. It does not impose additional restrictions on the font.
diff --git a/ui/fonts/Tuffy-LICENSE.txt b/ui/fonts/Tuffy-LICENSE.txt
new file mode 100644
index 0000000..defced0
--- /dev/null
+++ b/ui/fonts/Tuffy-LICENSE.txt
@@ -0,0 +1,11 @@
+We, the copyright holders of this work, hereby release it into the
+public domain. This applies worldwide.
+
+In case this is not legally possible,
+
+We grant any entity the right to use this work for any purpose, without
+any conditions, unless such conditions are required by law.
+
+Thatcher Ulrich http://tulrich.com
+Karoly Barta bartakarcsi@gmail.com
+Michael Evans http://www.evertype.com
diff --git a/ui/fonts/Tuffy_Bold.otf b/ui/fonts/Tuffy_Bold.otf
new file mode 100644
index 0000000..805ef2e
Binary files /dev/null and b/ui/fonts/Tuffy_Bold.otf differ
diff --git a/ui/fonts/Tuffy_Bold.otf.import b/ui/fonts/Tuffy_Bold.otf.import
new file mode 100644
index 0000000..04a768a
--- /dev/null
+++ b/ui/fonts/Tuffy_Bold.otf.import
@@ -0,0 +1,36 @@
+[remap]
+
+importer="font_data_dynamic"
+type="FontFile"
+uid="uid://xhdms1j01k2q"
+path="res://.godot/imported/Tuffy_Bold.otf-1ed903e9af3906035b0cf09ea141ce25.fontdata"
+
+[deps]
+
+source_file="res://ui/fonts/Tuffy_Bold.otf"
+dest_files=["res://.godot/imported/Tuffy_Bold.otf-1ed903e9af3906035b0cf09ea141ce25.fontdata"]
+
+[params]
+
+Rendering=null
+antialiasing=1
+generate_mipmaps=false
+disable_embedded_bitmaps=true
+multichannel_signed_distance_field=false
+msdf_pixel_range=8
+msdf_size=48
+allow_system_fallback=true
+force_autohinter=false
+modulate_color_glyphs=false
+hinting=3
+subpixel_positioning=4
+keep_rounding_remainders=true
+oversampling=0.0
+Fallbacks=null
+fallbacks=[]
+Compress=null
+compress=true
+preload=[]
+language_support={}
+script_support={}
+opentype_features={}
diff --git a/ui/game_theme.tres b/ui/game_theme.tres
index 1021bc8..e21f90b 100644
--- a/ui/game_theme.tres
+++ b/ui/game_theme.tres
@@ -1,25 +1,13 @@
-[gd_resource type="Theme" load_steps=13 format=3]
+[gd_resource type="Theme" format=3 uid="uid://r35pr3k1nd34"]
-[ext_resource type="FontFile" uid="uid://ddbejmxpa3kw6" path="res://ui/fonts/seattle_avenue.otf" id="1_font"]
+[ext_resource type="FontFile" uid="uid://xhdms1j01k2q" path="res://ui/fonts/Tuffy_Bold.otf" id="1_font"]
-[sub_resource type="StyleBoxFlat" id="Panel"]
-bg_color = Color(0.075, 0.145, 0.17, 0.96)
-border_width_left = 2
-border_width_top = 2
-border_width_right = 2
-border_width_bottom = 2
-border_color = Color(0.2, 0.55, 0.59, 0.65)
-corner_radius_top_left = 10
-corner_radius_top_right = 10
-corner_radius_bottom_right = 10
-corner_radius_bottom_left = 10
-
-[sub_resource type="StyleBoxFlat" id="ButtonNormal"]
+[sub_resource type="StyleBoxFlat" id="ButtonDisabled"]
content_margin_left = 12.0
content_margin_top = 7.0
content_margin_right = 12.0
content_margin_bottom = 7.0
-bg_color = Color(0.13, 0.27, 0.31, 1)
+bg_color = Color(0.12, 0.18, 0.2, 0.8)
corner_radius_top_left = 7
corner_radius_top_right = 7
corner_radius_bottom_right = 7
@@ -36,6 +24,17 @@ corner_radius_top_right = 7
corner_radius_bottom_right = 7
corner_radius_bottom_left = 7
+[sub_resource type="StyleBoxFlat" id="ButtonNormal"]
+content_margin_left = 12.0
+content_margin_top = 7.0
+content_margin_right = 12.0
+content_margin_bottom = 7.0
+bg_color = Color(0.13, 0.27, 0.31, 1)
+corner_radius_top_left = 7
+corner_radius_top_right = 7
+corner_radius_bottom_right = 7
+corner_radius_bottom_left = 7
+
[sub_resource type="StyleBoxFlat" id="ButtonPressed"]
content_margin_left = 12.0
content_margin_top = 7.0
@@ -47,55 +46,6 @@ corner_radius_top_right = 7
corner_radius_bottom_right = 7
corner_radius_bottom_left = 7
-[sub_resource type="StyleBoxFlat" id="ButtonDisabled"]
-content_margin_left = 12.0
-content_margin_top = 7.0
-content_margin_right = 12.0
-content_margin_bottom = 7.0
-bg_color = Color(0.12, 0.18, 0.2, 0.8)
-corner_radius_top_left = 7
-corner_radius_top_right = 7
-corner_radius_bottom_right = 7
-corner_radius_bottom_left = 7
-
-[sub_resource type="StyleBoxFlat" id="DangerNormal"]
-content_margin_left = 12.0
-content_margin_top = 7.0
-content_margin_right = 12.0
-content_margin_bottom = 7.0
-bg_color = Color(0.55, 0.17, 0.19, 1)
-corner_radius_top_left = 7
-corner_radius_top_right = 7
-corner_radius_bottom_right = 7
-corner_radius_bottom_left = 7
-
-[sub_resource type="StyleBoxFlat" id="DangerHover"]
-content_margin_left = 12.0
-content_margin_top = 7.0
-content_margin_right = 12.0
-content_margin_bottom = 7.0
-bg_color = Color(0.82, 0.26, 0.29, 1)
-corner_radius_top_left = 7
-corner_radius_top_right = 7
-corner_radius_bottom_right = 7
-corner_radius_bottom_left = 7
-
-[sub_resource type="StyleBoxFlat" id="CardNormal"]
-content_margin_left = 7.0
-content_margin_top = 7.0
-content_margin_right = 7.0
-content_margin_bottom = 7.0
-bg_color = Color(0.105, 0.205, 0.235, 1)
-border_width_left = 2
-border_width_top = 2
-border_width_right = 2
-border_width_bottom = 2
-border_color = Color(0.2, 0.36, 0.4, 1)
-corner_radius_top_left = 8
-corner_radius_top_right = 8
-corner_radius_bottom_right = 8
-corner_radius_bottom_left = 8
-
[sub_resource type="StyleBoxFlat" id="CardHover"]
content_margin_left = 7.0
content_margin_top = 7.0
@@ -112,6 +62,22 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
+[sub_resource type="StyleBoxFlat" id="CardNormal"]
+content_margin_left = 7.0
+content_margin_top = 7.0
+content_margin_right = 7.0
+content_margin_bottom = 7.0
+bg_color = Color(0.105, 0.205, 0.235, 1)
+border_width_left = 2
+border_width_top = 2
+border_width_right = 2
+border_width_bottom = 2
+border_color = Color(0.2, 0.36, 0.4, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
[sub_resource type="StyleBoxFlat" id="CardPressed"]
content_margin_left = 7.0
content_margin_top = 7.0
@@ -128,6 +94,28 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
+[sub_resource type="StyleBoxFlat" id="DangerHover"]
+content_margin_left = 12.0
+content_margin_top = 7.0
+content_margin_right = 12.0
+content_margin_bottom = 7.0
+bg_color = Color(0.82, 0.26, 0.29, 1)
+corner_radius_top_left = 7
+corner_radius_top_right = 7
+corner_radius_bottom_right = 7
+corner_radius_bottom_left = 7
+
+[sub_resource type="StyleBoxFlat" id="DangerNormal"]
+content_margin_left = 12.0
+content_margin_top = 7.0
+content_margin_right = 12.0
+content_margin_bottom = 7.0
+bg_color = Color(0.55, 0.17, 0.19, 1)
+corner_radius_top_left = 7
+corner_radius_top_right = 7
+corner_radius_bottom_right = 7
+corner_radius_bottom_left = 7
+
[sub_resource type="StyleBoxFlat" id="LineEdit"]
content_margin_left = 8.0
content_margin_top = 5.0
@@ -144,34 +132,60 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
+[sub_resource type="StyleBoxFlat" id="Panel"]
+bg_color = Color(0.075, 0.145, 0.17, 0.96)
+border_width_left = 2
+border_width_top = 2
+border_width_right = 2
+border_width_bottom = 2
+border_color = Color(0.2, 0.55, 0.59, 0.65)
+corner_radius_top_left = 10
+corner_radius_top_right = 10
+corner_radius_bottom_right = 10
+corner_radius_bottom_left = 10
+
+[sub_resource type="StyleBoxFlat" id="TooltipPanel"]
+content_margin_left = 12.0
+content_margin_top = 8.0
+content_margin_right = 12.0
+content_margin_bottom = 8.0
+bg_color = Color(0.031, 0.122, 0.169, 1)
+corner_radius_top_left = 10
+corner_radius_top_right = 10
+corner_radius_bottom_right = 10
+corner_radius_bottom_left = 10
+
[resource]
default_font = ExtResource("1_font")
-default_font_size = 15
-Label/colors/font_color = Color(0.956, 0.972, 0.976, 1)
-Label/colors/font_shadow_color = Color(0, 0, 0, 0)
-PanelContainer/styles/panel = SubResource("Panel")
+default_font_size = 23
Button/colors/font_color = Color(0.956, 0.972, 0.976, 1)
+Button/colors/font_disabled_color = Color(0.48, 0.55, 0.58, 1)
Button/colors/font_hover_color = Color(1, 1, 1, 1)
Button/colors/font_pressed_color = Color(0.04, 0.12, 0.14, 1)
-Button/colors/font_disabled_color = Color(0.48, 0.55, 0.58, 1)
-Button/styles/normal = SubResource("ButtonNormal")
-Button/styles/hover = SubResource("ButtonHover")
-Button/styles/pressed = SubResource("ButtonPressed")
-Button/styles/focus = SubResource("ButtonHover")
Button/styles/disabled = SubResource("ButtonDisabled")
-OptionButton/styles/normal = SubResource("ButtonNormal")
-OptionButton/styles/hover = SubResource("ButtonHover")
-OptionButton/styles/pressed = SubResource("ButtonPressed")
-OptionButton/styles/focus = SubResource("ButtonHover")
-DangerButton/base_type = &"Button"
-DangerButton/styles/normal = SubResource("DangerNormal")
-DangerButton/styles/hover = SubResource("DangerHover")
-DangerButton/styles/pressed = SubResource("DangerHover")
-DangerButton/styles/focus = SubResource("DangerHover")
+Button/styles/focus = SubResource("ButtonHover")
+Button/styles/hover = SubResource("ButtonHover")
+Button/styles/normal = SubResource("ButtonNormal")
+Button/styles/pressed = SubResource("ButtonPressed")
CardButton/base_type = &"Button"
-CardButton/styles/normal = SubResource("CardNormal")
-CardButton/styles/hover = SubResource("CardHover")
-CardButton/styles/pressed = SubResource("CardPressed")
-CardButton/styles/focus = SubResource("CardHover")
CardButton/colors/font_pressed_color = Color(0.956, 0.972, 0.976, 1)
+CardButton/styles/focus = SubResource("CardHover")
+CardButton/styles/hover = SubResource("CardHover")
+CardButton/styles/normal = SubResource("CardNormal")
+CardButton/styles/pressed = SubResource("CardPressed")
+DangerButton/base_type = &"Button"
+DangerButton/styles/focus = SubResource("DangerHover")
+DangerButton/styles/hover = SubResource("DangerHover")
+DangerButton/styles/normal = SubResource("DangerNormal")
+DangerButton/styles/pressed = SubResource("DangerHover")
+Label/colors/font_color = Color(0.956, 0.972, 0.976, 1)
+Label/colors/font_shadow_color = Color(0, 0, 0, 0)
LineEdit/styles/normal = SubResource("LineEdit")
+OptionButton/styles/focus = SubResource("ButtonHover")
+OptionButton/styles/hover = SubResource("ButtonHover")
+OptionButton/styles/normal = SubResource("ButtonNormal")
+OptionButton/styles/pressed = SubResource("ButtonPressed")
+PanelContainer/styles/panel = SubResource("Panel")
+TooltipLabel/colors/font_color = Color(0.765, 0.875, 0.902, 1)
+TooltipLabel/font_sizes/font_size = 18
+TooltipPanel/styles/panel = SubResource("TooltipPanel")
diff --git a/ui/game_ui.gd b/ui/game_ui.gd
index 0301989..27db6e8 100644
--- a/ui/game_ui.gd
+++ b/ui/game_ui.gd
@@ -5,18 +5,22 @@ const CollectionLogType = preload("res://collection/collection_log.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const PlayerMenuType = preload("res://ui/player_menu.gd")
const PlayerType = preload("res://player/player.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
+const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const HotbarUIType = preload("res://ui/hotbar.gd")
const TitleScreenType = preload("res://ui/title_screen.gd")
const PauseMenuType = preload("res://ui/pause_menu.gd")
+const NetworkSessionType = preload("res://network/network_session.gd")
const FishingShopType = preload("res://ui/fishing_shop.gd")
+const SettingsPanelType = preload("res://ui/settings_panel.gd")
const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
)
@@ -29,38 +33,231 @@ const PlayerItemEffectsType = preload(
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
+const ChatUIType = preload("res://ui/chat_ui.gd")
+const EmoteRadialMenuType = preload("res://ui/emote_radial_menu.gd")
+const QuickRadialMenuType = preload("res://ui/quick_radial_menu.gd")
+const ControllerVirtualCursorType = preload(
+ "res://ui/controller_virtual_cursor.gd"
+)
+const SurfaceDrawingToolbarType = preload(
+ "res://ui/surface_drawing_toolbar.gd"
+)
+const PlayerSettingsManagerType = preload(
+ "res://settings/player_settings_manager.gd"
+)
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const WorldTimeServiceType = preload("res://world/world_time_service.gd")
+const WorldWeatherServiceType = preload(
+ "res://world/world_weather_service.gd"
+)
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+const MAIN_BUBBLE_PROFILE: BubbleMenuProfile = preload(
+ "res://ui/components/bubble_menu/bubble_menu_profile.tres"
+)
+const ACTIVE_BAIT_EMPTY_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/x_dark.png"
+)
+
+signal pixelation_settings_visibility_changed(is_visible: bool)
+signal crisp_reset_focus_requested
+signal interactive_pointer_ui_changed(is_open: bool)
+signal passive_pointer_ui_changed(is_enabled: bool)
+signal player_menu_backdrop_visibility_changed(is_visible: bool)
+signal shop_backdrop_visibility_changed(is_visible: bool)
+
+const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse"
+const VIRTUAL_MOUSE_TRIGGER_THRESHOLD: float = 0.55
+const VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD: float = 0.35
+const VIRTUAL_MOUSE_STICK_DEADZONE: float = 0.18
+const VIRTUAL_MOUSE_SPEED: float = 720.0
+const CONTROLLER_MENU_SCROLL_DEADZONE: float = 0.25
+const CONTROLLER_MENU_SCROLL_SPEED: float = 660.0
+# Android controller mappings may expose LT on its own axis or as the negative
+# half of the same signed axis used by RT. Support both without letting the RT
+# zoom direction enter virtual-pointer mode.
+const VIRTUAL_MOUSE_TRIGGER_AXIS: JoyAxis = JOY_AXIS_TRIGGER_RIGHT
+const VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS: JoyAxis = JOY_AXIS_TRIGGER_LEFT
+const VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS: JoyAxis = JOY_AXIS_TRIGGER_LEFT
+const FISHING_PANEL_DEFAULT_TOP_OFFSET: float = -210.0
+const FISHING_PANEL_DEFAULT_BOTTOM_OFFSET: float = -140.0
+const FISHING_PANEL_SHOWCASE_TOP_OFFSET: float = -174.0
+const FISHING_PANEL_SHOWCASE_BOTTOM_OFFSET: float = -104.0
@onready var _status_label: Label = %StatusLabel
+@onready var _bite_prompt_button: Button = %BitePromptButton
+@onready var _gameplay_transient_hud: Control = %GameplayTransientHUD
+@onready var _active_bait_indicator: Control = %ActiveBaitIndicator
+@onready var _active_bait_button: Button = %ActiveBaitButton
+@onready var _active_bait_quantity_badge: Panel = %ActiveBaitQuantityBadge
+@onready var _active_bait_quantity: Label = %ActiveBaitQuantity
+@onready var _experience_presentation: Control = %ExperiencePresentation
@onready var _catch_track: Control = %CatchTrack
@onready var _green_catch_progress: ProgressBar = %GreenCatchProgress
@onready var _red_chase_progress: ProgressBar = %RedChaseProgress
@onready var _barrier_markers: Control = %BarrierMarkers
-@onready var _barrier_summary: Label = %BarrierSummary
+@onready var _barrier_prompt_panel: PanelContainer = %BarrierPromptPanel
+@onready var _barrier_prompt: Label = %BarrierPrompt
@onready var _barrier_health: Label = %BarrierHealth
@onready var _showcase_details: Label = %ShowcaseDetails
@onready var _fishing_panel: PanelContainer = %FishingPanel
+@onready var _experience_panel: PanelContainer = %ExperienceProgressPanel
+@onready var _experience_level_label: Label = %ExperienceLevelLabel
+@onready var _experience_award_label: Label = %ExperienceAwardLabel
+@onready var _experience_progress: ProgressBar = %ExperienceProgress
+@onready var _experience_bubble: PanelContainer = %ExperienceBubble
+@onready var _experience_bubble_label: Label = %ExperienceBubbleLabel
+const TypewriterRevealType = preload("res://ui/typewriter_reveal.gd")
+const AnimaleseVoiceType = preload("res://ui/animalese_voice.gd")
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+const SHOP_ANIMALESE_VOICE_ID: String = "natural"
+const SHOP_ANIMALESE_BASE_PITCH: float = 1.08
+const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0
+const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
+const ART_KIT_ITEM_ID: StringName = &"art_kit"
+
+
+@onready var _canonical_stage: Control = %CanonicalStage
@onready var _player_menu: PlayerMenuType = %PlayerMenu
@onready var _screen_fade: ScreenFade = %ScreenFade
@onready var _title_screen: TitleScreenType = %TitleScreen
@onready var _pause_menu: PauseMenuType = %PauseMenu
@onready var _hotbar_ui: HotbarUIType = %Hotbar
@onready var _fishing_shop: FishingShopType = %FishingShop
-@onready var _shop_prompt: PanelContainer = %ShopPrompt
+@onready var _shop_prompt: Control = %ShopPrompt
+@onready var _shop_prompt_bubble: PanelContainer = %ShopPromptBubble
+@onready var _shop_prompt_message: Label = %ShopPromptMessage
+@onready var _shop_prompt_key_badge: Panel = %ShopPromptKeyBadge
+@onready var _shop_prompt_key: Label = %ShopPromptKey
+@onready var _shop_prompt_pointer: Polygon2D = %ShopPromptPointer
+var _shop_animalese_voice: AnimaleseVoiceType
+var _shop_npc_player_in_range: bool = false
+var _shop_npc_spoken_for_current_visit: bool = false
+var _shop_npc_next_speech_msec: int = 0
@onready var _effect_status: Label = %EffectStatus
+@onready var _chat_ui: ChatUIType = %ChatUI
+@onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu
+@onready var _quick_radial_menu: QuickRadialMenuType = %QuickRadialMenu
+@onready var _controller_virtual_cursor: ControllerVirtualCursorType = (
+ %ControllerVirtualCursor
+)
+@onready var _surface_drawing_toolbar: SurfaceDrawingToolbarType = (
+ %SurfaceDrawingToolbar
+)
+@onready var _title_settings_panel: SettingsPanelType = (
+ $UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel
+)
+@onready var _pause_settings_panel: SettingsPanelType = (
+ $UIRoot/CanonicalStage/PauseMenu/ResponsivePauseStage/PausePresentationScaleRoot/SettingsCenter/SettingsPanel
+)
var _showcase_active: bool = false
+var _player: PlayerType
+var _network_chat_service: NetworkChatService
+var _network_profile: NetworkProfilePreferences
+var _spawn_service: PlayerSpawnService
+var _bag: PlayerBagType
+var _hotbar: PlayerHotbarType
+var _item_catalog: ItemCatalogType
var _player_menu_open: bool = false
var _gameplay_ui_enabled: bool = false
+var _gameplay_hud_hidden: bool = false
var _fishing_spot: FishingSpotType
var _system_menu_open: bool = false
var _shop_open: bool = false
+var _chat_input_open: bool = false
+var _player_menu_hotbar_visible: bool = false
var _item_effects: PlayerItemEffectsType
+var _main_shop_buyer: FishBuyerProfileType
+var _shop_interaction: ShopInteractionType
+var _surface_drawing: NetworkSurfaceDrawingService
+var _surface_drawing_hotbar_selected: bool = false
+var _experience: PlayerExperienceType
+var _experience_award_queue: Array[Dictionary] = []
+var _experience_animation_active: bool = false
+var _experience_animation_generation: int = 0
+var _experience_panel_rest_y: float = 18.0
+var _emote_prior_camera_input_enabled: bool = true
+var _quick_prior_camera_input_enabled: bool = true
+var _virtual_mouse_active: bool = false
+var _virtual_mouse_prior_camera_input_enabled: bool = true
+var _virtual_mouse_prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
+var _virtual_mouse_window_position: Vector2 = Vector2.ZERO
+var _virtual_mouse_button_mask: int = 0
+var _virtual_mouse_right_pressed: bool = false
+var _virtual_mouse_device_id: int = 0
+var _virtual_mouse_trigger_strength: float = 0.0
+var _virtual_mouse_stick: Vector2 = Vector2.ZERO
+var _virtual_mouse_trigger_rest_by_device: Dictionary[int, float] = {}
+var _shared_trigger_rest_by_device: Dictionary[int, float] = {}
+var _controller_mapping_manager: ControllerMappingManagerType
+var _settings_manager: PlayerSettingsManagerType
+
+
+func _ready() -> void:
+ _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed)
+ _apply_active_bait_indicator_style()
+ _refresh_active_bait_indicator()
+ # Reward feedback must remain above full-screen canonical menus. Keeping the
+ # overlay as the final stage child makes that ownership explicit instead of
+ # relying on scene declaration order when another menu adds high-z children.
+ var presentation_parent: Node = _experience_presentation.get_parent()
+ if presentation_parent != null:
+ presentation_parent.move_child(
+ _experience_presentation,
+ presentation_parent.get_child_count() - 1,
+ )
+ _emote_radial_menu.emote_selected.connect(_on_emote_selected)
+ _quick_radial_menu.action_selected.connect(_on_quick_action_selected)
+ _chat_ui.text_entry_ownership_changed.connect(
+ _on_chat_text_entry_ownership_changed
+ )
+ _hotbar_ui.presentation_transition_finished.connect(
+ _on_hotbar_presentation_transition_finished
+ )
+ _player_menu.controller_hotbar_placement_requested.connect(
+ _on_controller_hotbar_placement_requested
+ )
+ _player_menu.controller_hotbar_placement_ended.connect(
+ _on_controller_hotbar_placement_ended
+ )
+ _player_menu.controller_hotbar_management_requested.connect(
+ _on_controller_hotbar_management_requested
+ )
+ _player_menu.controller_hotbar_management_ended.connect(
+ _on_controller_hotbar_management_ended
+ )
+ _title_settings_panel.panel_visibility_changed.connect(
+ _on_settings_visibility_changed
+ )
+ _pause_settings_panel.panel_visibility_changed.connect(
+ _on_settings_visibility_changed
+ )
+ _title_settings_panel.crisp_reset_focus_requested.connect(
+ crisp_reset_focus_requested.emit
+ )
+ _pause_settings_panel.crisp_reset_focus_requested.connect(
+ crisp_reset_focus_requested.emit
+ )
+ if not Input.joy_connection_changed.is_connected(
+ _on_controller_connection_changed
+ ):
+ Input.joy_connection_changed.connect(_on_controller_connection_changed)
func setup(
player: PlayerType,
inventory: FishInventoryType,
collection_log: CollectionLogType,
+ experience: PlayerExperienceType,
wallet: PlayerWalletType,
sale_service: FishSaleServiceType,
default_buyer: FishBuyerProfileType,
@@ -74,19 +271,97 @@ func setup(
shop_interaction: ShopInteractionType,
item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType,
+ network_session: NetworkSessionType,
+ network_sale_service: NetworkSaleService,
+ network_shop_service: NetworkShopService,
+ network_chat_service: NetworkChatService,
+ network_profile: NetworkProfilePreferences,
+ spawn_service: PlayerSpawnService,
+ network_mail_service: NetworkMailService,
+ reservations: PlayerAssetReservationService,
+ network_profile_service: NetworkProfileService,
+ network_player_list: NetworkPlayerListService,
+ discovery: DiscoveryClient,
+ settings_manager: PlayerSettingsManagerType,
+ surface_drawing: NetworkSurfaceDrawingService,
+ art_unlocks: PlayerArtUnlocks,
+ world_time: WorldTimeServiceType,
+ world_weather: WorldWeatherServiceType,
+ player_jobs: PlayerJobService,
+ world_environment: WorldEnvironment,
+ world_sun: DirectionalLight3D,
) -> void:
+ _player = player
+ _network_chat_service = network_chat_service
+ _network_profile = network_profile
+ _spawn_service = spawn_service
+ _bag = bag
+ _hotbar = hotbar
+ _item_catalog = item_catalog
+ _settings_manager = settings_manager
_fishing_spot = fishing_spot
_item_effects = item_effects
+ _experience = experience
+ if (
+ _player != null
+ and not _player.active_bait_changed.is_connected(
+ _on_hud_active_bait_changed
+ )
+ ):
+ _player.active_bait_changed.connect(_on_hud_active_bait_changed)
+ if (
+ _bag != null
+ and not _bag.contents_changed.is_connected(
+ _on_hud_bait_inventory_changed
+ )
+ ):
+ _bag.contents_changed.connect(_on_hud_bait_inventory_changed)
+ _refresh_active_bait_indicator()
+ if (
+ _network_chat_service != null
+ and not _network_chat_service.character_call_received.is_connected(
+ _on_character_call_received
+ )
+ ):
+ _network_chat_service.character_call_received.connect(
+ _on_character_call_received
+ )
+ if (
+ _experience != null
+ and not _experience.experience_awarded.is_connected(
+ _on_experience_awarded
+ )
+ ):
+ _experience.experience_awarded.connect(_on_experience_awarded)
+ _chat_ui.setup(
+ network_chat_service, network_session, spawn_service, player,
+ fishing_spot, settings_manager, world_time, world_weather, item_effects
+ )
+ _title_settings_panel.setup_network_profile(
+ network_profile, network_session
+ )
+ _pause_settings_panel.setup_network_profile(
+ network_profile, network_session
+ )
fishing_spot.status_changed.connect(_on_fishing_status_changed)
+ fishing_spot.bite_prompt_changed.connect(_on_bite_prompt_changed)
fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
fishing_spot.showcase_changed.connect(_on_showcase_changed)
_player_menu.menu_visibility_changed.connect(
_on_player_menu_visibility_changed
)
+ _player_menu.menu_exit_started.connect(_on_player_menu_exit_started)
+ _player_menu.inventory_hotbar_context_changed.connect(
+ _on_inventory_hotbar_context_changed
+ )
+ _player_menu.shop_cooler_modal_changed.connect(
+ _on_shop_cooler_modal_changed
+ )
_player_menu.setup(
player,
inventory,
collection_log,
+ experience,
wallet,
sale_service,
default_buyer,
@@ -95,26 +370,845 @@ func setup(
bag,
hotbar,
item_catalog,
- cooler_capacity
+ cooler_capacity,
+ network_session,
+ network_sale_service,
+ network_mail_service,
+ reservations,
+ network_profile_service,
+ network_player_list,
+ discovery,
+ player_jobs,
+ world_time,
+ world_environment,
+ world_sun,
)
- _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot)
+ if (
+ _settings_manager != null
+ and not _settings_manager.settings_changed.is_connected(
+ _on_player_settings_changed
+ )
+ ):
+ _settings_manager.settings_changed.connect(_on_player_settings_changed)
+ if _settings_manager != null:
+ _on_player_settings_changed(_settings_manager.current_settings)
+ _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot, inventory)
_fishing_shop.setup(
player,
- inventory,
wallet,
- sale_service,
main_shop_buyer,
fishing_upgrades,
fishing_spot,
shop_interaction,
bag,
item_catalog,
- cooler_capacity
+ cooler_capacity,
+ art_unlocks,
+ network_shop_service,
)
_fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
+ _fishing_shop.menu_exit_started.connect(_on_shop_exit_started)
+ _fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested)
+ _fishing_shop.shop_cooler_return_requested.connect(
+ _on_shop_cooler_return_requested
+ )
+ _fishing_shop.shop_cooler_confirmation_cancel_requested.connect(
+ _player_menu.cancel_shop_cooler_confirmation
+ )
+ _main_shop_buyer = main_shop_buyer
+ _shop_interaction = shop_interaction
+ _surface_drawing = surface_drawing
+ _surface_drawing_toolbar.setup(_surface_drawing, art_unlocks)
+ set_edge_docks(
+ settings_manager.current_settings.chat_dock_right,
+ settings_manager.current_settings.paint_dock_right,
+ settings_manager.current_settings.chat_mobile_mode,
+ )
-func _process(_delta: float) -> void:
+func _input(event: InputEvent) -> void:
+ if _handle_virtual_mouse_input(event):
+ get_viewport().set_input_as_handled()
+ return
+ if _handle_controller_chat_controls(event):
+ get_viewport().set_input_as_handled()
+ return
+ if (
+ event.is_action_pressed("toggle_hud")
+ and not (event is InputEventKey and event.echo)
+ and _can_toggle_gameplay_hud()
+ ):
+ set_gameplay_hud_hidden(not _gameplay_hud_hidden)
+ get_viewport().set_input_as_handled()
+ return
+ if (
+ _surface_drawing_toolbar != null
+ and _surface_drawing_toolbar.owns_pointer_event(event)
+ ):
+ return
+ if event.is_action_pressed("character_call") and _can_use_character_call():
+ _network_chat_service.send_local_character_call(
+ _network_profile.call_id
+ )
+ get_viewport().set_input_as_handled()
+ return
+ var drawing_can_open: bool = _can_surface_drawing_be_active()
+ if (
+ _surface_drawing != null
+ and _surface_drawing.handle_input(
+ event,
+ drawing_can_open,
+ _drawing_pointer_window_position(),
+ )
+ ):
+ get_viewport().set_input_as_handled()
+ return
+ if _emote_radial_menu == null or _quick_radial_menu == null:
+ return
+ var can_open: bool = (
+ _gameplay_ui_enabled
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ and not _chat_input_open
+ and not _showcase_active
+ and not _virtual_mouse_active
+ )
+ var emote_was_open: bool = _emote_radial_menu.is_open()
+ if _emote_radial_menu.handle_input(
+ event,
+ can_open and not _quick_radial_menu.is_open(),
+ ):
+ var emote_is_open: bool = _emote_radial_menu.is_open()
+ if emote_is_open != emote_was_open and _player != null:
+ if emote_is_open:
+ _emote_prior_camera_input_enabled = (
+ _player.is_camera_input_enabled()
+ )
+ _player.set_camera_input_enabled(false)
+ else:
+ _player.set_camera_input_enabled(
+ _emote_prior_camera_input_enabled
+ )
+ get_viewport().set_input_as_handled()
+ return
+ var quick_was_open: bool = _quick_radial_menu.is_open()
+ if _quick_radial_menu.handle_input(
+ event,
+ can_open and not _emote_radial_menu.is_open(),
+ ):
+ var quick_is_open: bool = _quick_radial_menu.is_open()
+ if quick_is_open != quick_was_open and _player != null:
+ if quick_is_open:
+ _quick_prior_camera_input_enabled = (
+ _player.is_camera_input_enabled()
+ )
+ _player.set_camera_input_enabled(false)
+ else:
+ _player.set_camera_input_enabled(
+ _quick_prior_camera_input_enabled
+ )
+ get_viewport().set_input_as_handled()
+
+
+func _can_use_character_call() -> bool:
+ return (
+ _gameplay_ui_enabled
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ and not _chat_input_open
+ and not _showcase_active
+ and not _virtual_mouse_active
+ and _network_chat_service != null
+ and _network_profile != null
+ )
+
+
+func _on_character_call_received(
+ peer_id: int,
+ call_id: String,
+ pitch_scale: float,
+) -> void:
+ if _spawn_service == null:
+ return
+ var avatar: PlayerType = _spawn_service.get_avatar(peer_id)
+ if avatar == null:
+ return
+ avatar.play_character_call_visual()
+ var audio_path: String = VoiceProfilesType.call_audio_path(call_id)
+ if not ResourceLoader.exists(audio_path, "AudioStream"):
+ return
+ var stream: AudioStream = load(audio_path) as AudioStream
+ if stream == null:
+ return
+ var call_player := AudioStreamPlayer3D.new()
+ call_player.name = "CharacterCall"
+ call_player.bus = &"SFX"
+ call_player.stream = stream
+ call_player.pitch_scale = pitch_scale
+ call_player.max_distance = 28.0
+ call_player.unit_size = 4.0
+ call_player.position = (
+ Vector3.UP * 1.25 * avatar.get_character_visual_scale()
+ )
+ avatar.add_child(call_player)
+ call_player.finished.connect(call_player.queue_free)
+ call_player.play()
+
+
+func _handle_controller_chat_controls(event: InputEvent) -> bool:
+ var button_event: InputEventJoypadButton = event as InputEventJoypadButton
+ if (
+ button_event == null
+ or not button_event.pressed
+ or not _gameplay_ui_enabled
+ or _system_menu_open
+ or _player_menu_open
+ or _shop_open
+ ):
+ return false
+ var use_mapping: bool = (
+ _controller_mapping_manager != null
+ )
+ var select_pressed: bool = (
+ _controller_mapping_manager.event_matches_role(
+ event, ControllerMappingManagerType.ROLE_SELECT
+ )
+ if use_mapping
+ else button_event.button_index == JOY_BUTTON_BACK
+ )
+ var focus_pressed: bool = (
+ _controller_mapping_manager.event_matches_role(
+ event, ControllerMappingManagerType.ROLE_LB
+ )
+ if use_mapping
+ else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER
+ )
+ var accept_pressed: bool = (
+ _controller_mapping_manager.event_matches_role(
+ event, ControllerMappingManagerType.ROLE_A
+ )
+ if use_mapping
+ else button_event.button_index == JOY_BUTTON_A
+ )
+ if select_pressed:
+ # Handle Select before focused LineEdit controls consume it. Select owns
+ # chat visibility while LB only returns input ownership to the world.
+ _chat_ui.toggle_chat()
+ return true
+ if focus_pressed:
+ _chat_ui.refocus_gameplay()
+ return true
+ if accept_pressed:
+ return _chat_ui.request_virtual_keyboard()
+ return false
+
+
+func _on_emote_selected(emote_id: StringName) -> void:
+ if emote_id == &"sit" and _player != null:
+ _player.toggle_sitting()
+
+
+func _on_quick_action_selected(action_id: StringName) -> void:
+ match action_id:
+ &"stuff":
+ _player_menu.open_section(PlayerMenuType.Section.COOLER)
+ &"logbook":
+ _player_menu.open_section(PlayerMenuType.Section.LOGBOOK)
+ &"fishnet":
+ _player_menu.open_section(PlayerMenuType.Section.NET)
+ &"mail":
+ _player_menu.open_section(PlayerMenuType.Section.MAIL)
+ &"profile":
+ _player_menu.open_section(PlayerMenuType.Section.PROFILE)
+ &"online":
+ _player_menu.open_section(PlayerMenuType.Section.PLAYERS)
+ &"paint":
+ _select_art_kit_hotbar_slot()
+ &"chat":
+ _chat_ui.open_chat()
+ &"freecam":
+ if _player != null:
+ _player.toggle_free_camera()
+ &"hud":
+ set_gameplay_hud_hidden(not _gameplay_hud_hidden)
+
+
+func _handle_virtual_mouse_input(event: InputEvent) -> bool:
+ if (
+ _controller_mapping_manager != null
+ and _controller_mapping_manager.has_custom_mapping()
+ ):
+ return _handle_mapped_virtual_mouse_input(event)
+ var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion
+ if motion_event != null:
+ if motion_event.axis in [
+ VIRTUAL_MOUSE_TRIGGER_AXIS,
+ VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS,
+ ]:
+ if (
+ _virtual_mouse_active
+ and motion_event.device != _virtual_mouse_device_id
+ ):
+ return false
+ _sample_trigger_rest_values(motion_event.device)
+ _virtual_mouse_trigger_strength = (
+ _virtual_mouse_strength_for_device(motion_event.device)
+ )
+ var was_active: bool = _virtual_mouse_active
+ if (
+ _virtual_mouse_trigger_strength
+ >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD
+ ):
+ if not _virtual_mouse_active and _can_start_virtual_mouse():
+ _begin_virtual_mouse(motion_event.device)
+ elif (
+ _virtual_mouse_active
+ and _virtual_mouse_trigger_strength
+ < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD
+ ):
+ _end_virtual_mouse()
+ elif _virtual_mouse_active:
+ _set_virtual_mouse_button(
+ MOUSE_BUTTON_RIGHT,
+ _secondary_click_strength_for_device(
+ motion_event.device
+ ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD,
+ )
+ return was_active or _virtual_mouse_active
+ if (
+ _virtual_mouse_active
+ and motion_event.device == _virtual_mouse_device_id
+ and motion_event.axis in [
+ JOY_AXIS_RIGHT_X,
+ JOY_AXIS_RIGHT_Y,
+ ]
+ ):
+ if motion_event.axis == JOY_AXIS_RIGHT_X:
+ _virtual_mouse_stick.x = motion_event.axis_value
+ else:
+ _virtual_mouse_stick.y = motion_event.axis_value
+ return true
+ var button_event: InputEventJoypadButton = event as InputEventJoypadButton
+ if (
+ button_event != null
+ and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER
+ and (
+ _virtual_mouse_active
+ or _virtual_mouse_trigger_strength
+ >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD
+ )
+ ):
+ if not _virtual_mouse_active and _can_start_virtual_mouse():
+ _begin_virtual_mouse(button_event.device)
+ if _virtual_mouse_active:
+ _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, button_event.pressed)
+ return true
+ return false
+
+
+func _handle_mapped_virtual_mouse_input(event: InputEvent) -> bool:
+ var uses_activation: bool = _controller_mapping_manager.event_uses_role(
+ event,
+ ControllerMappingManagerType.ROLE_POINTER_MODIFIER,
+ )
+ if uses_activation:
+ var was_active: bool = _virtual_mouse_active
+ _virtual_mouse_trigger_strength = (
+ _controller_mapping_manager.get_role_strength(
+ ControllerMappingManagerType.ROLE_POINTER_MODIFIER
+ )
+ )
+ if (
+ _virtual_mouse_trigger_strength
+ >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD
+ and not _virtual_mouse_active
+ and _can_start_virtual_mouse()
+ ):
+ _begin_virtual_mouse(
+ _controller_mapping_manager.get_active_device_id()
+ )
+ elif (
+ _virtual_mouse_active
+ and _virtual_mouse_trigger_strength
+ < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD
+ ):
+ _end_virtual_mouse()
+ return was_active or _virtual_mouse_active
+ if not _virtual_mouse_active:
+ return false
+ if (
+ _controller_mapping_manager.event_uses_role(
+ event,
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X,
+ )
+ or _controller_mapping_manager.event_uses_role(
+ event,
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_Y,
+ )
+ ):
+ _virtual_mouse_stick = _mapped_virtual_mouse_stick()
+ return true
+ var button_event := event as InputEventJoypadButton
+ if button_event != null and _controller_mapping_manager.event_uses_role(
+ event,
+ ControllerMappingManagerType.ROLE_RB,
+ ):
+ _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, button_event.pressed)
+ return true
+ if _controller_mapping_manager.event_uses_role(
+ event,
+ ControllerMappingManagerType.ROLE_CAMERA_ZOOM,
+ ):
+ _set_virtual_mouse_button(
+ MOUSE_BUTTON_RIGHT,
+ _controller_mapping_manager.get_role_strength(
+ ControllerMappingManagerType.ROLE_CAMERA_ZOOM
+ ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD,
+ )
+ return true
+ return false
+
+
+func _mapped_virtual_mouse_stick() -> Vector2:
+ return Vector2(
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X
+ ),
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_Y
+ ),
+ )
+
+
+func _can_start_virtual_mouse() -> bool:
+ return (
+ _gameplay_ui_enabled
+ and not _showcase_active
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ and not _chat_input_open
+ and _player != null
+ and _fishing_spot != null
+ and _fishing_spot.can_open_system_menu()
+ and not _emote_radial_menu.is_open()
+ and not _quick_radial_menu.is_open()
+ )
+
+
+func _begin_virtual_mouse(device_id: int) -> void:
+ if _virtual_mouse_active:
+ return
+ _virtual_mouse_active = true
+ _virtual_mouse_device_id = maxi(device_id, 0)
+ _virtual_mouse_stick = (
+ _mapped_virtual_mouse_stick()
+ if (
+ _controller_mapping_manager != null
+ and _controller_mapping_manager.has_custom_mapping()
+ )
+ else Vector2(
+ Input.get_joy_axis(_virtual_mouse_device_id, JOY_AXIS_RIGHT_X),
+ Input.get_joy_axis(_virtual_mouse_device_id, JOY_AXIS_RIGHT_Y),
+ )
+ )
+ _virtual_mouse_prior_camera_input_enabled = (
+ _player.is_camera_input_enabled()
+ )
+ _player.set_camera_input_enabled(false)
+ if _fishing_spot != null:
+ _fishing_spot.set_local_menu_input_suppressed(
+ VIRTUAL_MOUSE_INPUT_OWNER,
+ true,
+ )
+ _virtual_mouse_prior_mouse_mode = Input.mouse_mode
+ Input.mouse_mode = Input.MOUSE_MODE_HIDDEN
+ _virtual_mouse_button_mask = 0
+ _virtual_mouse_right_pressed = false
+ _virtual_mouse_window_position = _clamp_virtual_mouse_window_position(
+ Vector2(get_window().size) * 0.5
+ )
+ _controller_virtual_cursor.visible = true
+ _update_virtual_cursor_position(_virtual_mouse_window_position)
+ _emit_virtual_mouse_motion(Vector2.ZERO)
+
+
+func _end_virtual_mouse() -> void:
+ if not _virtual_mouse_active:
+ return
+ if (_virtual_mouse_button_mask & MOUSE_BUTTON_MASK_LEFT) != 0:
+ _set_virtual_mouse_button(MOUSE_BUTTON_LEFT, false)
+ if _virtual_mouse_right_pressed:
+ _set_virtual_mouse_button(MOUSE_BUTTON_RIGHT, false)
+ _virtual_mouse_active = false
+ _virtual_mouse_trigger_strength = 0.0
+ _virtual_mouse_stick = Vector2.ZERO
+ _controller_virtual_cursor.visible = false
+ if _fishing_spot != null:
+ _fishing_spot.set_local_menu_input_suppressed(
+ VIRTUAL_MOUSE_INPUT_OWNER,
+ false,
+ )
+ if _player != null and is_instance_valid(_player):
+ _player.set_camera_input_enabled(
+ _virtual_mouse_prior_camera_input_enabled
+ )
+ Input.mouse_mode = _virtual_mouse_prior_mouse_mode
+
+
+func _update_virtual_mouse(delta: float) -> void:
+ if not _virtual_mouse_active:
+ return
+ if (
+ _virtual_mouse_trigger_strength
+ < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD
+ ):
+ _end_virtual_mouse()
+ return
+ var stick: Vector2 = _virtual_mouse_stick
+ var stick_length: float = stick.length()
+ if stick_length <= VIRTUAL_MOUSE_STICK_DEADZONE:
+ return
+ var adjusted_strength: float = (
+ (stick_length - VIRTUAL_MOUSE_STICK_DEADZONE)
+ / (1.0 - VIRTUAL_MOUSE_STICK_DEADZONE)
+ )
+ var display_scale: float = UIReferencePresentationType.get_scale(
+ Vector2(get_window().size)
+ )
+ var relative_motion: Vector2 = (
+ stick.normalized()
+ * adjusted_strength
+ * VIRTUAL_MOUSE_SPEED
+ * display_scale
+ * delta
+ )
+ _virtual_mouse_window_position = _clamp_virtual_mouse_window_position(
+ _virtual_mouse_window_position + relative_motion
+ )
+ _update_virtual_cursor_position(_virtual_mouse_window_position)
+ _emit_virtual_mouse_motion(relative_motion)
+
+
+func _poll_virtual_mouse_controller_state() -> void:
+ if (
+ _controller_mapping_manager != null
+ and _controller_mapping_manager.has_custom_mapping()
+ ):
+ _poll_mapped_virtual_mouse_controller_state()
+ return
+ var device_ids: Array[int] = Input.get_connected_joypads()
+ # Some Android controller backends deliver device-0 axes without including
+ # that device in get_connected_joypads(). Player camera input already uses
+ # this same primary-device fallback.
+ if device_ids.is_empty():
+ device_ids.append(0)
+ for device_id: int in device_ids:
+ _sample_trigger_rest_values(device_id)
+ if _virtual_mouse_active:
+ if not device_ids.has(_virtual_mouse_device_id):
+ _end_virtual_mouse()
+ return
+ _virtual_mouse_trigger_strength = _virtual_mouse_strength_for_device(
+ _virtual_mouse_device_id
+ )
+ _set_virtual_mouse_button(
+ MOUSE_BUTTON_RIGHT,
+ _secondary_click_strength_for_device(
+ _virtual_mouse_device_id
+ ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD,
+ )
+ _virtual_mouse_stick = Vector2(
+ Input.get_joy_axis(
+ _virtual_mouse_device_id,
+ JOY_AXIS_RIGHT_X,
+ ),
+ Input.get_joy_axis(
+ _virtual_mouse_device_id,
+ JOY_AXIS_RIGHT_Y,
+ ),
+ )
+ return
+ if not _can_start_virtual_mouse():
+ return
+ for device_id: int in device_ids:
+ var trigger_strength: float = _virtual_mouse_strength_for_device(
+ device_id
+ )
+ if trigger_strength < VIRTUAL_MOUSE_TRIGGER_THRESHOLD:
+ continue
+ _virtual_mouse_trigger_strength = trigger_strength
+ _begin_virtual_mouse(device_id)
+ return
+
+
+func _poll_mapped_virtual_mouse_controller_state() -> void:
+ var trigger_strength: float = _controller_mapping_manager.get_role_strength(
+ ControllerMappingManagerType.ROLE_POINTER_MODIFIER
+ )
+ if _virtual_mouse_active:
+ _virtual_mouse_trigger_strength = trigger_strength
+ if trigger_strength < VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD:
+ _end_virtual_mouse()
+ return
+ _virtual_mouse_stick = _mapped_virtual_mouse_stick()
+ _set_virtual_mouse_button(
+ MOUSE_BUTTON_RIGHT,
+ _controller_mapping_manager.get_role_strength(
+ ControllerMappingManagerType.ROLE_CAMERA_ZOOM
+ ) >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD,
+ )
+ return
+ if (
+ trigger_strength >= VIRTUAL_MOUSE_TRIGGER_THRESHOLD
+ and _can_start_virtual_mouse()
+ ):
+ _virtual_mouse_trigger_strength = trigger_strength
+ _begin_virtual_mouse(
+ _controller_mapping_manager.get_active_device_id()
+ )
+
+
+func _sample_trigger_rest_values(device_id: int) -> void:
+ if not _virtual_mouse_trigger_rest_by_device.has(device_id):
+ _virtual_mouse_trigger_rest_by_device[device_id] = Input.get_joy_axis(
+ device_id,
+ VIRTUAL_MOUSE_TRIGGER_AXIS,
+ )
+ if not _shared_trigger_rest_by_device.has(device_id):
+ _shared_trigger_rest_by_device[device_id] = Input.get_joy_axis(
+ device_id,
+ VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS,
+ )
+
+
+func _virtual_mouse_strength_for_device(device_id: int) -> float:
+ _sample_trigger_rest_values(device_id)
+ var dedicated_rest: float = (
+ _virtual_mouse_trigger_rest_by_device[device_id]
+ )
+ var shared_rest: float = _shared_trigger_rest_by_device[device_id]
+ var dedicated_strength: float = normalized_trigger_strength(
+ Input.get_joy_axis(device_id, VIRTUAL_MOUSE_TRIGGER_AXIS),
+ dedicated_rest,
+ )
+ var shared_negative_strength: float = directional_trigger_strength(
+ Input.get_joy_axis(device_id, VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS),
+ shared_rest,
+ -1.0,
+ )
+ return maxf(dedicated_strength, shared_negative_strength)
+
+
+func _secondary_click_strength_for_device(device_id: int) -> float:
+ _sample_trigger_rest_values(device_id)
+ return directional_trigger_strength(
+ Input.get_joy_axis(device_id, VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS),
+ _shared_trigger_rest_by_device[device_id],
+ 1.0,
+ )
+
+
+static func normalized_trigger_strength(
+ axis_value: float,
+ resting_value: float,
+) -> float:
+ var negative_travel: float = absf(-1.0 - resting_value)
+ var positive_travel: float = absf(1.0 - resting_value)
+ var available_travel: float = maxf(negative_travel, positive_travel)
+ if available_travel <= 0.001:
+ return 0.0
+ return clampf(
+ absf(axis_value - resting_value) / available_travel,
+ 0.0,
+ 1.0,
+ )
+
+
+static func directional_trigger_strength(
+ axis_value: float,
+ resting_value: float,
+ direction: float,
+) -> float:
+ var normalized_direction: float = signf(direction)
+ if is_zero_approx(normalized_direction):
+ return 0.0
+ var endpoint: float = normalized_direction
+ var available_travel: float = absf(endpoint - resting_value)
+ if available_travel <= 0.001:
+ return 0.0
+ var directed_travel: float = (
+ (axis_value - resting_value) * normalized_direction
+ )
+ return clampf(directed_travel / available_travel, 0.0, 1.0)
+
+
+func _set_virtual_mouse_button(button: MouseButton, pressed: bool) -> void:
+ var mask: int = (
+ MOUSE_BUTTON_MASK_LEFT
+ if button == MOUSE_BUTTON_LEFT
+ else MOUSE_BUTTON_MASK_RIGHT
+ )
+ var already_pressed: bool = bool(_virtual_mouse_button_mask & mask)
+ if already_pressed == pressed:
+ return
+ if pressed:
+ _virtual_mouse_button_mask |= mask
+ else:
+ _virtual_mouse_button_mask &= ~mask
+ if button == MOUSE_BUTTON_RIGHT:
+ _virtual_mouse_right_pressed = pressed
+ var mouse_event := InputEventMouseButton.new()
+ mouse_event.position = _virtual_mouse_window_position
+ mouse_event.global_position = _virtual_mouse_window_position
+ mouse_event.button_index = button
+ mouse_event.button_mask = _virtual_mouse_button_mask
+ mouse_event.pressed = pressed
+ mouse_event.factor = 1.0
+ _parse_virtual_mouse_event(mouse_event)
+
+
+func _emit_virtual_mouse_motion(relative_motion: Vector2) -> void:
+ var motion_event := InputEventMouseMotion.new()
+ motion_event.position = _virtual_mouse_window_position
+ motion_event.global_position = _virtual_mouse_window_position
+ motion_event.relative = relative_motion
+ motion_event.button_mask = _virtual_mouse_button_mask
+ _parse_virtual_mouse_event(motion_event)
+
+
+func _parse_virtual_mouse_event(event: InputEventMouse) -> void:
+ Input.parse_input_event(event)
+
+
+func _update_virtual_cursor_position(viewport_position: Vector2) -> void:
+ var output_scale: float = UIReferencePresentationType.get_scale(
+ Vector2(get_window().size)
+ )
+ _controller_virtual_cursor.set_pointer_position(
+ viewport_position / output_scale
+ )
+
+
+func _clamp_virtual_mouse_window_position(
+ window_position: Vector2,
+) -> Vector2:
+ var bounds: Rect2 = get_virtual_mouse_window_bounds(
+ Vector2(get_window().size)
+ )
+ return Vector2(
+ clampf(window_position.x, bounds.position.x, bounds.end.x),
+ clampf(window_position.y, bounds.position.y, bounds.end.y),
+ )
+
+
+static func get_virtual_mouse_window_bounds(window_size: Vector2) -> Rect2:
+ var output_scale: float = UIReferencePresentationType.get_scale(window_size)
+ var cursor_margin: Vector2 = (
+ ControllerVirtualCursorType.CURSOR_SIZE * output_scale * 0.5
+ )
+ var bounds_size: Vector2 = Vector2(
+ maxf(window_size.x - cursor_margin.x * 2.0, 0.0),
+ maxf(window_size.y - cursor_margin.y * 2.0, 0.0),
+ )
+ return Rect2(cursor_margin, bounds_size)
+
+
+func _select_art_kit_hotbar_slot() -> void:
+ if _hotbar == null:
+ return
+ for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
+ if _hotbar.get_item_id(slot_index) == ART_KIT_ITEM_ID:
+ _hotbar.select_slot(slot_index)
+ return
+
+
+func set_surface_drawing_hotbar_selected(is_selected: bool) -> void:
+ _surface_drawing_hotbar_selected = is_selected
+ _refresh_surface_drawing_activation()
+
+
+func _refresh_surface_drawing_activation() -> void:
+ if _surface_drawing == null:
+ return
+ var should_be_active: bool = _can_surface_drawing_be_active()
+ if should_be_active and not _surface_drawing.is_active():
+ _surface_drawing.activate(_drawing_pointer_window_position())
+ elif not should_be_active and _surface_drawing.is_active():
+ _surface_drawing.deactivate()
+
+
+func _can_surface_drawing_be_active() -> bool:
+ return (
+ _surface_drawing_hotbar_selected
+ and _gameplay_ui_enabled
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ and not _chat_input_open
+ and not _showcase_active
+ and _fishing_spot != null
+ and _fishing_spot.can_use_surface_drawing()
+ and _surface_drawing != null
+ and _surface_drawing.can_activate()
+ )
+
+
+func _drawing_pointer_window_position() -> Vector2:
+ if _virtual_mouse_active:
+ return _virtual_mouse_window_position
+ return get_window().get_mouse_position()
+
+
+func setup_data_and_identity(
+ data_root: PlayerDataRoot,
+ identity_backups: IdentityBackupService,
+ player_identity: PlayerIdentityStore,
+ host_identity: HostIdentityStore,
+ network_session: NetworkSession,
+ interface_fonts: InterfaceFontController,
+) -> void:
+ for panel: SettingsPanelType in [
+ _title_settings_panel, _pause_settings_panel
+ ]:
+ panel.setup_data_and_identity(
+ data_root,
+ identity_backups,
+ player_identity,
+ host_identity,
+ network_session,
+ interface_fonts,
+ )
+
+
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+ _player.set_controller_mapping_manager(_controller_mapping_manager)
+ _emote_radial_menu.setup_controller_mapping(_controller_mapping_manager)
+ _quick_radial_menu.setup_controller_mapping(_controller_mapping_manager)
+ _player_menu.setup_controller_mapping(_controller_mapping_manager)
+ for panel: SettingsPanelType in [
+ _title_settings_panel, _pause_settings_panel
+ ]:
+ panel.setup_controller_mapping(_controller_mapping_manager)
+
+
+func is_controller_mapping_capturing() -> bool:
+ return (
+ _title_settings_panel.is_controller_mapping_capturing()
+ or _pause_settings_panel.is_controller_mapping_capturing()
+ )
+
+
+func _process(delta: float) -> void:
+ _poll_virtual_mouse_controller_state()
+ _update_virtual_mouse(delta)
+ _update_controller_menu_scroll(delta)
+ _update_experience_bubble_position()
if _item_effects == null or not _gameplay_ui_enabled:
_effect_status.hide()
return
@@ -136,12 +1230,101 @@ func _process(_delta: float) -> void:
}.get(item_id, "")
parts.append(
"%s %d:%02d"
- % [label, int(remaining) / 60, int(remaining) % 60]
+ % [label, floori(remaining / 60.0), int(remaining) % 60]
)
_effect_status.text = " ".join(parts)
_effect_status.visible = not parts.is_empty()
+func _update_controller_menu_scroll(delta: float) -> void:
+ if (
+ _controller_mapping_manager == null
+ or _virtual_mouse_active
+ or is_controller_mapping_capturing()
+ ):
+ return
+ var stick_y: float = _controller_menu_scroll_axis()
+ var magnitude: float = absf(stick_y)
+ if magnitude <= CONTROLLER_MENU_SCROLL_DEADZONE:
+ return
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ var scroll: ScrollContainer = _focused_scroll_container(focus_owner)
+ if scroll == null:
+ return
+ var adjusted_axis: float = (
+ signf(stick_y)
+ * (magnitude - CONTROLLER_MENU_SCROLL_DEADZONE)
+ / (1.0 - CONTROLLER_MENU_SCROLL_DEADZONE)
+ )
+ scroll.scroll_vertical += roundi(
+ adjusted_axis * CONTROLLER_MENU_SCROLL_SPEED * delta
+ )
+
+
+func _focused_scroll_container(focus_owner: Control) -> ScrollContainer:
+ if focus_owner == null or not focus_owner.is_visible_in_tree():
+ return null
+ var current: Node = focus_owner
+ while current != null:
+ var scroll := current as ScrollContainer
+ if scroll != null:
+ if _controller_scroll_container_is_available(scroll):
+ return scroll
+ return null
+ var candidates: Array[ScrollContainer] = []
+ _collect_controller_scroll_containers(current, candidates)
+ if candidates.size() == 1:
+ return candidates[0]
+ if candidates.size() > 1:
+ return null
+ current = current.get_parent()
+ return null
+
+
+func _collect_controller_scroll_containers(
+ root_node: Node,
+ result: Array[ScrollContainer],
+) -> void:
+ for child: Node in root_node.get_children():
+ var canvas_item := child as CanvasItem
+ if canvas_item != null and not canvas_item.is_visible_in_tree():
+ continue
+ var scroll := child as ScrollContainer
+ if scroll != null and _controller_scroll_container_is_available(scroll):
+ result.append(scroll)
+ _collect_controller_scroll_containers(child, result)
+
+
+func _controller_scroll_container_is_available(
+ scroll: ScrollContainer,
+) -> bool:
+ return (
+ scroll.is_visible_in_tree()
+ and scroll.vertical_scroll_mode != ScrollContainer.SCROLL_MODE_DISABLED
+ )
+
+
+func _controller_menu_scroll_axis() -> float:
+ if _controller_mapping_manager != null:
+ return _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_Y
+ )
+ return Input.get_joy_axis(
+ 0,
+ JOY_AXIS_RIGHT_Y,
+ )
+
+
+func _on_controller_connection_changed(
+ device_id: int,
+ connected: bool,
+) -> void:
+ _virtual_mouse_trigger_rest_by_device.erase(device_id)
+ _shared_trigger_rest_by_device.erase(device_id)
+ if not connected and _virtual_mouse_device_id == device_id:
+ _end_virtual_mouse()
+
+
func close_player_menu() -> void:
_player_menu.close_menu()
@@ -166,26 +1349,185 @@ func get_pause_menu() -> PauseMenuType:
return _pause_menu
+func _apply_active_bait_indicator_style() -> void:
+ var normal_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_normal_style()
+ var hover_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_hover_style()
+ var pressed_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_pressed_style()
+ var disabled_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_disabled_style()
+ for style: StyleBoxFlat in [
+ normal_style,
+ hover_style,
+ pressed_style,
+ disabled_style,
+ ]:
+ style.set_corner_radius_all(36)
+ style.content_margin_left = 13.5
+ style.content_margin_top = 13.5
+ style.content_margin_right = 13.5
+ style.content_margin_bottom = 13.5
+ _active_bait_button.add_theme_stylebox_override("normal", normal_style)
+ _active_bait_button.add_theme_stylebox_override("hover", hover_style)
+ _active_bait_button.add_theme_stylebox_override("focus", hover_style)
+ _active_bait_button.add_theme_stylebox_override("pressed", pressed_style)
+ _active_bait_button.add_theme_stylebox_override("disabled", disabled_style)
+ for state: StringName in [
+ &"icon_normal_color",
+ &"icon_hover_color",
+ &"icon_focus_color",
+ &"icon_pressed_color",
+ ]:
+ _active_bait_button.add_theme_color_override(state, Color.WHITE)
+ var badge_style := StyleBoxFlat.new()
+ badge_style.bg_color = Color("0b5558")
+ badge_style.set_corner_radius_all(12)
+ badge_style.anti_aliasing = false
+ _active_bait_quantity_badge.add_theme_stylebox_override(
+ "panel",
+ badge_style,
+ )
+
+
+func _refresh_active_bait_indicator() -> void:
+ _refresh_active_bait_indicator_visibility()
+ if not is_node_ready():
+ return
+ var item: ItemDataType
+ if (
+ _player != null
+ and _item_catalog != null
+ and not _player.active_bait_id.is_empty()
+ ):
+ item = _item_catalog.get_item_by_id(_player.active_bait_id)
+ var has_active_bait: bool = item != null and item.is_bait()
+ _active_bait_button.icon = (
+ item.icon
+ if has_active_bait and item.icon != null
+ else ACTIVE_BAIT_EMPTY_ICON
+ )
+ _active_bait_quantity_badge.visible = has_active_bait
+ if not has_active_bait:
+ _active_bait_button.tooltip_text = "no bait selected"
+ return
+ var quantity: int = (
+ _bag.get_quantity(item.item_id)
+ if _bag != null
+ else 0
+ )
+ _active_bait_quantity.text = str(quantity)
+ _active_bait_quantity.add_theme_font_size_override(
+ "font_size",
+ 13 if quantity >= 100 else 15,
+ )
+ _active_bait_button.tooltip_text = "%s ×%d" % [
+ item.display_name,
+ quantity,
+ ]
+
+
+func _on_hud_active_bait_changed(_item_id: StringName) -> void:
+ _refresh_active_bait_indicator()
+
+
+func _refresh_active_bait_indicator_visibility() -> void:
+ _active_bait_indicator.visible = (
+ _gameplay_ui_enabled
+ and not _gameplay_hud_hidden
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ )
+
+
+func _on_hud_bait_inventory_changed() -> void:
+ _refresh_active_bait_indicator()
+
+
func set_gameplay_ui_enabled(enabled: bool) -> void:
_gameplay_ui_enabled = enabled
if not enabled:
+ _gameplay_hud_hidden = false
+ _refresh_active_bait_indicator_visibility()
+ if enabled:
+ _try_start_shop_npc_speech()
+ _refresh_gameplay_hud_visibility()
+ _refresh_chat_availability()
+ if not enabled:
+ _end_virtual_mouse()
+ if _emote_radial_menu != null and _emote_radial_menu.is_open():
+ _emote_radial_menu.close_menu()
+ if _player != null:
+ _player.set_camera_input_enabled(
+ _emote_prior_camera_input_enabled
+ )
+ if _quick_radial_menu != null and _quick_radial_menu.is_open():
+ _quick_radial_menu.close_menu()
+ if _player != null:
+ _player.set_camera_input_enabled(
+ _quick_prior_camera_input_enabled
+ )
+ if _surface_drawing != null:
+ _surface_drawing.deactivate()
close_player_menu_for_session_end()
_fishing_shop.close_for_session_end()
_fishing_panel.visible = false
_shop_prompt.hide()
- _hotbar_ui.hide()
+ _hotbar_ui.set_presentation_visible(false, false)
_hotbar_ui.set_gameplay_input_enabled(false)
else:
- _hotbar_ui.show()
+ _refresh_hotbar_visibility()
_hotbar_ui.set_gameplay_input_enabled(true)
_refresh_fishing_panel_visibility()
+ call_deferred("_start_next_experience_animation")
+ _refresh_surface_drawing_activation()
+
+
+func set_gameplay_hud_hidden(hidden: bool) -> void:
+ _gameplay_hud_hidden = hidden
+ if _quick_radial_menu != null:
+ _quick_radial_menu.set_hud_hidden(hidden)
+ _refresh_gameplay_hud_visibility()
+ _refresh_active_bait_indicator_visibility()
+ _refresh_hotbar_visibility()
+ _refresh_chat_availability()
+
+
+func is_gameplay_hud_hidden() -> bool:
+ return _gameplay_hud_hidden
+
+
+func _can_toggle_gameplay_hud() -> bool:
+ return (
+ _gameplay_ui_enabled
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ and not _chat_input_open
+ and not _showcase_active
+ and not _emote_radial_menu.is_open()
+ and not _quick_radial_menu.is_open()
+ )
+
+
+func _refresh_gameplay_hud_visibility() -> void:
+ var show_world_hud: bool = (
+ _gameplay_ui_enabled
+ and not _gameplay_hud_hidden
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ )
+ _gameplay_transient_hud.visible = show_world_hud
+ _experience_presentation.visible = (
+ _gameplay_ui_enabled and not _gameplay_hud_hidden
+ )
func set_system_menu_open(is_open: bool) -> void:
_system_menu_open = is_open
- _hotbar_ui.visible = (
- _gameplay_ui_enabled and not is_open and not _shop_open
- )
+ _refresh_surface_drawing_activation()
+ _refresh_gameplay_hud_visibility()
+ _refresh_chat_availability()
+ _refresh_hotbar_visibility()
_hotbar_ui.set_gameplay_input_enabled(
_gameplay_ui_enabled
and not is_open
@@ -194,21 +1536,158 @@ func set_system_menu_open(is_open: bool) -> void:
)
_hotbar_ui.set_drag_enabled(_player_menu_open and not is_open)
if is_open:
+ _hotbar_ui.set_drag_enabled(false)
_shop_prompt.hide()
+ _emit_interactive_pointer_ui_changed()
func get_fishing_shop() -> FishingShopType:
return _fishing_shop
-func set_shop_prompt_visible(is_visible: bool) -> void:
+func set_shop_prompt_visible(
+ requested_visible: bool,
+ world_anchor: Vector3 = Vector3(0.0, INF, 0.0),
+) -> void:
+ _apply_shop_prompt_style()
_shop_prompt.visible = (
- is_visible
+ requested_visible
and _gameplay_ui_enabled
and not _system_menu_open
and not _player_menu_open
and not _shop_open
)
+ if _shop_prompt.visible:
+ if world_anchor.is_finite():
+ _position_shop_prompt(world_anchor)
+
+
+func set_shop_npc_player_in_range(in_range: bool) -> void:
+ if _shop_npc_player_in_range == in_range:
+ return
+ _shop_npc_player_in_range = in_range
+ if not in_range:
+ _shop_npc_spoken_for_current_visit = false
+ return
+ _try_start_shop_npc_speech()
+
+
+func _try_start_shop_npc_speech() -> void:
+ if (
+ not _shop_npc_player_in_range
+ or _shop_npc_spoken_for_current_visit
+ or not _gameplay_ui_enabled
+ ):
+ return
+ _shop_npc_spoken_for_current_visit = true
+ var now_msec: int = Time.get_ticks_msec()
+ if now_msec < _shop_npc_next_speech_msec:
+ return
+ _shop_npc_next_speech_msec = (
+ now_msec + SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS
+ )
+ _ensure_shop_animalese_voice()
+ TypewriterRevealType.start(
+ _shop_prompt_message,
+ SHOP_SPEECH_CHARACTERS_PER_SECOND,
+ )
+ _shop_animalese_voice.speak_text(
+ _shop_prompt_message,
+ _shop_prompt_message.text,
+ "shopkeeper",
+ SHOP_ANIMALESE_VOICE_ID,
+ SHOP_SPEECH_CHARACTERS_PER_SECOND,
+ )
+
+
+func _ensure_shop_animalese_voice() -> void:
+ if _shop_animalese_voice != null:
+ return
+ _shop_animalese_voice = AnimaleseVoiceType.new()
+ _shop_animalese_voice.name = "ShopAnimaleseVoice"
+ _shop_animalese_voice.base_pitch = SHOP_ANIMALESE_BASE_PITCH
+ add_child(_shop_animalese_voice)
+
+
+func _apply_shop_prompt_style() -> void:
+ if _shop_prompt.has_meta(&"shop_prompt_styled"):
+ return
+ _shop_prompt.set_meta(&"shop_prompt_styled", true)
+ var bubble_style := StyleBoxFlat.new()
+ bubble_style.bg_color = Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.96)
+ bubble_style.set_border_width_all(0)
+ bubble_style.set_corner_radius_all(12)
+ bubble_style.anti_aliasing = false
+ _shop_prompt_bubble.add_theme_stylebox_override("panel", bubble_style)
+ var badge_style := StyleBoxFlat.new()
+ badge_style.bg_color = Color("0b5558")
+ badge_style.set_border_width_all(0)
+ badge_style.set_corner_radius_all(12)
+ badge_style.anti_aliasing = false
+ _shop_prompt_key_badge.add_theme_stylebox_override("panel", badge_style)
+ for label: Label in [_shop_prompt_message, _shop_prompt_key]:
+ label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ label.add_theme_color_override(
+ "font_color",
+ UtilityPageStyle.OCEAN_TEXT_PRIMARY,
+ )
+ _shop_prompt_pointer.color = Color(
+ UtilityPageStyle.OCEAN_PANEL_MID,
+ 0.96,
+ )
+
+
+func _position_shop_prompt(world_anchor: Vector3) -> void:
+ if _player == null:
+ _shop_prompt.hide()
+ return
+ var camera: Camera3D = _player.get_gameplay_camera()
+ if camera == null or camera.is_position_behind(world_anchor):
+ _shop_prompt.hide()
+ return
+ var camera_viewport_size := camera.get_viewport().get_visible_rect().size
+ var ui_viewport_size: Vector2 = _canonical_stage.size
+ if camera_viewport_size.x <= 0.0 or camera_viewport_size.y <= 0.0:
+ _shop_prompt.hide()
+ return
+ var screen_position := (
+ camera.unproject_position(world_anchor)
+ * ui_viewport_size
+ / camera_viewport_size
+ )
+ if (
+ screen_position.x < -80.0
+ or screen_position.x > ui_viewport_size.x + 80.0
+ or screen_position.y < -80.0
+ or screen_position.y > ui_viewport_size.y + 80.0
+ ):
+ _shop_prompt.hide()
+ return
+ var pointer_height := 10.0
+ var desired := screen_position - Vector2(
+ _shop_prompt.size.x * 0.5,
+ _shop_prompt.size.y + pointer_height,
+ )
+ _shop_prompt.position = Vector2(
+ clampf(
+ desired.x,
+ 8.0,
+ ui_viewport_size.x - _shop_prompt.size.x - 8.0,
+ ),
+ clampf(
+ desired.y,
+ 8.0,
+ ui_viewport_size.y - _shop_prompt.size.y - pointer_height - 8.0,
+ ),
+ )
+ _shop_prompt_pointer.position = Vector2(
+ clampf(
+ screen_position.x - _shop_prompt.position.x,
+ 20.0,
+ _shop_prompt.size.x - 20.0,
+ ),
+ _shop_prompt.size.y - 1.0,
+ )
func get_screen_fade() -> ScreenFade:
@@ -219,11 +1698,62 @@ func get_title_screen() -> TitleScreenType:
return _title_screen
+func set_effective_ui_pixel_size(pixel_size: int) -> void:
+ _title_settings_panel.set_effective_ui_pixel_size(pixel_size)
+ _pause_settings_panel.set_effective_ui_pixel_size(pixel_size)
+
+
+func _on_player_settings_changed(settings: PlayerSettings) -> void:
+ if settings != null:
+ _player_menu.set_profile_preview_world_pixel_size(
+ settings.world_pixel_size
+ )
+
+
+func set_edge_docks(
+ chat_dock_right: bool,
+ paint_dock_right: bool,
+ chat_mobile_mode: bool,
+) -> void:
+ _chat_ui.set_dock_right(chat_dock_right)
+ _chat_ui.set_mobile_mode(chat_mobile_mode)
+ _surface_drawing_toolbar.set_dock_right(paint_dock_right)
+
+
+func focus_open_settings_back_button() -> void:
+ if _title_settings_panel.visible:
+ _title_settings_panel.focus_back_button()
+ elif _pause_settings_panel.visible:
+ _pause_settings_panel.focus_back_button()
+
+
+func refresh_open_settings_panel() -> void:
+ _title_settings_panel.refresh_open_panel()
+ _pause_settings_panel.refresh_open_panel()
+
+
+func _on_settings_visibility_changed(_is_visible: bool) -> void:
+ pixelation_settings_visibility_changed.emit(
+ _title_settings_panel.visible or _pause_settings_panel.visible
+ )
+
+
func _on_fishing_status_changed(status: String) -> void:
if _showcase_active:
return
if _fishing_spot != null and _fishing_spot.is_fighting():
return
+ var normalized_status: String = status.strip_edges().to_lower()
+ if normalized_status in [
+ "fishing cancelled.",
+ "fishing cancelled",
+ "fishing failed.",
+ "fishing failed",
+ "fishing attempt ended.",
+ "fishing attempt ended",
+ ]:
+ _set_fishing_status("")
+ return
_set_fishing_status(status)
@@ -238,9 +1768,7 @@ func _refresh_fishing_panel_visibility() -> void:
var has_content: bool = (
not _status_label.text.strip_edges().is_empty()
or not _showcase_details.text.strip_edges().is_empty()
- or _catch_track.visible
- or _barrier_summary.visible
- or _barrier_health.visible
+ or _bite_prompt_button.visible
)
_fishing_panel.visible = (
_gameplay_ui_enabled
@@ -248,17 +1776,27 @@ func _refresh_fishing_panel_visibility() -> void:
)
+func _on_bite_prompt_changed(prompt_visible: bool) -> void:
+ _bite_prompt_button.visible = prompt_visible
+ _refresh_fishing_panel_visibility()
+
+
+func _on_bite_prompt_pressed() -> void:
+ if _fishing_spot != null:
+ _fishing_spot.confirm_pending_bite()
+
+
func _on_catch_display_changed(
progress: float,
chase_progress: float,
barrier_positions: PackedFloat32Array,
barrier_health: PackedInt32Array,
- barrier_max_health: PackedInt32Array,
+ _barrier_max_health: PackedInt32Array,
active_barrier_index: int,
- visible: bool,
+ encounter_requested_visible: bool,
) -> void:
var encounter_visible: bool = (
- visible
+ encounter_requested_visible
and _fishing_spot != null
and _fishing_spot.is_fighting()
)
@@ -266,12 +1804,13 @@ func _on_catch_display_changed(
_green_catch_progress.value = progress * 100.0
_red_chase_progress.value = maxf(chase_progress, 0.0) * 100.0
_catch_track.visible = encounter_visible
- _barrier_summary.visible = encounter_visible
- _barrier_health.visible = encounter_visible
+ _barrier_prompt_panel.visible = false
+ _barrier_prompt.visible = false
+ _barrier_health.visible = false
if not encounter_visible:
_barrier_health.visible = false
+ _barrier_prompt.visible = false
_clear_barrier_markers()
- _barrier_summary.text = ""
_barrier_health.text = ""
_refresh_fishing_panel_visibility()
return
@@ -283,45 +1822,18 @@ func _on_catch_display_changed(
barrier_health,
active_barrier_index
)
- var barrier_labels: PackedStringArray = []
- for barrier_index: int in range(barrier_positions.size()):
- var defeated: bool = (
- barrier_index < barrier_health.size()
- and barrier_health[barrier_index] <= 0
- )
- var marker: String = "✓" if defeated else "●"
- if barrier_index == active_barrier_index:
- marker = "[%s]" % marker
- barrier_labels.append(
- "%d%% %s"
- % [roundi(barrier_positions[barrier_index] * 100.0), marker]
- )
- _barrier_summary.text = "barriers: %s" % " ".join(barrier_labels)
-
if (
active_barrier_index >= 0
and active_barrier_index < barrier_health.size()
- and active_barrier_index < barrier_max_health.size()
):
- _barrier_health.text = (
- "barrier: %d / %d"
- % [
- barrier_health[active_barrier_index],
- barrier_max_health[active_barrier_index],
- ]
- )
+ _barrier_prompt_panel.visible = true
+ _barrier_prompt.visible = true
+ _barrier_health.visible = true
+ _barrier_health.text = "%d" % barrier_health[active_barrier_index]
else:
- _barrier_health.text = "hold left click to reel"
-
- var chase_gap: float = progress - chase_progress
- if chase_gap <= 0.05:
- _barrier_health.text = (
- "%s%s"
- % [
- _barrier_health.text,
- "\nred is closing in!",
- ]
- ).strip_edges()
+ _barrier_prompt.visible = false
+ _barrier_health.visible = false
+ _barrier_health.text = ""
_refresh_fishing_panel_visibility()
@@ -340,7 +1852,7 @@ func _update_barrier_markers(
marker.color = (
UIPalette.DISABLED
if defeated
- else UIPalette.SECONDARY
+ else UIPalette.PRIMARY
)
if barrier_index == active_index:
marker.color = UIPalette.TEXT
@@ -364,42 +1876,312 @@ func _on_showcase_changed(
fish_name: String,
rarity_name: String,
weight_lb: float,
- visible: bool,
+ quality: int,
+ showcase_visible: bool,
) -> void:
- _showcase_active = visible
- if not visible:
+ _showcase_active = showcase_visible
+ _refresh_surface_drawing_activation()
+ if not showcase_visible:
+ _set_fishing_panel_showcase_position(false)
_showcase_details.text = ""
_showcase_details.visible = false
_set_fishing_status("")
+ call_deferred("_start_next_experience_animation")
return
+ _set_fishing_panel_showcase_position(true)
_catch_track.visible = false
- _barrier_summary.visible = false
+ _barrier_prompt_panel.visible = false
+ _barrier_prompt.visible = false
_barrier_health.visible = false
_clear_barrier_markers()
_showcase_details.text = (
- "%.1f lb • %s\nleft click or escape to put away"
- % [weight_lb, rarity_name]
+ "%s • %.1f lb"
+ % [rarity_name.to_lower(), weight_lb]
)
_showcase_details.visible = true
- _set_fishing_status("you caught a %s!" % fish_name)
+ _status_label.text = "You caught %s!" % (
+ FishQualityType.qualified_name_with_article(fish_name, quality)
+ )
+ _status_label.visible = true
+ _refresh_fishing_panel_visibility()
+
+
+func _set_fishing_panel_showcase_position(showcase: bool) -> void:
+ _fishing_panel.offset_top = (
+ FISHING_PANEL_SHOWCASE_TOP_OFFSET
+ if showcase
+ else FISHING_PANEL_DEFAULT_TOP_OFFSET
+ )
+ _fishing_panel.offset_bottom = (
+ FISHING_PANEL_SHOWCASE_BOTTOM_OFFSET
+ if showcase
+ else FISHING_PANEL_DEFAULT_BOTTOM_OFFSET
+ )
+
+
+func _on_experience_awarded(
+ amount: int,
+ previous_total: int,
+ new_total: int,
+ previous_level: int,
+ new_level: int,
+) -> void:
+ if amount <= 0:
+ return
+ _experience_award_queue.append({
+ "amount": amount,
+ "previous_total": previous_total,
+ "new_total": new_total,
+ "previous_level": previous_level,
+ "new_level": new_level,
+ })
+ _start_next_experience_animation()
+
+
+func _start_next_experience_animation() -> void:
+ if (
+ _experience_animation_active
+ or _showcase_active
+ or _experience_award_queue.is_empty()
+ or not _gameplay_ui_enabled
+ ):
+ return
+ var award: Dictionary = _experience_award_queue.pop_front()
+ _experience_animation_active = true
+ _experience_animation_generation += 1
+ var generation: int = _experience_animation_generation
+ _play_experience_animation(award, generation)
+
+
+func _play_experience_animation(
+ award: Dictionary,
+ generation: int,
+) -> void:
+ var amount: int = int(award.get("amount", 0))
+ var previous_total: int = int(award.get("previous_total", 0))
+ var new_total: int = int(award.get("new_total", previous_total))
+ _experience_award_label.text = "+%d xp" % amount
+ _experience_bubble_label.text = "+%d xp!" % amount
+ _update_experience_progress(previous_total)
+ _experience_panel.position.y = -_experience_panel.size.y - 8.0
+ _experience_panel.modulate.a = 0.0
+ _experience_panel.show()
+ _experience_bubble.modulate.a = 0.0
+ _experience_bubble.scale = Vector2(0.72, 0.72)
+ _experience_bubble.pivot_offset = _experience_bubble.size * 0.5
+ _experience_bubble.show()
+
+ var entry_tween: Tween = create_tween()
+ entry_tween.set_parallel(true)
+ entry_tween.tween_property(
+ _experience_panel,
+ "position:y",
+ _experience_panel_rest_y,
+ 0.26,
+ ).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
+ entry_tween.tween_property(
+ _experience_panel,
+ "modulate:a",
+ 1.0,
+ 0.18,
+ )
+ entry_tween.tween_property(
+ _experience_bubble,
+ "modulate:a",
+ 1.0,
+ 0.15,
+ )
+ entry_tween.tween_property(
+ _experience_bubble,
+ "scale",
+ Vector2.ONE,
+ 0.28,
+ ).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
+ await entry_tween.finished
+ if generation != _experience_animation_generation:
+ return
+
+ var fill_duration: float = clampf(
+ 0.8 + float(amount) * 0.006,
+ 0.9,
+ 1.65,
+ )
+ var fill_tween: Tween = create_tween()
+ fill_tween.tween_method(
+ Callable(self, "_set_experience_animation_progress").bind(
+ previous_total,
+ new_total,
+ ),
+ 0.0,
+ 1.0,
+ fill_duration,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ await fill_tween.finished
+ if generation != _experience_animation_generation:
+ return
+ await get_tree().create_timer(0.75).timeout
+ if generation != _experience_animation_generation:
+ return
+
+ var exit_tween: Tween = create_tween()
+ exit_tween.set_parallel(true)
+ exit_tween.tween_property(
+ _experience_panel,
+ "modulate:a",
+ 0.0,
+ 0.24,
+ )
+ exit_tween.tween_property(
+ _experience_bubble,
+ "modulate:a",
+ 0.0,
+ 0.2,
+ )
+ exit_tween.tween_property(
+ _experience_bubble,
+ "scale",
+ Vector2(0.82, 0.82),
+ 0.24,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ await exit_tween.finished
+ if generation != _experience_animation_generation:
+ return
+ _experience_panel.hide()
+ _experience_bubble.hide()
+ _experience_animation_active = false
+ call_deferred("_start_next_experience_animation")
+
+
+func _set_experience_animation_progress(
+ progress: float,
+ previous_total: int,
+ new_total: int,
+) -> void:
+ var displayed_total: int = roundi(lerpf(
+ float(previous_total),
+ float(new_total),
+ clampf(progress, 0.0, 1.0),
+ ))
+ _update_experience_progress(displayed_total)
+
+
+func _update_experience_progress(total_experience: int) -> void:
+ var level: int = PlayerExperienceType.level_for_total_experience(
+ total_experience
+ )
+ _experience_level_label.text = "level %d" % level
+ _experience_progress.value = (
+ PlayerExperienceType.progress_for_total_experience(total_experience)
+ * 100.0
+ )
+
+
+func _update_experience_bubble_position() -> void:
+ if not _experience_animation_active or _player == null:
+ return
+ if _player_menu_open:
+ _experience_bubble.show()
+ _experience_bubble.position = Vector2(
+ (_canonical_stage.size.x - _experience_bubble.size.x) * 0.5,
+ _experience_panel_rest_y + _experience_panel.size.y + 10.0,
+ )
+ return
+ var camera: Camera3D = _player.get_gameplay_camera()
+ var anchor_position: Vector3 = _player.get_chat_anchor_position()
+ if camera == null or camera.is_position_behind(anchor_position):
+ _experience_bubble.hide()
+ return
+ _experience_bubble.show()
+ var window_size := Vector2(get_window().size)
+ var output_scale: float = UIReferencePresentationType.get_scale(
+ window_size
+ )
+ var stage_position: Vector2 = (
+ camera.unproject_position(anchor_position) / output_scale
+ - _canonical_stage.position
+ )
+ var desired: Vector2 = stage_position - Vector2(
+ _experience_bubble.size.x * 0.5,
+ _experience_bubble.size.y + 10.0,
+ )
+ _experience_bubble.position = Vector2(
+ clampf(
+ desired.x,
+ 8.0,
+ _canonical_stage.size.x - _experience_bubble.size.x - 8.0,
+ ),
+ clampf(
+ desired.y,
+ 8.0,
+ _canonical_stage.size.y - _experience_bubble.size.y - 8.0,
+ ),
+ )
func _on_player_menu_visibility_changed(is_open: bool) -> void:
_player_menu_open = is_open
+ if is_open:
+ _end_virtual_mouse()
+ _refresh_surface_drawing_activation()
+ _refresh_gameplay_hud_visibility()
+ if is_open:
+ _hotbar_ui.set_drag_enabled(false)
+ _hotbar_ui.set_presentation_visible(false, false)
+ if _player_menu_hotbar_visible:
+ _hotbar_ui.set_player_menu_context(true)
+ _hotbar_ui.set_presentation_visible(
+ true,
+ true,
+ UIMotion.PLAYER_MENU_ENTER_DURATION,
+ )
+ player_menu_backdrop_visibility_changed.emit(true)
+ else:
+ _player_menu_hotbar_visible = false
+ _hotbar_ui.set_presentation_visible(false, false)
+ _hotbar_ui.set_player_menu_context(false)
+ _refresh_chat_availability()
_hotbar_ui.set_gameplay_input_enabled(
_gameplay_ui_enabled and not is_open
)
- _hotbar_ui.set_drag_enabled(is_open)
+ if not is_open:
+ _hotbar_ui.set_drag_enabled(false)
+ _refresh_hotbar_visibility()
_refresh_fishing_panel_visibility()
+ _emit_interactive_pointer_ui_changed()
if is_open:
_shop_prompt.hide()
+ else:
+ _refresh_fishing_panel_visibility()
+
+
+func _on_player_menu_exit_started() -> void:
+ if not _player_menu_open:
+ return
+ _hotbar_ui.set_drag_enabled(false)
+ _hotbar_ui.set_presentation_visible(
+ false,
+ true,
+ UIMotion.PLAYER_MENU_EXIT_DURATION,
+ )
+ player_menu_backdrop_visibility_changed.emit(false)
+
+
+func _on_shop_exit_started() -> void:
+ if not _shop_open:
+ return
+ shop_backdrop_visibility_changed.emit(false)
func _on_shop_visibility_changed(is_open: bool) -> void:
_shop_open = is_open
- _hotbar_ui.visible = (
- _gameplay_ui_enabled and not is_open and not _system_menu_open
- )
+ _refresh_surface_drawing_activation()
+ if is_open:
+ shop_backdrop_visibility_changed.emit(true)
+ if not is_open and _player_menu.is_shop_cooler_mounted():
+ _player_menu.unmount_shop_cooler()
+ _refresh_gameplay_hud_visibility()
+ _refresh_chat_availability()
+ _refresh_hotbar_visibility()
_hotbar_ui.set_gameplay_input_enabled(
_gameplay_ui_enabled
and not is_open
@@ -408,3 +2190,156 @@ func _on_shop_visibility_changed(is_open: bool) -> void:
)
if is_open:
_shop_prompt.hide()
+ _emit_interactive_pointer_ui_changed()
+
+
+func _on_shop_sell_fish_requested() -> void:
+ if (
+ not _fishing_shop.visible
+ or _main_shop_buyer == null
+ or not _main_shop_buyer.is_valid()
+ or _shop_interaction == null
+ or not _shop_interaction.is_local_player_in_range()
+ or _fishing_spot == null
+ or not _fishing_spot.is_ready_for_shop_transaction()
+ ):
+ return
+ if _player_menu.mount_shop_cooler(
+ _fishing_shop.get_shop_cooler_mount(),
+ _main_shop_buyer,
+ ):
+ _fishing_shop.activate_shop_cooler_page()
+
+
+func _on_shop_cooler_return_requested() -> void:
+ _player_menu.unmount_shop_cooler()
+ _fishing_shop.deactivate_shop_cooler_page()
+
+
+func _on_shop_cooler_modal_changed(is_open: bool) -> void:
+ _fishing_shop.set_shop_cooler_modal_open(is_open)
+
+
+func _on_inventory_hotbar_context_changed(show_hotbar: bool) -> void:
+ var context_changed: bool = _player_menu_hotbar_visible != show_hotbar
+ _player_menu_hotbar_visible = show_hotbar
+ if _player_menu_open and context_changed:
+ _hotbar_ui.set_drag_enabled(false)
+ if show_hotbar:
+ _hotbar_ui.set_presentation_visible(false, false)
+ _hotbar_ui.set_player_menu_context(true)
+ _hotbar_ui.set_presentation_visible(
+ true,
+ true,
+ UIMotion.PLAYER_MENU_INVENTORY_DURATION,
+ )
+ else:
+ _hotbar_ui.set_presentation_visible(
+ false,
+ true,
+ UIMotion.PLAYER_MENU_INVENTORY_DURATION,
+ )
+ elif _player_menu_open and show_hotbar:
+ _hotbar_ui.set_drag_enabled(true)
+ if not context_changed:
+ return
+ if not _player_menu_open:
+ _refresh_hotbar_visibility()
+
+
+func _on_controller_hotbar_placement_requested(
+ assignment_kind: PlayerHotbarType.AssignmentKind,
+ identity: StringName,
+ initial_slot: int,
+) -> void:
+ _player_menu_hotbar_visible = true
+ _hotbar_ui.set_player_menu_context(true)
+ _hotbar_ui.set_drag_enabled(false)
+ _hotbar_ui.set_presentation_visible(true, false)
+ _hotbar_ui.begin_controller_placement(
+ assignment_kind,
+ identity,
+ initial_slot,
+ )
+
+
+func _on_controller_hotbar_placement_ended() -> void:
+ _hotbar_ui.end_controller_placement()
+ _hotbar_ui.set_drag_enabled(
+ _player_menu_open
+ and _player_menu_hotbar_visible
+ and not _system_menu_open
+ )
+
+
+func _on_controller_hotbar_management_requested(initial_slot: int) -> void:
+ _player_menu_hotbar_visible = true
+ _hotbar_ui.set_player_menu_context(true)
+ _hotbar_ui.set_drag_enabled(false)
+ _hotbar_ui.set_presentation_visible(true, false)
+ _hotbar_ui.begin_controller_management(initial_slot)
+
+
+func _on_controller_hotbar_management_ended() -> void:
+ _hotbar_ui.end_controller_management()
+ _hotbar_ui.set_drag_enabled(
+ _player_menu_open
+ and _player_menu_hotbar_visible
+ and not _system_menu_open
+ )
+
+
+func _on_hotbar_presentation_transition_finished(
+ presentation_visible: bool,
+) -> void:
+ _hotbar_ui.set_drag_enabled(
+ presentation_visible
+ and _player_menu_open
+ and _player_menu_hotbar_visible
+ and not _system_menu_open
+ )
+
+
+func _refresh_hotbar_visibility() -> void:
+ _hotbar_ui.set_presentation_visible(
+ _gameplay_ui_enabled
+ and (
+ not _gameplay_hud_hidden
+ or (_player_menu_open and _player_menu_hotbar_visible)
+ )
+ and not _system_menu_open
+ and not _shop_open
+ and (
+ not _player_menu_open
+ or _player_menu_hotbar_visible
+ ),
+ _player_menu_open,
+ )
+
+
+func _emit_interactive_pointer_ui_changed() -> void:
+ _refresh_active_bait_indicator_visibility()
+ interactive_pointer_ui_changed.emit(
+ _system_menu_open or _player_menu_open or _shop_open or _chat_input_open
+ )
+
+
+func _on_chat_text_entry_ownership_changed(active: bool) -> void:
+ _chat_input_open = active
+ _refresh_surface_drawing_activation()
+ _refresh_chat_availability()
+ _emit_interactive_pointer_ui_changed()
+
+
+func _refresh_chat_availability() -> void:
+ if _chat_ui == null:
+ return
+ var chat_available := (
+ _gameplay_ui_enabled
+ and not _system_menu_open
+ and not _player_menu_open
+ and not _shop_open
+ )
+ _chat_ui.set_available(chat_available)
+ _chat_ui.set_hud_hidden(_gameplay_hud_hidden and not _chat_input_open)
+ passive_pointer_ui_changed.emit(chat_available)
diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn
index 18b7864..1cbbc4a 100644
--- a/ui/game_ui.tscn
+++ b/ui/game_ui.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=13 format=3]
+[gd_scene load_steps=21 format=3]
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"]
@@ -8,35 +8,242 @@
[ext_resource type="PackedScene" path="res://ui/pause_menu.tscn" id="6_pause"]
[ext_resource type="PackedScene" path="res://ui/hotbar.tscn" id="7_hotbar"]
[ext_resource type="PackedScene" path="res://ui/fishing_shop.tscn" id="8_shop"]
+[ext_resource type="Script" path="res://ui/chat_ui.gd" id="9_chat"]
+[ext_resource type="PackedScene" path="res://ui/emote_radial_menu.tscn" id="10_emote"]
+[ext_resource type="PackedScene" path="res://ui/surface_drawing_toolbar.tscn" id="11_drawing_toolbar"]
+[ext_resource type="PackedScene" path="res://ui/quick_radial_menu.tscn" id="12_quick"]
+[ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"]
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
-bg_color = Color(0.055, 0.105, 0.125, 1)
-corner_radius_top_left = 6
-corner_radius_top_right = 6
-corner_radius_bottom_right = 6
-corner_radius_bottom_left = 6
+bg_color = Color(0.032, 0.118, 0.15, 1)
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_fill"]
-bg_color = Color(0.937, 0.357, 0.384, 0.95)
-corner_radius_top_left = 6
-corner_radius_top_right = 6
-corner_radius_bottom_right = 6
-corner_radius_bottom_left = 6
+bg_color = Color(0.071, 0.247, 0.306, 0.96)
[sub_resource type="StyleBoxFlat" id="StyleBox_catch_fill"]
-bg_color = Color(0.275, 0.784, 0.471, 0.92)
-corner_radius_top_left = 6
-corner_radius_top_right = 6
-corner_radius_bottom_right = 6
-corner_radius_bottom_left = 6
+bg_color = Color(0.208, 0.725, 0.78, 0.94)
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_fishing_panel"]
+bg_color = Color(0.051, 0.173, 0.227, 0.96)
+border_width_left = 0
+border_width_top = 0
+border_width_right = 0
+border_width_bottom = 0
+corner_radius_top_left = 12
+corner_radius_top_right = 12
+corner_radius_bottom_right = 12
+corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBox_transparent"]
bg_color = Color(0, 0, 0, 0)
+[sub_resource type="StyleBoxFlat" id="StyleBox_experience_panel"]
+bg_color = Color(0.051, 0.173, 0.227, 0.97)
+corner_radius_top_left = 14
+corner_radius_top_right = 14
+corner_radius_bottom_right = 14
+corner_radius_bottom_left = 14
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_experience_background"]
+bg_color = Color(0.025, 0.102, 0.137, 1)
+corner_radius_top_left = 7
+corner_radius_top_right = 7
+corner_radius_bottom_right = 7
+corner_radius_bottom_left = 7
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_experience_fill"]
+bg_color = Color(1, 0.82, 0.4, 1)
+corner_radius_top_left = 7
+corner_radius_top_right = 7
+corner_radius_bottom_right = 7
+corner_radius_bottom_left = 7
+
[node name="GameUI" type="CanvasLayer"]
script = ExtResource("1_ui")
-[node name="FishingPanel" type="PanelContainer" parent="."]
+[node name="UIRoot" type="Control" parent="."]
+unique_name_in_owner = true
+mouse_filter = 2
+
+[node name="CanonicalStage" type="Control" parent="UIRoot"]
+unique_name_in_owner = true
+mouse_filter = 2
+
+[node name="SurfaceDrawingToolbar" parent="UIRoot" instance=ExtResource("11_drawing_toolbar")]
+unique_name_in_owner = true
+
+[node name="GameplayTransientHUD" type="Control" parent="UIRoot/CanonicalStage"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="ActiveBaitIndicator" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
+unique_name_in_owner = true
+z_index = 54
+layout_mode = 1
+anchors_preset = 3
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = -88.0
+offset_top = -88.0
+offset_right = -16.0
+offset_bottom = -16.0
+grow_horizontal = 0
+grow_vertical = 0
+mouse_filter = 2
+theme = ExtResource("3_theme")
+
+[node name="ActiveBaitButton" type="Button" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ActiveBaitIndicator"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+focus_mode = 0
+texture_filter = 1
+expand_icon = true
+alignment = 1
+icon_alignment = 1
+vertical_icon_alignment = 1
+
+[node name="ActiveBaitQuantityBadge" type="Panel" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ActiveBaitIndicator"]
+unique_name_in_owner = true
+z_index = 2
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -27.0
+offset_top = 3.0
+offset_right = -3.0
+offset_bottom = 27.0
+grow_horizontal = 0
+mouse_filter = 2
+
+[node name="ActiveBaitQuantity" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ActiveBaitIndicator/ActiveBaitQuantityBadge"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 1.0
+offset_right = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.905882, 0.960784, 0.956863, 1)
+theme_override_font_sizes/font_size = 15
+text = "0"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="ExperiencePresentation" type="Control" parent="UIRoot/CanonicalStage"]
+unique_name_in_owner = true
+z_index = 200
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="ExperienceProgressPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation"]
+unique_name_in_owner = true
+visible = false
+z_index = 63
+anchors_preset = 10
+anchor_left = 0.5
+anchor_right = 0.5
+offset_left = -240.0
+offset_top = 18.0
+offset_right = 240.0
+offset_bottom = 82.0
+grow_horizontal = 2
+mouse_filter = 2
+theme = ExtResource("3_theme")
+theme_override_styles/panel = SubResource("StyleBox_experience_panel")
+
+[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 14
+theme_override_constants/margin_top = 7
+theme_override_constants/margin_right = 14
+theme_override_constants/margin_bottom = 9
+
+[node name="Layout" type="VBoxContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="Header" type="HBoxContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout"]
+layout_mode = 2
+
+[node name="ExperienceLevelLabel" type="Label" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout/Header"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "level 1"
+theme_override_colors/font_color = Color(0.95, 0.98, 1, 1)
+theme_override_font_sizes/font_size = 16
+
+[node name="Spacer" type="Control" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout/Header"]
+layout_mode = 2
+size_flags_horizontal = 3
+mouse_filter = 2
+
+[node name="ExperienceAwardLabel" type="Label" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout/Header"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "+0 xp"
+theme_override_colors/font_color = Color(1, 0.82, 0.4, 1)
+theme_override_font_sizes/font_size = 16
+
+[node name="ExperienceProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceProgressPanel/Margin/Layout"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 20)
+layout_mode = 2
+mouse_filter = 2
+theme_override_styles/background = SubResource("StyleBox_experience_background")
+theme_override_styles/fill = SubResource("StyleBox_experience_fill")
+value = 0.0
+show_percentage = false
+
+[node name="ExperienceBubble" type="PanelContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation"]
+unique_name_in_owner = true
+visible = false
+z_index = 63
+offset_right = 122.0
+offset_bottom = 46.0
+mouse_filter = 2
+theme = ExtResource("3_theme")
+theme_override_styles/panel = SubResource("StyleBox_experience_panel")
+
+[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceBubble"]
+layout_mode = 2
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_top = 5
+theme_override_constants/margin_right = 10
+theme_override_constants/margin_bottom = 5
+
+[node name="ExperienceBubbleLabel" type="Label" parent="UIRoot/CanonicalStage/ExperiencePresentation/ExperienceBubble/Margin"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "+0 xp!"
+horizontal_alignment = 1
+vertical_alignment = 1
+theme_override_colors/font_color = Color(1, 0.82, 0.4, 1)
+theme_override_font_sizes/font_size = 22
+
+[node name="FishingPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
z_index = 60
@@ -45,42 +252,67 @@ anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
-offset_left = -205.0
-offset_top = -216.0
-offset_right = 205.0
-offset_bottom = -128.0
+offset_left = -190.0
+offset_top = -210.0
+offset_right = 190.0
+offset_bottom = -140.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
theme = ExtResource("3_theme")
+theme_override_styles/panel = SubResource("StyleBox_fishing_panel")
-[node name="MarginContainer" type="MarginContainer" parent="FishingPanel"]
-theme_override_constants/margin_left = 14
-theme_override_constants/margin_top = 8
-theme_override_constants/margin_right = 14
-theme_override_constants/margin_bottom = 8
+[node name="MarginContainer" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/FishingPanel"]
+theme_override_constants/margin_left = 12
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 12
+theme_override_constants/margin_bottom = 4
-[node name="VBoxContainer" type="VBoxContainer" parent="FishingPanel/MarginContainer"]
-theme_override_constants/separation = 4
+[node name="VBoxContainer" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/FishingPanel/MarginContainer"]
+alignment = 1
+theme_override_constants/separation = 2
-[node name="StatusLabel" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
+[node name="StatusLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
text = ""
horizontal_alignment = 1
theme_override_font_sizes/font_size = 16
-[node name="ShowcaseDetails" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
+[node name="BitePromptButton" type="Button" parent="UIRoot/CanonicalStage/GameplayTransientHUD/FishingPanel/MarginContainer/VBoxContainer"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(0, 42)
+layout_mode = 2
+focus_mode = 0
+mouse_default_cursor_shape = 2
+text = "you got a bite!"
+theme_override_font_sizes/font_size = 22
+
+[node name="ShowcaseDetails" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
horizontal_alignment = 1
-[node name="CatchTrack" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer"]
+[node name="CatchTrack" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
-custom_minimum_size = Vector2(0, 20)
+z_index = 60
+layout_mode = 1
+anchors_preset = 7
+anchor_left = 0.5
+anchor_top = 1.0
+anchor_right = 0.5
+anchor_bottom = 1.0
+offset_left = -420.0
+offset_top = -160.0
+offset_right = 420.0
+offset_bottom = -124.0
+grow_horizontal = 2
+grow_vertical = 0
+mouse_filter = 2
-[node name="GreenCatchProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"]
+[node name="GreenCatchProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/GameplayTransientHUD/CatchTrack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
@@ -94,7 +326,7 @@ theme_override_styles/fill = SubResource("StyleBox_catch_fill")
value = 0.0
show_percentage = false
-[node name="RedChaseProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"]
+[node name="RedChaseProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/GameplayTransientHUD/CatchTrack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
@@ -108,7 +340,7 @@ theme_override_styles/fill = SubResource("StyleBox_chase_fill")
value = 0.0
show_percentage = false
-[node name="BarrierMarkers" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"]
+[node name="BarrierMarkers" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD/CatchTrack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
@@ -118,56 +350,130 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
-[node name="BarrierSummary" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
+[node name="BarrierPromptPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
-horizontal_alignment = 1
-
-[node name="BarrierHealth" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
-unique_name_in_owner = true
-visible = false
-horizontal_alignment = 1
-
-[node name="PlayerMenu" parent="." instance=ExtResource("2_menu")]
-unique_name_in_owner = true
-
-[node name="Hotbar" parent="." instance=ExtResource("7_hotbar")]
-unique_name_in_owner = true
-
-[node name="ShopPrompt" type="PanelContainer" parent="."]
-unique_name_in_owner = true
-visible = false
-z_index = 55
+z_index = 61
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
-offset_left = -150.0
-offset_top = -284.0
-offset_right = 150.0
-offset_bottom = -246.0
+offset_left = -170.0
+offset_top = -280.0
+offset_right = 170.0
+offset_bottom = -172.0
grow_horizontal = 2
grow_vertical = 0
+pivot_offset = Vector2(170, 55)
+rotation = 0.025
+mouse_filter = 2
+theme = ExtResource("3_theme")
+theme_override_styles/panel = SubResource("StyleBox_fishing_panel")
+
+[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/BarrierPromptPanel"]
+theme_override_constants/margin_left = 18
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 18
+theme_override_constants/margin_bottom = 4
+
+[node name="Layout" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/BarrierPromptPanel/Margin"]
+theme_override_constants/separation = 0
+
+[node name="BarrierPrompt" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/BarrierPromptPanel/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+text = "MASH!"
+horizontal_alignment = 1
+theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
+theme_override_font_sizes/font_size = 63
+
+[node name="BarrierHealth" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/BarrierPromptPanel/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+text = "0"
+horizontal_alignment = 1
+theme_override_colors/font_color = Color(0.95, 0.98, 1, 1)
+theme_override_font_sizes/font_size = 34
+
+[node name="PlayerMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("2_menu")]
+unique_name_in_owner = true
+
+[node name="Hotbar" parent="UIRoot/CanonicalStage" instance=ExtResource("7_hotbar")]
+unique_name_in_owner = true
+
+[node name="EmoteRadialMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("10_emote")]
+unique_name_in_owner = true
+
+[node name="QuickRadialMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("12_quick")]
+unique_name_in_owner = true
+
+[node name="ShopPrompt" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
+unique_name_in_owner = true
+visible = false
+z_index = 55
+offset_right = 300.0
+offset_bottom = 72.0
mouse_filter = 2
theme = ExtResource("3_theme")
-[node name="Margin" type="MarginContainer" parent="ShopPrompt"]
+[node name="ShopPromptBubble" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ShopPrompt"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ShopPrompt/ShopPromptBubble"]
layout_mode = 2
theme_override_constants/margin_left = 12
-theme_override_constants/margin_top = 7
+theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
-theme_override_constants/margin_bottom = 7
+theme_override_constants/margin_bottom = 8
-[node name="Label" type="Label" parent="ShopPrompt/Margin"]
+[node name="ShopPromptMessage" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ShopPrompt/ShopPromptBubble/Margin"]
+unique_name_in_owner = true
layout_mode = 2
-text = "press e to open fishing shop"
+theme_override_font_sizes/font_size = 18
+text = "Hey, if you've got any fish I've got cash! OwO"
horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
-[node name="FishingShop" parent="." instance=ExtResource("8_shop")]
+[node name="ShopPromptKeyBadge" type="Panel" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ShopPrompt"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 282.0
+offset_top = -8.0
+offset_right = 306.0
+offset_bottom = 16.0
+mouse_filter = 2
+
+[node name="ShopPromptKey" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ShopPrompt/ShopPromptKeyBadge"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_font_sizes/font_size = 15
+text = "E"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="ShopPromptPointer" type="Polygon2D" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ShopPrompt"]
+unique_name_in_owner = true
+polygon = PackedVector2Array(-8, 0, 8, 0, 0, 10)
+
+[node name="FishingShop" parent="UIRoot/CanonicalStage" instance=ExtResource("8_shop")]
unique_name_in_owner = true
-[node name="EffectStatus" type="Label" parent="."]
+[node name="EffectStatus" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
z_index = 54
@@ -181,7 +487,24 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
-[node name="ScreenFade" type="ColorRect" parent="."]
+[node name="ChatUI" type="Control" parent="UIRoot"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("9_chat")
+
+[node name="ControllerVirtualCursor" type="Control" parent="UIRoot"]
+unique_name_in_owner = true
+z_index = 500
+mouse_filter = 2
+script = ExtResource("13_cursor")
+
+[node name="ScreenFade" type="ColorRect" parent="UIRoot"]
unique_name_in_owner = true
visible = false
z_index = 100
@@ -194,8 +517,8 @@ mouse_filter = 0
color = Color(0, 0, 0, 1)
script = ExtResource("4_fade")
-[node name="TitleScreen" parent="." instance=ExtResource("5_title")]
+[node name="TitleScreen" parent="UIRoot" instance=ExtResource("5_title")]
unique_name_in_owner = true
-[node name="PauseMenu" parent="." instance=ExtResource("6_pause")]
+[node name="PauseMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("6_pause")]
unique_name_in_owner = true
diff --git a/ui/hotbar.gd b/ui/hotbar.gd
index af6ade1..fe26cc6 100644
--- a/ui/hotbar.gd
+++ b/ui/hotbar.gd
@@ -1,29 +1,69 @@
class_name HotbarUI
extends Control
+signal presentation_transition_finished(is_visible: bool)
+
const ItemCatalogType = preload("res://items/item_catalog.gd")
+const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
-const HotbarSlotType = preload("res://ui/hotbar_slot.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const FishCatchType = preload("res://fish/fish_catch.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const BubbleHotbarSlotType = preload(
+ "res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd"
+)
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
-@onready var _slot_row: HBoxContainer = %SlotRow
+const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0)
+const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0)
+const HOTBAR_PRESENTATION_SCALE: float = 0.80
+const HOTBAR_CANONICAL_POSITION := Vector2.ZERO
+const HOTBAR_MENU_POSITION := Vector2(-136.0, -90.0)
+const HOTBAR_GAMEPLAY_Z_INDEX: int = 35
+# PlayerMenu is z=30 and its authored inventory panels are relative z=50.
+const HOTBAR_MENU_Z_INDEX: int = 90
+
+@onready var _presentation_scale_root: Control = %HotbarPresentationScaleRoot
+@onready var _bubble_field: Control = %BubbleField
@onready var _selected_item_label: Label = %SelectedItemLabel
@onready var _item_name_timer: Timer = %ItemNameTimer
var _hotbar: PlayerHotbarType
var _bag: PlayerBagType
var _catalog: ItemCatalogType
+var _fish_inventory: FishInventoryType
var _fishing_spot: FishingSpotType
-var _slots: Array[HotbarSlotType] = []
+var _slots: Array[BubbleHotbarSlotType] = []
var _gameplay_input_enabled: bool = false
var _drag_enabled: bool = false
var _hovered_slot_index: int = -1
var _item_name_suppressed: bool = false
+var _motion_elapsed: float = 0.0
+var _compact_layout: bool = false
+var _player_menu_context: bool = false
+var _controller_placement_active: bool = false
+var _controller_management_active: bool = false
+var _controller_placement_kind: PlayerHotbarType.AssignmentKind = (
+ PlayerHotbarType.AssignmentKind.EMPTY
+)
+var _controller_placement_identity: StringName
+var _controller_placement_texture: Texture2D
+var _visibility_tween: Tween
+var _visibility_generation: int = 0
func _ready() -> void:
_item_name_timer.timeout.connect(_on_item_name_timer_timeout)
+ resized.connect(_apply_layout)
+ _collect_slots()
+ _apply_layout()
+
+
+func _process(delta: float) -> void:
+ _motion_elapsed += delta
+ for slot: BubbleHotbarSlotType in _slots:
+ slot.advance_presentation(delta, _motion_elapsed)
func setup(
@@ -31,11 +71,13 @@ func setup(
bag: PlayerBagType,
catalog: ItemCatalogType,
fishing_spot: FishingSpotType,
+ fish_inventory: FishInventoryType,
) -> void:
_hotbar = hotbar
_bag = bag
_catalog = catalog
_fishing_spot = fishing_spot
+ _fish_inventory = fish_inventory
if not _hotbar.slots_changed.is_connected(_refresh):
_hotbar.slots_changed.connect(_refresh)
if not _hotbar.selected_slot_changed.is_connected(
@@ -44,7 +86,11 @@ func setup(
_hotbar.selected_slot_changed.connect(_on_selected_slot_changed)
if not _bag.contents_changed.is_connected(_refresh):
_bag.contents_changed.connect(_refresh)
- _build_slots()
+ if not _fish_inventory.catches_changed.is_connected(_refresh):
+ _fish_inventory.catches_changed.connect(_refresh)
+ for slot: BubbleHotbarSlotType in _slots:
+ slot.setup(_hotbar, _bag, _catalog, _fish_inventory)
+ slot.set_drag_enabled(_drag_enabled)
_refresh()
@@ -54,13 +100,193 @@ func set_gameplay_input_enabled(enabled: bool) -> void:
func set_drag_enabled(enabled: bool) -> void:
_drag_enabled = enabled
- for slot: HotbarSlotType in _slots:
+ for slot: BubbleHotbarSlotType in _slots:
slot.set_drag_enabled(enabled)
if not enabled:
_hovered_slot_index = -1
_hide_item_name()
+func begin_controller_placement(
+ assignment_kind: PlayerHotbarType.AssignmentKind,
+ identity: StringName,
+ initial_slot: int,
+) -> void:
+ if _hotbar == null or identity.is_empty():
+ return
+ _controller_placement_active = true
+ _controller_placement_kind = assignment_kind
+ _controller_placement_identity = identity
+ _controller_placement_texture = _resolve_controller_placement_texture()
+ var slot_count: int = _slots.size()
+ for index: int in slot_count:
+ var slot: BubbleHotbarSlotType = _slots[index]
+ slot.focus_mode = Control.FOCUS_ALL
+ slot.focus_neighbor_left = slot.get_path_to(
+ _slots[wrapi(index - 1, 0, slot_count)]
+ )
+ slot.focus_neighbor_right = slot.get_path_to(
+ _slots[wrapi(index + 1, 0, slot_count)]
+ )
+ slot.focus_neighbor_top = slot.get_path_to(slot)
+ slot.focus_neighbor_bottom = slot.get_path_to(slot)
+ var target_index: int = clampi(initial_slot, 0, slot_count - 1)
+ _hotbar.select_slot(target_index)
+ _refresh_controller_placement_preview()
+ _slots[target_index].call_deferred("grab_focus")
+
+
+func end_controller_placement() -> void:
+ if not _controller_placement_active:
+ return
+ _controller_placement_active = false
+ _controller_placement_kind = PlayerHotbarType.AssignmentKind.EMPTY
+ _controller_placement_identity = StringName()
+ _controller_placement_texture = null
+ for slot: BubbleHotbarSlotType in _slots:
+ slot.focus_mode = Control.FOCUS_NONE
+ slot.set_controller_placement_preview(false, null)
+ _show_selected_item_briefly()
+
+
+func begin_controller_management(initial_slot: int) -> void:
+ if _hotbar == null or _slots.is_empty():
+ return
+ _controller_management_active = true
+ var slot_count: int = _slots.size()
+ for index: int in slot_count:
+ var slot: BubbleHotbarSlotType = _slots[index]
+ slot.focus_mode = Control.FOCUS_ALL
+ slot.focus_neighbor_left = slot.get_path_to(
+ _slots[wrapi(index - 1, 0, slot_count)]
+ )
+ slot.focus_neighbor_right = slot.get_path_to(
+ _slots[wrapi(index + 1, 0, slot_count)]
+ )
+ slot.focus_neighbor_top = slot.get_path_to(slot)
+ slot.focus_neighbor_bottom = slot.get_path_to(slot)
+ var target_index: int = clampi(initial_slot, 0, slot_count - 1)
+ _hotbar.select_slot(target_index)
+ _slots[target_index].call_deferred("grab_focus")
+
+
+func end_controller_management() -> void:
+ if not _controller_management_active:
+ return
+ _controller_management_active = false
+ for slot: BubbleHotbarSlotType in _slots:
+ slot.focus_mode = Control.FOCUS_NONE
+ _show_selected_item_briefly()
+
+
+func _resolve_controller_placement_texture() -> Texture2D:
+ if (
+ _controller_placement_kind == PlayerHotbarType.AssignmentKind.FISH
+ and _fish_inventory != null
+ ):
+ var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(
+ _controller_placement_identity
+ )
+ return fish_catch.fish.display_texture if fish_catch != null else null
+ if (
+ _controller_placement_kind == PlayerHotbarType.AssignmentKind.ITEM
+ and _catalog != null
+ ):
+ var item: ItemDataType = _catalog.get_item_by_id(
+ _controller_placement_identity
+ )
+ return item.icon if item != null else null
+ return null
+
+
+func _refresh_controller_placement_preview() -> void:
+ if not _controller_placement_active or _hotbar == null:
+ return
+ var target_index: int = _hotbar.get_selected_slot()
+ for slot: BubbleHotbarSlotType in _slots:
+ slot.set_controller_placement_preview(
+ slot.slot_index == target_index,
+ _controller_placement_texture,
+ )
+
+
+func set_player_menu_context(enabled: bool) -> void:
+ if _player_menu_context == enabled:
+ return
+ _player_menu_context = enabled
+ z_index = HOTBAR_MENU_Z_INDEX if enabled else HOTBAR_GAMEPLAY_Z_INDEX
+ _presentation_scale_root.position = _presentation_position()
+
+
+func set_presentation_visible(
+ should_show: bool,
+ animate: bool = true,
+ transition_duration: float = -1.0,
+) -> void:
+ _visibility_generation += 1
+ var generation: int = _visibility_generation
+ if _visibility_tween != null:
+ _visibility_tween.kill()
+ _visibility_tween = null
+ if should_show:
+ var was_visible: bool = visible
+ visible = true
+ if not animate:
+ modulate.a = 1.0
+ presentation_transition_finished.emit(true)
+ return
+ if not was_visible:
+ modulate.a = 0.0
+ var duration: float = (
+ transition_duration
+ if transition_duration >= 0.0
+ else UIMotion.UTILITY_ENTER_DURATION
+ )
+ _visibility_tween = create_tween()
+ _visibility_tween.tween_property(
+ self,
+ "modulate:a",
+ 1.0,
+ duration,
+ )
+ _visibility_tween.finished.connect(
+ func() -> void:
+ if generation != _visibility_generation:
+ return
+ _visibility_tween = null
+ presentation_transition_finished.emit(true)
+ )
+ else:
+ if not visible:
+ return
+ if not animate:
+ visible = false
+ modulate.a = 1.0
+ presentation_transition_finished.emit(false)
+ return
+ var duration: float = (
+ transition_duration
+ if transition_duration >= 0.0
+ else UIMotion.UTILITY_EXIT_DURATION
+ )
+ _visibility_tween = create_tween()
+ _visibility_tween.tween_property(
+ self,
+ "modulate:a",
+ 0.0,
+ duration,
+ )
+ _visibility_tween.finished.connect(
+ func() -> void:
+ if generation != _visibility_generation:
+ return
+ visible = false
+ modulate.a = 1.0
+ _visibility_tween = null
+ presentation_transition_finished.emit(false)
+ )
+
+
func set_item_name_suppressed(suppressed: bool) -> void:
_item_name_suppressed = suppressed
if suppressed:
@@ -76,6 +302,14 @@ func _unhandled_input(event: InputEvent) -> void:
or not _fishing_spot.can_change_hotbar_selection()
):
return
+ if event.is_action_pressed("hotbar_previous"):
+ _hotbar.cycle_selection(-1)
+ get_viewport().set_input_as_handled()
+ return
+ if event.is_action_pressed("hotbar_next"):
+ _hotbar.cycle_selection(1)
+ get_viewport().set_input_as_handled()
+ return
if event is InputEventKey and event.pressed and not event.echo:
for index: int in range(PlayerHotbarType.SLOT_COUNT):
if event.is_action_pressed("hotbar_%d" % (index + 1)):
@@ -95,25 +329,70 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
-func _build_slots() -> void:
- for child: Node in _slot_row.get_children():
- child.queue_free()
+func _collect_slots() -> void:
_slots.clear()
- for index: int in range(PlayerHotbarType.SLOT_COUNT):
- var slot := HotbarSlotType.new()
- slot.toggle_mode = true
- slot.setup(index, _hotbar, _bag, _catalog)
- slot.set_drag_enabled(_drag_enabled)
+ for child: Node in _bubble_field.get_children():
+ var slot := child as BubbleHotbarSlotType
+ if slot == null:
+ continue
slot.item_hovered.connect(_on_slot_item_hovered)
slot.item_hover_ended.connect(_on_slot_item_hover_ended)
slot.item_drag_started.connect(_on_slot_drag_started)
slot.item_drag_finished.connect(_on_slot_drag_finished)
- _slot_row.add_child(slot)
+ slot.focus_entered.connect(
+ _on_controller_slot_focused.bind(slot.slot_index)
+ )
_slots.append(slot)
+ _slots.sort_custom(
+ func(
+ left: BubbleHotbarSlotType,
+ right: BubbleHotbarSlotType,
+ ) -> bool:
+ return left.slot_index < right.slot_index
+ )
+
+
+func _apply_layout() -> void:
+ if not is_node_ready():
+ return
+ _compact_layout = false
+ var reference_size := DESKTOP_REFERENCE_SIZE
+ _presentation_scale_root.size = reference_size
+ _presentation_scale_root.scale = (
+ Vector2.ONE * HOTBAR_PRESENTATION_SCALE
+ )
+ _presentation_scale_root.position = _presentation_position()
+ var field_size := (
+ Vector2(580.0, 82.0)
+ if _compact_layout
+ else Vector2(790.0, 104.0)
+ )
+ _bubble_field.size = field_size
+ _bubble_field.position = Vector2(
+ (reference_size.x - field_size.x) * 0.5,
+ reference_size.y - 8.0 - field_size.y
+ )
+ for slot: BubbleHotbarSlotType in _slots:
+ slot.apply_layout(_compact_layout)
+
+
+func _presentation_position() -> Vector2:
+ var scaled_size: Vector2 = (
+ DESKTOP_REFERENCE_SIZE * HOTBAR_PRESENTATION_SCALE
+ )
+ var bottom_centered_offset := Vector2(
+ (DESKTOP_REFERENCE_SIZE.x - scaled_size.x) * 0.5,
+ DESKTOP_REFERENCE_SIZE.y - scaled_size.y,
+ )
+ return bottom_centered_offset + (
+ HOTBAR_MENU_POSITION
+ if _player_menu_context
+ else HOTBAR_CANONICAL_POSITION
+ )
func _refresh() -> void:
- for slot: HotbarSlotType in _slots:
+ for slot: BubbleHotbarSlotType in _slots:
slot.refresh()
@@ -122,10 +401,24 @@ func _on_selected_slot_changed(
_item_id: StringName,
) -> void:
_refresh()
+ if _controller_placement_active:
+ _refresh_controller_placement_preview()
+ return
if _hovered_slot_index < 0:
_show_selected_item_briefly()
+func _on_controller_slot_focused(slot_index: int) -> void:
+ if (
+ not (_controller_placement_active or _controller_management_active)
+ or _hotbar == null
+ ):
+ return
+ _hotbar.select_slot(slot_index)
+ if _controller_placement_active:
+ _refresh_controller_placement_preview()
+
+
func _on_slot_item_hovered(
slot_index: int,
item_id: StringName,
@@ -134,7 +427,7 @@ func _on_slot_item_hovered(
return
_hovered_slot_index = slot_index
_item_name_timer.stop()
- _show_item_name(item_id)
+ _show_assignment_name(slot_index, item_id)
func _on_slot_item_hover_ended(slot_index: int) -> void:
@@ -157,16 +450,34 @@ func _show_selected_item_briefly() -> void:
if _item_name_suppressed or _hotbar == null:
_hide_item_name()
return
- _show_item_name(_hotbar.get_selected_item_id())
+ var selected_slot: int = _hotbar.get_selected_slot()
+ var identity: StringName = _hotbar.get_selected_item_id()
+ if identity.is_empty():
+ identity = _hotbar.get_selected_fish_catch_id()
+ _show_assignment_name(selected_slot, identity)
if _selected_item_label.visible:
_item_name_timer.start()
-func _show_item_name(item_id: StringName) -> void:
- if item_id.is_empty() or _catalog == null:
+func _show_assignment_name(slot_index: int, identity: StringName) -> void:
+ if identity.is_empty() or _hotbar == null:
_hide_item_name()
return
- var item := _catalog.get_item_by_id(item_id)
+ var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index)
+ if not catch_id.is_empty() and _fish_inventory != null:
+ var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id)
+ if fish_catch != null:
+ _selected_item_label.text = FishQualityType.qualified_name(
+ fish_catch.fish.display_name,
+ fish_catch.quality,
+ )
+ _selected_item_label.visible = true
+ return
+ var item = (
+ _catalog.get_item_by_id(identity)
+ if _catalog != null
+ else null
+ )
if item == null:
_hide_item_name()
return
diff --git a/ui/hotbar.tscn b/ui/hotbar.tscn
index 7b73bc1..ebe0099 100644
--- a/ui/hotbar.tscn
+++ b/ui/hotbar.tscn
@@ -1,25 +1,24 @@
-[gd_scene load_steps=3 format=3]
+[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://ui/hotbar.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_hotbar/bubble_hotbar_slot.tscn" id="3_slot"]
[node name="Hotbar" type="Control"]
unique_name_in_owner = true
visible = false
-z_index = 20
+z_index = 35
layout_mode = 3
-anchors_preset = 12
+anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
-offset_top = -84.0
-offset_bottom = -8.0
grow_horizontal = 2
-grow_vertical = 0
+grow_vertical = 2
mouse_filter = 2
theme = ExtResource("2_theme")
script = ExtResource("1_script")
-[node name="Center" type="CenterContainer" parent="."]
+[node name="ResponsiveHotbarStage" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -28,36 +27,100 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
-[node name="Panel" type="PanelContainer" parent="Center"]
-layout_mode = 2
-mouse_filter = 2
-
-[node name="Margin" type="MarginContainer" parent="Center/Panel"]
-layout_mode = 2
-mouse_filter = 2
-theme_override_constants/margin_left = 5
-theme_override_constants/margin_top = 5
-theme_override_constants/margin_right = 5
-theme_override_constants/margin_bottom = 5
-
-[node name="SlotRow" type="HBoxContainer" parent="Center/Panel/Margin"]
+[node name="HotbarPresentationScaleRoot" type="Control" parent="ResponsiveHotbarStage"]
unique_name_in_owner = true
-layout_mode = 2
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
mouse_filter = 2
-theme_override_constants/separation = 4
-[node name="SelectedItemLabel" type="Label" parent="."]
+[node name="BubbleField" type="Control" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 245.0
+offset_top = 608.0
+offset_right = 1035.0
+offset_bottom = 712.0
+mouse_filter = 2
+
+[node name="Slot1" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 0
+desktop_anchor = Vector2(55, 49)
+compact_anchor = Vector2(50, 42)
+motion_phase = 0.15
+
+[node name="Slot2" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 1
+desktop_anchor = Vector2(140, 53)
+compact_anchor = Vector2(110, 46)
+motion_phase = 0.82
+
+[node name="Slot3" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 2
+desktop_anchor = Vector2(225, 57)
+compact_anchor = Vector2(170, 50)
+motion_phase = 1.49
+
+[node name="Slot4" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 3
+desktop_anchor = Vector2(310, 60)
+compact_anchor = Vector2(230, 53)
+motion_phase = 2.16
+
+[node name="Slot5" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 4
+desktop_anchor = Vector2(395, 62)
+compact_anchor = Vector2(290, 55)
+motion_phase = 2.83
+
+[node name="Slot6" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 5
+desktop_anchor = Vector2(480, 60)
+compact_anchor = Vector2(350, 53)
+motion_phase = 3.5
+
+[node name="Slot7" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 6
+desktop_anchor = Vector2(565, 57)
+compact_anchor = Vector2(410, 50)
+motion_phase = 4.17
+
+[node name="Slot8" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 7
+desktop_anchor = Vector2(650, 53)
+compact_anchor = Vector2(470, 46)
+motion_phase = 4.84
+
+[node name="Slot9" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot/BubbleField" instance=ExtResource("3_slot")]
+layout_mode = 0
+slot_index = 8
+desktop_anchor = Vector2(735, 49)
+compact_anchor = Vector2(530, 42)
+motion_phase = 5.51
+
+[node name="SelectedItemLabel" type="Label" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot"]
unique_name_in_owner = true
visible = false
layout_mode = 1
-anchors_preset = 10
+anchors_preset = 12
anchor_left = 0.5
+anchor_top = 1.0
anchor_right = 0.5
+anchor_bottom = 1.0
offset_left = -150.0
-offset_top = -28.0
+offset_top = -132.0
offset_right = 150.0
-offset_bottom = -8.0
+offset_bottom = -112.0
grow_horizontal = 2
+grow_vertical = 0
mouse_filter = 2
clip_text = true
text_overrun_behavior = 3
diff --git a/ui/hotbar_slot.gd b/ui/hotbar_slot.gd
deleted file mode 100644
index f531312..0000000
--- a/ui/hotbar_slot.gd
+++ /dev/null
@@ -1,187 +0,0 @@
-class_name HotbarSlot
-extends Button
-
-signal item_hovered(slot_index: int, item_id: StringName)
-signal item_hover_ended(slot_index: int)
-signal item_drag_started
-signal item_drag_finished
-
-const ItemCatalogType = preload("res://items/item_catalog.gd")
-const ItemDataType = preload("res://items/item_data.gd")
-const PlayerBagType = preload("res://inventory/player_bag.gd")
-const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
-
-var slot_index: int = 0
-var _hotbar: PlayerHotbarType
-var _bag: PlayerBagType
-var _catalog: ItemCatalogType
-var _drag_enabled: bool = false
-var _drag_in_progress: bool = false
-var _slot_number_label: Label
-var _quantity_label: Label
-
-
-func setup(
- index: int,
- hotbar: PlayerHotbarType,
- bag: PlayerBagType,
- catalog: ItemCatalogType,
-) -> void:
- slot_index = index
- _hotbar = hotbar
- _bag = bag
- _catalog = catalog
- custom_minimum_size = Vector2(50, 50)
- expand_icon = true
- focus_mode = Control.FOCUS_ALL
- _slot_number_label = _create_corner_label(str(slot_index + 1))
- _quantity_label = _create_corner_label("", true)
- pressed.connect(_select_slot)
- mouse_entered.connect(_on_mouse_entered)
- mouse_exited.connect(_on_mouse_exited)
- refresh()
-
-
-func set_drag_enabled(enabled: bool) -> void:
- _drag_enabled = enabled
- mouse_filter = (
- Control.MOUSE_FILTER_STOP
- if enabled
- else Control.MOUSE_FILTER_IGNORE
- )
-
-
-func refresh() -> void:
- if _hotbar == null:
- return
- var item_id: StringName = _hotbar.get_item_id(slot_index)
- var item: ItemDataType = (
- _catalog.get_item_by_id(item_id)
- if _catalog != null and not item_id.is_empty()
- else null
- )
- icon = item.icon if item != null else null
- var quantity: int = (
- _bag.get_quantity(item_id)
- if _bag != null and not item_id.is_empty()
- else 0
- )
- var quantity_text: String = (
- "×%d" % quantity
- if item != null and item.stackable and quantity > 1
- else ""
- )
- text = ""
- _quantity_label.text = quantity_text
- _quantity_label.visible = not quantity_text.is_empty()
- tooltip_text = (
- item.display_name if item != null else "empty hotbar slot"
- )
- button_pressed = slot_index == _hotbar.get_selected_slot()
-
-
-func _create_corner_label(
- label_text: String,
- bottom_right: bool = false,
-) -> Label:
- var label := Label.new()
- label.text = label_text
- label.mouse_filter = Control.MOUSE_FILTER_IGNORE
- label.add_theme_font_size_override("font_size", 11)
- label.add_theme_color_override("font_color", UIPalette.TEXT)
- if bottom_right:
- label.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
- label.position = Vector2(-22.0, -20.0)
- else:
- label.position = Vector2(5.0, 2.0)
- add_child(label)
- return label
-
-
-func _gui_input(event: InputEvent) -> void:
- if (
- _drag_enabled
- and event is InputEventMouseButton
- and event.button_index == MOUSE_BUTTON_RIGHT
- and event.pressed
- and _hotbar != null
- ):
- _hotbar.clear_slot(slot_index)
- accept_event()
-
-
-func _get_drag_data(_at_position: Vector2) -> Variant:
- if not _drag_enabled or _hotbar == null:
- return null
- var item_id: StringName = _hotbar.get_item_id(slot_index)
- if item_id.is_empty():
- return null
- var item: ItemDataType = _catalog.get_item_by_id(item_id)
- var preview := TextureRect.new()
- preview.custom_minimum_size = Vector2(44, 44)
- preview.texture = item.icon if item != null else null
- preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
- preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
- preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
- set_drag_preview(preview)
- _drag_in_progress = true
- item_drag_started.emit()
- return {
- "kind": "hotbar_slot",
- "slot_index": slot_index,
- "item_id": String(item_id),
- }
-
-
-func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
- if not _drag_enabled or typeof(data) != TYPE_DICTIONARY:
- return false
- var payload: Dictionary = data
- var kind: String = str(payload.get("kind", ""))
- if kind == "hotbar_slot":
- var source_index: int = int(payload.get("slot_index", -1))
- return (
- source_index >= 0
- and source_index < PlayerHotbarType.SLOT_COUNT
- and source_index != slot_index
- )
- if kind != "bag_item":
- return false
- var item_id: StringName = StringName(str(payload.get("item_id", "")))
- if _bag == null or not _bag.owns_item(item_id) or _catalog == null:
- return false
- var item: ItemDataType = _catalog.get_item_by_id(item_id)
- return item != null and item.is_valid() and item.hotbar_allowed
-
-
-func _drop_data(_at_position: Vector2, data: Variant) -> void:
- if not _can_drop_data(Vector2.ZERO, data) or _hotbar == null:
- return
- var payload: Dictionary = data
- if str(payload.get("kind", "")) == "hotbar_slot":
- _hotbar.swap_slots(int(payload["slot_index"]), slot_index)
- else:
- _hotbar.assign_item(
- slot_index,
- StringName(str(payload["item_id"]))
- )
-
-
-func _select_slot() -> void:
- if _hotbar != null:
- _hotbar.select_slot(slot_index)
-
-
-func _on_mouse_entered() -> void:
- if _hotbar != null:
- item_hovered.emit(slot_index, _hotbar.get_item_id(slot_index))
-
-
-func _on_mouse_exited() -> void:
- item_hover_ended.emit(slot_index)
-
-
-func _notification(what: int) -> void:
- if what == NOTIFICATION_DRAG_END and _drag_in_progress:
- _drag_in_progress = false
- item_drag_finished.emit()
diff --git a/ui/hotbar_slot.gd.uid b/ui/hotbar_slot.gd.uid
deleted file mode 100644
index dd28680..0000000
--- a/ui/hotbar_slot.gd.uid
+++ /dev/null
@@ -1 +0,0 @@
-uid://swcob5q48f8e
diff --git a/ui/icons/pictograms/arrow_dark_down_full.png b/ui/icons/pictograms/arrow_dark_down_full.png
new file mode 100644
index 0000000..e2bdb9e
Binary files /dev/null and b/ui/icons/pictograms/arrow_dark_down_full.png differ
diff --git a/ui/icons/pictograms/arrow_dark_down_full.png.import b/ui/icons/pictograms/arrow_dark_down_full.png.import
new file mode 100644
index 0000000..2e7726a
--- /dev/null
+++ b/ui/icons/pictograms/arrow_dark_down_full.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d4aah3j5brkij"
+path="res://.godot/imported/arrow_dark_down_full.png-0e7bb59c41a81c0bd02261d6a7b319ef.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/arrow_dark_down_full.png"
+dest_files=["res://.godot/imported/arrow_dark_down_full.png-0e7bb59c41a81c0bd02261d6a7b319ef.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/arrow_dark_up_full.png b/ui/icons/pictograms/arrow_dark_up_full.png
new file mode 100644
index 0000000..a9d2f3d
Binary files /dev/null and b/ui/icons/pictograms/arrow_dark_up_full.png differ
diff --git a/ui/icons/pictograms/arrow_dark_up_full.png.import b/ui/icons/pictograms/arrow_dark_up_full.png.import
new file mode 100644
index 0000000..1cbc9ac
--- /dev/null
+++ b/ui/icons/pictograms/arrow_dark_up_full.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d34x31jx5p5ll"
+path="res://.godot/imported/arrow_dark_up_full.png-907f3a8653cf6bf8b6ba95ad31b18e0d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/arrow_dark_up_full.png"
+dest_files=["res://.godot/imported/arrow_dark_up_full.png-907f3a8653cf6bf8b6ba95ad31b18e0d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/arrow_light_down_more.png b/ui/icons/pictograms/arrow_light_down_more.png
new file mode 100644
index 0000000..3c7683d
Binary files /dev/null and b/ui/icons/pictograms/arrow_light_down_more.png differ
diff --git a/ui/icons/pictograms/arrow_light_down_more.png.import b/ui/icons/pictograms/arrow_light_down_more.png.import
new file mode 100644
index 0000000..47a516b
--- /dev/null
+++ b/ui/icons/pictograms/arrow_light_down_more.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmc7sguttrl2o"
+path="res://.godot/imported/arrow_light_down_more.png-a02fb5d09f2022507ddb6c06e3f22732.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/arrow_light_down_more.png"
+dest_files=["res://.godot/imported/arrow_light_down_more.png-a02fb5d09f2022507ddb6c06e3f22732.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/arrow_light_left_more.png b/ui/icons/pictograms/arrow_light_left_more.png
new file mode 100644
index 0000000..361adab
Binary files /dev/null and b/ui/icons/pictograms/arrow_light_left_more.png differ
diff --git a/ui/icons/pictograms/arrow_light_left_more.png.import b/ui/icons/pictograms/arrow_light_left_more.png.import
new file mode 100644
index 0000000..07d5a52
--- /dev/null
+++ b/ui/icons/pictograms/arrow_light_left_more.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b3lx64prsccyk"
+path="res://.godot/imported/arrow_light_left_more.png-a92f7c96dbcafd9c1b2cb612e94fef44.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/arrow_light_left_more.png"
+dest_files=["res://.godot/imported/arrow_light_left_more.png-a92f7c96dbcafd9c1b2cb612e94fef44.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/arrow_light_right_more.png b/ui/icons/pictograms/arrow_light_right_more.png
new file mode 100644
index 0000000..39d6148
Binary files /dev/null and b/ui/icons/pictograms/arrow_light_right_more.png differ
diff --git a/ui/icons/pictograms/arrow_light_right_more.png.import b/ui/icons/pictograms/arrow_light_right_more.png.import
new file mode 100644
index 0000000..e0a72ff
--- /dev/null
+++ b/ui/icons/pictograms/arrow_light_right_more.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cedstrt57et53"
+path="res://.godot/imported/arrow_light_right_more.png-56d3df49848f9fee268e5c4b6c279828.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/arrow_light_right_more.png"
+dest_files=["res://.godot/imported/arrow_light_right_more.png-56d3df49848f9fee268e5c4b6c279828.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/arrow_light_up_more.png b/ui/icons/pictograms/arrow_light_up_more.png
new file mode 100644
index 0000000..03e3817
Binary files /dev/null and b/ui/icons/pictograms/arrow_light_up_more.png differ
diff --git a/ui/icons/pictograms/arrow_light_up_more.png.import b/ui/icons/pictograms/arrow_light_up_more.png.import
new file mode 100644
index 0000000..60f266f
--- /dev/null
+++ b/ui/icons/pictograms/arrow_light_up_more.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dqu71oklbvofk"
+path="res://.godot/imported/arrow_light_up_more.png-7e8472cc9f410835ab24173229e09c9b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/arrow_light_up_more.png"
+dest_files=["res://.godot/imported/arrow_light_up_more.png-7e8472cc9f410835ab24173229e09c9b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/check_mark_dark.png b/ui/icons/pictograms/check_mark_dark.png
new file mode 100644
index 0000000..f1c398b
Binary files /dev/null and b/ui/icons/pictograms/check_mark_dark.png differ
diff --git a/ui/icons/pictograms/check_mark_dark.png.import b/ui/icons/pictograms/check_mark_dark.png.import
new file mode 100644
index 0000000..d2fa9e9
--- /dev/null
+++ b/ui/icons/pictograms/check_mark_dark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://3x5tryb721th"
+path="res://.godot/imported/check_mark_dark.png-d9abda15f33a6a8f8c29c5f148cda086.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/check_mark_dark.png"
+dest_files=["res://.godot/imported/check_mark_dark.png-d9abda15f33a6a8f8c29c5f148cda086.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/check_mark_light.png b/ui/icons/pictograms/check_mark_light.png
new file mode 100644
index 0000000..da0af7c
Binary files /dev/null and b/ui/icons/pictograms/check_mark_light.png differ
diff --git a/ui/icons/pictograms/check_mark_light.png.import b/ui/icons/pictograms/check_mark_light.png.import
new file mode 100644
index 0000000..c3af542
--- /dev/null
+++ b/ui/icons/pictograms/check_mark_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dtibvrik7ug0j"
+path="res://.godot/imported/check_mark_light.png-83417416d6580b7b5eb41987b7af5ccf.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/check_mark_light.png"
+dest_files=["res://.godot/imported/check_mark_light.png-83417416d6580b7b5eb41987b7af5ccf.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/online_dark.png b/ui/icons/pictograms/online_dark.png
new file mode 100644
index 0000000..1c643ca
Binary files /dev/null and b/ui/icons/pictograms/online_dark.png differ
diff --git a/ui/icons/pictograms/online_dark.png.import b/ui/icons/pictograms/online_dark.png.import
new file mode 100644
index 0000000..f917f4c
--- /dev/null
+++ b/ui/icons/pictograms/online_dark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://uxkgiwd7py03"
+path="res://.godot/imported/online_dark.png-081123db8a385b31ed626b7c95b20c3c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/online_dark.png"
+dest_files=["res://.godot/imported/online_dark.png-081123db8a385b31ed626b7c95b20c3c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/save_dark.png b/ui/icons/pictograms/save_dark.png
new file mode 100644
index 0000000..382648b
Binary files /dev/null and b/ui/icons/pictograms/save_dark.png differ
diff --git a/ui/icons/pictograms/save_dark.png.import b/ui/icons/pictograms/save_dark.png.import
new file mode 100644
index 0000000..33bbad8
--- /dev/null
+++ b/ui/icons/pictograms/save_dark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b71gds1q8ny61"
+path="res://.godot/imported/save_dark.png-6dbadbfe5b8057ede23f77629e3c1226.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/save_dark.png"
+dest_files=["res://.godot/imported/save_dark.png-6dbadbfe5b8057ede23f77629e3c1226.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/settings_dark.png b/ui/icons/pictograms/settings_dark.png
new file mode 100644
index 0000000..3777b39
Binary files /dev/null and b/ui/icons/pictograms/settings_dark.png differ
diff --git a/ui/icons/pictograms/settings_dark.png.import b/ui/icons/pictograms/settings_dark.png.import
new file mode 100644
index 0000000..fabd6c0
--- /dev/null
+++ b/ui/icons/pictograms/settings_dark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ul28xl5qed55"
+path="res://.godot/imported/settings_dark.png-7196550e6c3f5a58c1402ab0ccc9c9d0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/settings_dark.png"
+dest_files=["res://.godot/imported/settings_dark.png-7196550e6c3f5a58c1402ab0ccc9c9d0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/undo_dark.png b/ui/icons/pictograms/undo_dark.png
new file mode 100644
index 0000000..e26b579
Binary files /dev/null and b/ui/icons/pictograms/undo_dark.png differ
diff --git a/ui/icons/pictograms/undo_dark.png.import b/ui/icons/pictograms/undo_dark.png.import
new file mode 100644
index 0000000..e1cce7e
--- /dev/null
+++ b/ui/icons/pictograms/undo_dark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://deux1h722j33u"
+path="res://.godot/imported/undo_dark.png-652c08d6b38e19b70b66927ecfc6222c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/undo_dark.png"
+dest_files=["res://.godot/imported/undo_dark.png-652c08d6b38e19b70b66927ecfc6222c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/undo_light.png b/ui/icons/pictograms/undo_light.png
new file mode 100644
index 0000000..aa04205
Binary files /dev/null and b/ui/icons/pictograms/undo_light.png differ
diff --git a/ui/icons/pictograms/undo_light.png.import b/ui/icons/pictograms/undo_light.png.import
new file mode 100644
index 0000000..161faa8
--- /dev/null
+++ b/ui/icons/pictograms/undo_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://1qr5t7gx1eag"
+path="res://.godot/imported/undo_light.png-83dd161fb13faa93326dee6cb01e205c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/undo_light.png"
+dest_files=["res://.godot/imported/undo_light.png-83dd161fb13faa93326dee6cb01e205c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/x_dark.png b/ui/icons/pictograms/x_dark.png
new file mode 100644
index 0000000..d4d183d
Binary files /dev/null and b/ui/icons/pictograms/x_dark.png differ
diff --git a/ui/icons/pictograms/x_dark.png.import b/ui/icons/pictograms/x_dark.png.import
new file mode 100644
index 0000000..36797cb
--- /dev/null
+++ b/ui/icons/pictograms/x_dark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cg8vi6sxnpl2t"
+path="res://.godot/imported/x_dark.png-5430cf57b7772f059ee1fac9842c6898.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/x_dark.png"
+dest_files=["res://.godot/imported/x_dark.png-5430cf57b7772f059ee1fac9842c6898.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/pictograms/x_light.png b/ui/icons/pictograms/x_light.png
new file mode 100644
index 0000000..68b3180
Binary files /dev/null and b/ui/icons/pictograms/x_light.png differ
diff --git a/ui/icons/pictograms/x_light.png.import b/ui/icons/pictograms/x_light.png.import
new file mode 100644
index 0000000..36b30e7
--- /dev/null
+++ b/ui/icons/pictograms/x_light.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cvb228ypykqaq"
+path="res://.godot/imported/x_light.png-b099203c17614ca6f36b269beabfcbab.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/pictograms/x_light.png"
+dest_files=["res://.godot/imported/x_light.png-b099203c17614ca6f36b269beabfcbab.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/close.png b/ui/icons/tab_menu/close.png
new file mode 100644
index 0000000..010564e
Binary files /dev/null and b/ui/icons/tab_menu/close.png differ
diff --git a/ui/icons/tab_menu/close.png.import b/ui/icons/tab_menu/close.png.import
new file mode 100644
index 0000000..97421f4
--- /dev/null
+++ b/ui/icons/tab_menu/close.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://i0di6nvyfgwq"
+path="res://.godot/imported/close.png-f592282e3f82852ae6231bd14b5fd099.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/close.png"
+dest_files=["res://.godot/imported/close.png-f592282e3f82852ae6231bd14b5fd099.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/fishnet_simple.png b/ui/icons/tab_menu/fishnet_simple.png
new file mode 100644
index 0000000..00f9a75
Binary files /dev/null and b/ui/icons/tab_menu/fishnet_simple.png differ
diff --git a/ui/icons/tab_menu/fishnet_simple.png.import b/ui/icons/tab_menu/fishnet_simple.png.import
new file mode 100644
index 0000000..3264172
--- /dev/null
+++ b/ui/icons/tab_menu/fishnet_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cjbdqfnyvsnm4"
+path="res://.godot/imported/fishnet_simple.png-4b51afc915ee521b23b2244427b7733d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/fishnet_simple.png"
+dest_files=["res://.godot/imported/fishnet_simple.png-4b51afc915ee521b23b2244427b7733d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/inventory_simple.png b/ui/icons/tab_menu/inventory_simple.png
new file mode 100644
index 0000000..94a41ef
Binary files /dev/null and b/ui/icons/tab_menu/inventory_simple.png differ
diff --git a/ui/icons/tab_menu/inventory_simple.png.import b/ui/icons/tab_menu/inventory_simple.png.import
new file mode 100644
index 0000000..c570770
--- /dev/null
+++ b/ui/icons/tab_menu/inventory_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cjcbjd5edx5x4"
+path="res://.godot/imported/inventory_simple.png-f67cb963aaeb25aa9f4d1cdb8c686d15.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/inventory_simple.png"
+dest_files=["res://.godot/imported/inventory_simple.png-f67cb963aaeb25aa9f4d1cdb8c686d15.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/logbook_icon_simple.png b/ui/icons/tab_menu/logbook_icon_simple.png
new file mode 100644
index 0000000..0455fdd
Binary files /dev/null and b/ui/icons/tab_menu/logbook_icon_simple.png differ
diff --git a/ui/icons/tab_menu/logbook_icon_simple.png.import b/ui/icons/tab_menu/logbook_icon_simple.png.import
new file mode 100644
index 0000000..3b6c86a
--- /dev/null
+++ b/ui/icons/tab_menu/logbook_icon_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bbrhsth08pra"
+path="res://.godot/imported/logbook_icon_simple.png-19496dc73a295b5d47ceff1151998546.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/logbook_icon_simple.png"
+dest_files=["res://.godot/imported/logbook_icon_simple.png-19496dc73a295b5d47ceff1151998546.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/mail_simple.png b/ui/icons/tab_menu/mail_simple.png
new file mode 100644
index 0000000..0c5569c
Binary files /dev/null and b/ui/icons/tab_menu/mail_simple.png differ
diff --git a/ui/icons/tab_menu/mail_simple.png.import b/ui/icons/tab_menu/mail_simple.png.import
new file mode 100644
index 0000000..6ed128e
--- /dev/null
+++ b/ui/icons/tab_menu/mail_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://jlg77ov0slgm"
+path="res://.godot/imported/mail_simple.png-95f4049e43a5076c9411faf284044682.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/mail_simple.png"
+dest_files=["res://.godot/imported/mail_simple.png-95f4049e43a5076c9411faf284044682.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/online_simple.png b/ui/icons/tab_menu/online_simple.png
new file mode 100644
index 0000000..e332392
Binary files /dev/null and b/ui/icons/tab_menu/online_simple.png differ
diff --git a/ui/icons/tab_menu/online_simple.png.import b/ui/icons/tab_menu/online_simple.png.import
new file mode 100644
index 0000000..51ea7ab
--- /dev/null
+++ b/ui/icons/tab_menu/online_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://br2rfqaxc0sjl"
+path="res://.godot/imported/online_simple.png-8eb00fe8c066e2f85263d1fb847d94d6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/online_simple.png"
+dest_files=["res://.godot/imported/online_simple.png-8eb00fe8c066e2f85263d1fb847d94d6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/tab_menu/profile_simple.png b/ui/icons/tab_menu/profile_simple.png
new file mode 100644
index 0000000..de13f5d
Binary files /dev/null and b/ui/icons/tab_menu/profile_simple.png differ
diff --git a/ui/icons/tab_menu/profile_simple.png.import b/ui/icons/tab_menu/profile_simple.png.import
new file mode 100644
index 0000000..1a7ab07
--- /dev/null
+++ b/ui/icons/tab_menu/profile_simple.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cd08b16j75wep"
+path="res://.godot/imported/profile_simple.png-4d3096990c6fb1ca6619433b80d957a2.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/tab_menu/profile_simple.png"
+dest_files=["res://.godot/imported/profile_simple.png-4d3096990c6fb1ca6619433b80d957a2.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/weather/weather_clear_day.png b/ui/icons/weather/weather_clear_day.png
new file mode 100644
index 0000000..266887d
Binary files /dev/null and b/ui/icons/weather/weather_clear_day.png differ
diff --git a/ui/icons/weather/weather_clear_day.png.import b/ui/icons/weather/weather_clear_day.png.import
new file mode 100644
index 0000000..175f21f
--- /dev/null
+++ b/ui/icons/weather/weather_clear_day.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ce6ge5yypnnpf"
+path="res://.godot/imported/weather_clear_day.png-1cd1140830664281637d5589a748ef58.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/weather/weather_clear_day.png"
+dest_files=["res://.godot/imported/weather_clear_day.png-1cd1140830664281637d5589a748ef58.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/weather/weather_clear_night_full.png b/ui/icons/weather/weather_clear_night_full.png
new file mode 100644
index 0000000..6263b34
Binary files /dev/null and b/ui/icons/weather/weather_clear_night_full.png differ
diff --git a/ui/icons/weather/weather_clear_night_full.png.import b/ui/icons/weather/weather_clear_night_full.png.import
new file mode 100644
index 0000000..b9cffaf
--- /dev/null
+++ b/ui/icons/weather/weather_clear_night_full.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://78l0keyrvuiw"
+path="res://.godot/imported/weather_clear_night_full.png-3640fcf886e9bd01942e21cc0212bc45.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/weather/weather_clear_night_full.png"
+dest_files=["res://.godot/imported/weather_clear_night_full.png-3640fcf886e9bd01942e21cc0212bc45.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/weather/weather_cloudy.png b/ui/icons/weather/weather_cloudy.png
new file mode 100644
index 0000000..0e96583
Binary files /dev/null and b/ui/icons/weather/weather_cloudy.png differ
diff --git a/ui/icons/weather/weather_cloudy.png.import b/ui/icons/weather/weather_cloudy.png.import
new file mode 100644
index 0000000..c74657d
--- /dev/null
+++ b/ui/icons/weather/weather_cloudy.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b17frlbta1p7f"
+path="res://.godot/imported/weather_cloudy.png-c41e4d98007294310395f752b39ddaa6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/weather/weather_cloudy.png"
+dest_files=["res://.godot/imported/weather_cloudy.png-c41e4d98007294310395f752b39ddaa6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/weather/weather_fog.png b/ui/icons/weather/weather_fog.png
new file mode 100644
index 0000000..00e93b6
Binary files /dev/null and b/ui/icons/weather/weather_fog.png differ
diff --git a/ui/icons/weather/weather_fog.png.import b/ui/icons/weather/weather_fog.png.import
new file mode 100644
index 0000000..8887cfb
--- /dev/null
+++ b/ui/icons/weather/weather_fog.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://wpaj2l01upt6"
+path="res://.godot/imported/weather_fog.png-0fe4352261121bbcc8da10ebc1995663.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/weather/weather_fog.png"
+dest_files=["res://.godot/imported/weather_fog.png-0fe4352261121bbcc8da10ebc1995663.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/icons/weather/weather_rain.png b/ui/icons/weather/weather_rain.png
new file mode 100644
index 0000000..b3de517
Binary files /dev/null and b/ui/icons/weather/weather_rain.png differ
diff --git a/ui/icons/weather/weather_rain.png.import b/ui/icons/weather/weather_rain.png.import
new file mode 100644
index 0000000..af5d773
--- /dev/null
+++ b/ui/icons/weather/weather_rain.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cfqvlqp3nrfok"
+path="res://.godot/imported/weather_rain.png-affbb7091de81103538e2740c5b2c762.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://ui/icons/weather/weather_rain.png"
+dest_files=["res://.godot/imported/weather_rain.png-affbb7091de81103538e2740c5b2c762.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/ui/interface_font_controller.gd b/ui/interface_font_controller.gd
new file mode 100644
index 0000000..f8db71e
--- /dev/null
+++ b/ui/interface_font_controller.gd
@@ -0,0 +1,109 @@
+class_name InterfaceFontController
+extends Node
+
+const STANDARD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf")
+const COMPACT_FILE_DIALOG_LIMIT := Vector2i(800, 600)
+const COMPACT_FILE_DIALOG_MARGIN := Vector2i(12, 12)
+const COMPACT_FILE_DIALOG_FONT_SIZE: int = 17
+
+var _game_theme: Theme = preload("res://ui/game_theme.tres")
+var _utility_theme: Theme
+var _compact_file_dialog_theme: Theme
+
+
+func _ready() -> void:
+ enforce_standard_font()
+ _utility_theme = _game_theme.duplicate(true)
+ _utility_theme.default_font = STANDARD_FONT
+
+
+func enforce_standard_font() -> void:
+ _game_theme.default_font = STANDARD_FONT
+ if _utility_theme != null:
+ _utility_theme.default_font = STANDARD_FONT
+
+
+func set_readable_font_enabled(_enabled: bool) -> void:
+ # Compatibility seam for callers compiled against the old setting.
+ enforce_standard_font()
+
+
+func is_readable_font_enabled() -> bool:
+ return true
+
+
+func apply_utility_theme(themed_node: Node) -> void:
+ if themed_node == null:
+ return
+ if _utility_theme == null:
+ _utility_theme = _game_theme.duplicate(true)
+ _utility_theme.default_font = STANDARD_FONT
+ if themed_node is Control:
+ (themed_node as Control).theme = _utility_theme
+ elif themed_node is Window:
+ (themed_node as Window).theme = _utility_theme
+
+
+func popup_file_dialog(dialog: FileDialog) -> void:
+ if dialog == null or not dialog.is_inside_tree():
+ return
+ var host_window: Window = dialog.get_parent().get_window()
+ var host_size: Vector2i = host_window.size
+ var compact: bool = (
+ host_size.x <= COMPACT_FILE_DIALOG_LIMIT.x
+ or host_size.y <= COMPACT_FILE_DIALOG_LIMIT.y
+ )
+ if not compact:
+ apply_utility_theme(dialog)
+ dialog.min_size = Vector2i(200, 70)
+ dialog.max_size = Vector2i.ZERO
+ dialog.popup_centered_ratio(0.75)
+ return
+ if _compact_file_dialog_theme == null:
+ _compact_file_dialog_theme = _utility_theme.duplicate(true)
+ _compact_file_dialog_theme.default_font_size = (
+ COMPACT_FILE_DIALOG_FONT_SIZE
+ )
+ dialog.theme = _compact_file_dialog_theme
+ var available_size := Vector2i(
+ maxi(1, host_size.x - COMPACT_FILE_DIALOG_MARGIN.x * 2),
+ maxi(1, host_size.y - COMPACT_FILE_DIALOG_MARGIN.y * 2),
+ )
+ dialog.min_size = Vector2i.ZERO
+ dialog.max_size = available_size
+ dialog.popup_centered(available_size)
+ # FileDialog's desktop-oriented intrinsic minimum can exceed a compact
+ # 4:3 display. The compact font keeps its controls usable while this final
+ # clamp guarantees the embedded window cannot escape the physical screen.
+ dialog.size = available_size
+ if dialog.get_viewport().gui_embed_subwindows:
+ dialog.position = (host_size - available_size) / 2
+ _finalize_compact_file_dialog.call_deferred(
+ dialog, host_size, available_size
+ )
+
+
+func _finalize_compact_file_dialog(
+ dialog: FileDialog,
+ host_size: Vector2i,
+ requested_size: Vector2i,
+) -> void:
+ await get_tree().process_frame
+ if dialog == null or not dialog.visible:
+ return
+ # Embedded windows report their content origin below the title bar. Account
+ # for that inset after Godot has laid the window out so its bottom and right
+ # edges retain the same safe margin as its decorated top and left edges.
+ var safe_bottom_right: Vector2i = host_size - COMPACT_FILE_DIALOG_MARGIN
+ var fitted_size: Vector2i = requested_size
+ fitted_size.x -= maxi(
+ 0, dialog.position.x + fitted_size.x - safe_bottom_right.x
+ )
+ fitted_size.y -= maxi(
+ 0, dialog.position.y + fitted_size.y - safe_bottom_right.y
+ )
+ dialog.size = Vector2i(maxi(1, fitted_size.x), maxi(1, fitted_size.y))
+
+
+func readable_font() -> Font:
+ return STANDARD_FONT
diff --git a/ui/interface_font_controller.gd.uid b/ui/interface_font_controller.gd.uid
new file mode 100644
index 0000000..f6b4897
--- /dev/null
+++ b/ui/interface_font_controller.gd.uid
@@ -0,0 +1 @@
+uid://djmxy6jibi5y3
diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd
new file mode 100644
index 0000000..1ba1a1e
--- /dev/null
+++ b/ui/logbook_catalog.gd
@@ -0,0 +1,322 @@
+class_name LogbookCatalog
+extends RefCounted
+
+const FishDataType = preload("res://fish/fish_data.gd")
+
+# Catalog numbers are a presentation contract. Add future species deliberately;
+# filtering and display order must not redefine an existing number.
+const CATALOG_ORDER: Array[StringName] = [
+ &"bluegill",
+ &"bass",
+ &"carp",
+ &"sunfish",
+ &"catfish_blue",
+ &"catfish_channel",
+ &"catfish_flathead",
+ &"catfish_white",
+ &"tuna_albacore",
+ &"tuna_bigeye",
+ &"tuna_bluefin",
+ &"tuna_skipjack",
+ &"tuna_yellowfin",
+ &"goby_round",
+ &"salmon_atlantic",
+ &"salmon_chum",
+ &"salmon_coho",
+ &"salmon_pink",
+ &"salmon_sockeye",
+ &"anchovy_european",
+ &"anchovy_northern",
+ &"chub_european",
+ &"chub_flame",
+ &"chub_lake",
+ &"goldfish",
+ &"goldfish_bubbleeye",
+ &"grouper_gulf",
+ &"grouper_red",
+ &"mackerel_atlantic",
+ &"mackerel_cero",
+ &"mackerel_chub",
+ &"mackerel_king",
+ &"mackerel_spanish",
+ &"marlin_black",
+ &"marlin_blue",
+ &"marlin_white",
+ &"pomfret_black",
+ &"pomfret_chinese",
+ &"pomfret_golden",
+ &"pomfret_white",
+ &"sailfish",
+ &"sauger",
+ &"saugeye",
+ &"snapper_lane",
+ &"snapper_mangrove",
+ &"snapper_mutton",
+ &"snapper_red",
+ &"swordfish",
+ &"trout_cutthroat",
+ &"trout_golden",
+ &"trout_rainbow",
+ &"trout_steelhead",
+ &"walleye",
+]
+
+# Short presentation notes are kept here with the catalog contract rather
+# than mixed into gameplay balance resources. Add facts deliberately when a
+# species joins the catalog.
+const FISH_FACTS: Dictionary[StringName, String] = {
+ &"bluegill": (
+ "bluegill fathers sweep shallow bowls into the bottom for nests, "
+ + "then stand guard. whole neighborhoods of nests can crowd together."
+ ),
+ &"bass": (
+ "striped bass split their lives between salt and fresh water, "
+ + "returning upriver to spawn. a long-lived striper may see three decades."
+ ),
+ &"carp": (
+ "whisker-like barbels help a common carp investigate the bottom. "
+ + "nosing through sediment can cloud the water and loosen plants."
+ ),
+ &"sunfish": (
+ "ocean sunfish are built more like enormous swimming heads than "
+ + "ordinary fish. they visit the surface and turn sideways to warm up."
+ ),
+ &"catfish_blue": (
+ "a deep fork in the tail inspired the blue catfish's scientific "
+ + "name. it rests deep by daylight and becomes more active after dark."
+ ),
+ &"catfish_channel": (
+ "a channel catfish can taste through skin all over its body, "
+ + "especially near its gills and whiskers. the male watches the eggs."
+ ),
+ &"catfish_flathead": (
+ "flatheads favor hideouts made by logs, rocks, ledges, and other "
+ + "underwater structure. as they grow, fish dominate their menu."
+ ),
+ &"catfish_white": (
+ "white catfish began along the atlantic coast between new york "
+ + "and florida. people later carried them far beyond that home range."
+ ),
+ &"tuna_albacore": (
+ "albacore have unusually long pectoral fins. their warm muscles help "
+ + "them cruise across wide stretches of open ocean."
+ ),
+ &"tuna_bigeye": (
+ "bigeye tuna make daily trips between deep, cool water and warmer "
+ + "surface layers. their large eyes suit those dim depths."
+ ),
+ &"tuna_bluefin": (
+ "bluefin tuna are powerful ocean travelers. heat retained in their "
+ + "swimming muscles lets them thrive in surprisingly cold seas."
+ ),
+ &"tuna_skipjack": (
+ "skipjack travel in fast-moving schools and have dark stripes along "
+ + "their lower sides. they rarely stop swimming."
+ ),
+ &"tuna_yellowfin": (
+ "yellowfin are named for their bright yellow fins and finlets. large "
+ + "schools often gather with other open-ocean hunters."
+ ),
+ &"goby_round": (
+ "round gobies use fused pelvic fins like a suction cup to hold onto "
+ + "rocks. they are small fish with a famously large appetite."
+ ),
+ &"salmon_atlantic": (
+ "atlantic salmon can return from the ocean to the river where they "
+ + "hatched. unlike many salmon, some survive to make the trip again."
+ ),
+ &"salmon_chum": (
+ "chum salmon travel immense distances and develop striking bars when "
+ + "they return to fresh water to spawn."
+ ),
+ &"salmon_coho": (
+ "coho salmon are agile hunters known for powerful leaps. young coho "
+ + "may spend more than a year growing in streams."
+ ),
+ &"salmon_pink": (
+ "pink salmon usually follow a strict two-year life cycle. spawning "
+ + "males develop the hump that inspired their nickname."
+ ),
+ &"salmon_sockeye": (
+ "sockeye salmon feed heavily on plankton at sea. adults turn vivid red "
+ + "as they return inland to spawn."
+ ),
+ &"anchovy_european": (
+ "european anchovies form dense schools and feed by filtering tiny "
+ + "plankton from coastal water."
+ ),
+ &"anchovy_northern": (
+ "northern anchovies gather in huge schools along the pacific coast "
+ + "and filter plankton with their fine gill rakers."
+ ),
+ &"chub_european": (
+ "european chub are adaptable river fish. adults often patrol "
+ + "shallows and feed on insects, small fish, and fruit."
+ ),
+ &"chub_flame": (
+ "flame chubs prefer cool, clear streams. their bright breeding colors "
+ + "make a small fish easy to spot in spring water."
+ ),
+ &"chub_lake": (
+ "lake chubs school in cold northern water and use their small mouths "
+ + "to pick insects and other tiny prey from the current."
+ ),
+ &"goldfish": (
+ "goldfish can tolerate a surprisingly wide range of conditions. "
+ + "their wild relatives often live in slow, weedy water."
+ ),
+ &"goldfish_bubbleeye": (
+ "bubble-eye goldfish have delicate fluid-filled sacs beneath their "
+ + "eyes, making careful handling especially important."
+ ),
+ &"grouper_gulf": (
+ "gulf grouper are ambush predators that use a sudden gulp to pull "
+ + "prey into their cavernous mouths."
+ ),
+ &"grouper_red": (
+ "red grouper excavate shelters in reef rubble. the hollows they make "
+ + "can become hiding places for many smaller animals."
+ ),
+ &"mackerel_atlantic": (
+ "atlantic mackerel travel in fast schools across the north atlantic "
+ + "and feed on plankton and small schooling fish."
+ ),
+ &"mackerel_cero": (
+ "cero mackerel are streamlined coastal hunters. sharp teeth help "
+ + "them slash through schools of smaller fish."
+ ),
+ &"mackerel_chub": (
+ "chub mackerel school near the surface and follow plankton blooms. "
+ + "their dark wavy bars help identify them at a glance."
+ ),
+ &"mackerel_king": (
+ "king mackerel are swift coastal predators. their pointed teeth and "
+ + "long body are built for sudden bursts of speed."
+ ),
+ &"mackerel_spanish": (
+ "spanish mackerel hunt in warm coastal waters and often travel in "
+ + "loose schools while chasing baitfish."
+ ),
+ &"marlin_black": (
+ "black marlin are powerful billfish that spend much of their lives "
+ + "in warm open ocean and can make blistering runs."
+ ),
+ &"marlin_blue": (
+ "blue marlin are highly migratory open-ocean hunters. their long bill "
+ + "helps them stun prey before turning back to feed."
+ ),
+ &"marlin_white": (
+ "white marlin favor warm offshore water and use their rounded dorsal "
+ + "fin and bill to weave through schools of prey."
+ ),
+ &"pomfret_black": (
+ "black pomfret have a deep, laterally compressed body that lets them "
+ + "maneuver neatly through open water."
+ ),
+ &"pomfret_chinese": (
+ "chinese pomfret are silvery schooling fish with a tall, flattened "
+ + "body and long sickle-shaped fins."
+ ),
+ &"pomfret_golden": (
+ "golden pomfret are warm-water swimmers whose golden sheen is most "
+ + "noticeable along the fins and flanks."
+ ),
+ &"pomfret_white": (
+ "white pomfret gather over coastal grounds and use their compact, "
+ + "deep bodies to turn quickly while feeding."
+ ),
+ &"sailfish": (
+ "sailfish raise their enormous dorsal fin while herding baitfish. "
+ + "they are among the fastest fish in the sea."
+ ),
+ &"sauger": (
+ "sauger favor turbid rivers and reservoirs. their mottled backs "
+ + "blend into the dim, shifting light near the bottom."
+ ),
+ &"saugeye": (
+ "saugeye are a fertile hybrid of sauger and walleye. they combine "
+ + "traits from both parent species and often grow quickly."
+ ),
+ &"snapper_lane": (
+ "lane snapper use reef structure for cover and hunt small crustaceans "
+ + "and fish when the light begins to fade."
+ ),
+ &"snapper_mangrove": (
+ "mangrove snapper shelter among roots and docks when young, then "
+ + "move toward reefs as they grow."
+ ),
+ &"snapper_mutton": (
+ "mutton snapper have canine teeth that help them pick crabs, shrimp, "
+ + "and other hard-shelled prey from the reef."
+ ),
+ &"snapper_red": (
+ "red snapper gather around reefs, wrecks, and ledges. their diet "
+ + "includes small fish, shrimp, and squid."
+ ),
+ &"swordfish": (
+ "swordfish are fast, wide-ranging predators with a flattened bill. "
+ + "they can cross deep water and warm surface layers in one day."
+ ),
+ &"trout_cutthroat": (
+ "cutthroat trout take their name from the red slash beneath the jaw. "
+ + "many populations rely on cold, well-oxygenated streams."
+ ),
+ &"trout_golden": (
+ "golden trout evolved in clear, high-elevation california streams. "
+ + "their bright flanks stand out against rocky alpine water."
+ ),
+ &"trout_rainbow": (
+ "rainbow trout are adaptable stream hunters. the pink lateral stripe "
+ + "is especially vivid when the fish is in breeding condition."
+ ),
+ &"trout_steelhead": (
+ "steelhead are the ocean-going form of rainbow trout. adults can "
+ + "leave fresh water and later return to spawn."
+ ),
+ &"walleye": (
+ "walleye have light-sensitive eyes that help them hunt in murky water "
+ + "and around dusk. their reflective layer gives the eyes a pale glow."
+ ),
+}
+
+static func category_for(fish: FishDataType) -> WaterType.Type:
+ if fish == null:
+ return WaterType.Type.OTHER
+ return fish.get_primary_water_type()
+
+
+static func category_label(category: WaterType.Type) -> String:
+ return WaterType.label(category)
+
+
+static func empty_state(category: WaterType.Type) -> String:
+ match category:
+ WaterType.Type.FRESH_WATER:
+ return "No freshwater catches cataloged yet."
+ WaterType.Type.SALT_WATER:
+ return "No saltwater catches cataloged yet."
+ _:
+ return "No entries available."
+
+
+static func catalog_number(fish_id: StringName) -> int:
+ var index: int = CATALOG_ORDER.find(fish_id)
+ return index + 1 if index >= 0 else 0
+
+
+static func facts_for(fish_id: StringName) -> String:
+ return str(FISH_FACTS.get(fish_id, "unknown"))
+
+
+static func ordered_species(
+ candidates: Array[FishDataType],
+) -> Array[FishDataType]:
+ var by_id: Dictionary[StringName, FishDataType] = {}
+ for fish: FishDataType in candidates:
+ if fish != null and not fish.id.is_empty():
+ by_id[fish.id] = fish
+ var ordered: Array[FishDataType] = []
+ for fish_id: StringName in CATALOG_ORDER:
+ if by_id.has(fish_id):
+ ordered.append(by_id[fish_id])
+ return ordered
diff --git a/ui/logbook_catalog.gd.uid b/ui/logbook_catalog.gd.uid
new file mode 100644
index 0000000..9a14366
--- /dev/null
+++ b/ui/logbook_catalog.gd.uid
@@ -0,0 +1 @@
+uid://5vvacdo53k0n
diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd
new file mode 100644
index 0000000..ee63797
--- /dev/null
+++ b/ui/logbook_page.gd
@@ -0,0 +1,1177 @@
+class_name LogbookPage
+extends Control
+
+const CollectionLogType = preload("res://collection/collection_log.gd")
+const FishDataType = preload("res://fish/fish_data.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const FishPoolType = preload("res://fish/fish_pool.gd")
+const SILHOUETTE_SHADER: Shader = preload(
+ "res://ui/logbook_silhouette.gdshader"
+)
+const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
+const InventoryNotepadType = preload(
+ "res://ui/components/inventory_notepad.gd"
+)
+const LogbookPortraitType = preload(
+ "res://ui/components/logbook_portrait.gd"
+)
+const CurrencyPresentationType = preload(
+ "res://ui/currency_presentation.gd"
+)
+
+const DETAIL_FADE_DURATION: float = 0.12
+const CATEGORY_FADE_DURATION: float = UIMotion.UTILITY_EXIT_DURATION
+const HANDWRITTEN_NUMERIC_SCALE: float = 0.8
+const PORTRAIT_VIEW_MAX_SIZE := Vector2(860.0, 480.0)
+const INK := Color("251b10")
+const MUTED_INK := Color("6d5b45")
+const LOGBOOK_ARTWORK: Texture2D = preload(
+ "res://ui/assets/logbook/ui_logbook.png"
+)
+const SCROLL_UP_TEXTURE: Texture2D = preload(
+ "res://ui/icons/pictograms/arrow_dark_up_full.png"
+)
+const SCROLL_DOWN_TEXTURE: Texture2D = preload(
+ "res://ui/icons/pictograms/arrow_dark_down_full.png"
+)
+const LOGBOOK_ARTWORK_SOURCE_SIZE := Vector2(512.0, 247.0)
+const LEFT_PAGE_CONTENT_RECT := Rect2(56.0, 26.0, 190.0, 198.0)
+const RIGHT_PAGE_CONTENT_RECT := Rect2(267.0, 26.0, 210.0, 198.0)
+const LOGBOOK_TAB_LEFT_INSET: float = 122.0
+const PAGE_CONTENT_SCALE: float = 0.97
+const CATALOG_PORTRAIT_SIZE := Vector2(78.0, 36.0)
+const CATALOG_ENTRY_SIZE := Vector2(92.0, 92.0)
+const CATALOG_ROW_STEP: float = 98.0
+const CATALOG_SNAP_DELAY: float = 0.12
+const DETAIL_PORTRAIT_SIZE := Vector2(160.0, 88.0)
+const DETAIL_BOTTOM_INSET: float = 35.0
+
+var _collection_log: CollectionLogType
+var _inventory: FishInventoryType
+var _catalog: FishPoolType
+var _category: WaterType.Type = WaterType.Type.FRESH_WATER
+var _selected_id: StringName
+var _selected_entry_key: StringName
+var _active: bool = false
+var _interactive: bool = false
+var _entry_buttons: Dictionary[StringName, Button] = {}
+var _detail_generation: int = 0
+var _detail_tween: Tween
+var _category_generation: int = 0
+var _category_tween: Tween
+var _silhouette_material: ShaderMaterial
+var _snapping_catalog_scroll: bool = false
+var _catalog_scroll_snap_timer: Timer
+
+var _category_tabs: Array[Button] = []
+var _catalog_scroll: ScrollContainer
+var _catalog_grid: GridContainer
+var _catalog_scroll_up_indicator: TextureRect
+var _catalog_scroll_down_indicator: TextureRect
+var _empty_state: Label
+var _detail_body: VBoxContainer
+var _detail_portrait_button: Button
+var _portrait_overlay: Control
+var _portrait_overlay_backdrop: Button
+var _portrait_overlay_artwork: LogbookPortraitType
+
+
+func _ready() -> void:
+ _silhouette_material = ShaderMaterial.new()
+ _silhouette_material.shader = SILHOUETTE_SHADER
+ _build_interface()
+ set_interactive(false)
+
+
+func setup(
+ collection_log: CollectionLogType,
+ inventory: FishInventoryType,
+ catalog: FishPoolType,
+) -> void:
+ _collection_log = collection_log
+ _inventory = inventory
+ _catalog = catalog
+ if not _collection_log.fish_discovered.is_connected(
+ _on_fish_discovered
+ ):
+ _collection_log.fish_discovered.connect(_on_fish_discovered)
+ if not _collection_log.collection_changed.is_connected(
+ _on_collection_changed
+ ):
+ _collection_log.collection_changed.connect(_on_collection_changed)
+ if not _inventory.catches_changed.is_connected(_on_inventory_changed):
+ _inventory.catches_changed.connect(_on_inventory_changed)
+ _refresh_catalog()
+
+
+func activate() -> void:
+ _active = true
+ set_interactive(true)
+ _refresh_catalog()
+ _animate_tab_entry()
+
+
+func deactivate() -> void:
+ _active = false
+ _hide_portrait_overlay(false)
+ set_interactive(false)
+ _settle_tabs_for_close()
+ _cancel_detail_tween()
+ _cancel_category_tween()
+
+
+func set_interactive(value: bool) -> void:
+ _interactive = value and _active
+ mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if _interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ for tab: Button in _category_tabs:
+ tab.focus_mode = (
+ Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE
+ )
+ tab.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if _interactive and not tab.button_pressed
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ for entry: Button in _entry_buttons.values():
+ entry.focus_mode = (
+ Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE
+ )
+ entry.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if _interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func focus_initial() -> void:
+ if not _active or not _interactive:
+ return
+ if _portrait_overlay != null and _portrait_overlay.visible:
+ _portrait_overlay_backdrop.grab_focus()
+ return
+ var selected := _entry_buttons.get(_selected_id) as Button
+ if selected != null:
+ selected.grab_focus()
+ elif not _entry_buttons.is_empty():
+ var first := _entry_buttons.values().front() as Button
+ first.grab_focus()
+ else:
+ _category_tabs[int(_category)].grab_focus()
+
+
+func _build_interface() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ var outer := MarginContainer.new()
+ outer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ outer.add_theme_constant_override("margin_left", 52)
+ outer.add_theme_constant_override("margin_top", 85)
+ outer.add_theme_constant_override("margin_right", 52)
+ outer.add_theme_constant_override("margin_bottom", 18)
+ add_child(outer)
+
+ var stack := Control.new()
+ stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ outer.add_child(stack)
+
+ var tabs := HBoxContainer.new()
+ # Align the first flange with the authored left paper edge.
+ tabs.position = Vector2(LOGBOOK_TAB_LEFT_INSET, 37.0)
+ tabs.size = Vector2(384.0, 38.0)
+ tabs.z_index = 10
+ tabs.mouse_filter = Control.MOUSE_FILTER_PASS
+ tabs.add_theme_constant_override("separation", 6)
+ stack.add_child(tabs)
+ for category: WaterType.Type in [
+ WaterType.Type.FRESH_WATER,
+ WaterType.Type.SALT_WATER,
+ WaterType.Type.OTHER,
+ ]:
+ var tab := OrganizerTabType.new()
+ tab.custom_minimum_size = Vector2(124, 38)
+ tab.toggle_mode = true
+ tab.text = LogbookCatalog.category_label(category)
+ tab.palette_index = int(category)
+ tab.set_selected(category == _category, false)
+ tab.pressed.connect(_select_category.bind(category))
+ tabs.add_child(tab)
+ _category_tabs.append(tab)
+ _configure_category_focus()
+
+ var book := Control.new()
+ book.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ book.offset_top = 50.0
+ book.z_index = 20
+ book.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ stack.add_child(book)
+
+ var artwork := TextureRect.new()
+ artwork.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ artwork.texture = LOGBOOK_ARTWORK
+ artwork.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ artwork.stretch_mode = TextureRect.STRETCH_SCALE
+ artwork.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ artwork.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ book.add_child(artwork)
+
+ var left_page := Control.new()
+ left_page.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _apply_artwork_rect(left_page, LEFT_PAGE_CONTENT_RECT)
+ book.add_child(left_page)
+ var left_layout := VBoxContainer.new()
+ left_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ left_layout.add_theme_constant_override("separation", 8)
+ left_page.add_child(left_layout)
+ _apply_page_content_scale(left_layout)
+ var scroll_indicator_top_lane := Control.new()
+ scroll_indicator_top_lane.custom_minimum_size.y = 20.0
+ scroll_indicator_top_lane.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ left_layout.add_child(scroll_indicator_top_lane)
+ _empty_state = _label("", 18)
+ _empty_state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _empty_state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _empty_state.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ left_layout.add_child(_empty_state)
+ var catalog_scroll_margin := MarginContainer.new()
+ catalog_scroll_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ catalog_scroll_margin.add_theme_constant_override("margin_left", 20)
+ left_layout.add_child(catalog_scroll_margin)
+ _catalog_scroll = ScrollContainer.new()
+ _catalog_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _catalog_scroll.horizontal_scroll_mode = (
+ ScrollContainer.SCROLL_MODE_DISABLED
+ )
+ _catalog_scroll.scroll_vertical_custom_step = CATALOG_ROW_STEP
+ catalog_scroll_margin.add_child(_catalog_scroll)
+ _catalog_grid = GridContainer.new()
+ _catalog_grid.columns = 4
+ _catalog_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _catalog_grid.add_theme_constant_override("h_separation", 6)
+ _catalog_grid.add_theme_constant_override("v_separation", 6)
+ _catalog_scroll.add_child(_catalog_grid)
+ var scroll_indicator_bottom_lane := Control.new()
+ scroll_indicator_bottom_lane.custom_minimum_size.y = 32.0
+ scroll_indicator_bottom_lane.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ left_layout.add_child(scroll_indicator_bottom_lane)
+ _configure_catalog_scroll_bar()
+ _catalog_scroll_up_indicator = _make_scroll_indicator(
+ "CatalogScrollUpIndicator",
+ SCROLL_UP_TEXTURE,
+ true,
+ )
+ left_page.add_child(_catalog_scroll_up_indicator)
+ _catalog_scroll_down_indicator = _make_scroll_indicator(
+ "CatalogScrollDownIndicator",
+ SCROLL_DOWN_TEXTURE,
+ false,
+ )
+ left_page.add_child(_catalog_scroll_down_indicator)
+
+ var right_page := Control.new()
+ right_page.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _apply_artwork_rect(right_page, RIGHT_PAGE_CONTENT_RECT)
+ book.add_child(right_page)
+ _detail_body = VBoxContainer.new()
+ _detail_body.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ _detail_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _detail_body.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _detail_body.add_theme_constant_override("separation", 16)
+ right_page.add_child(_detail_body)
+ _apply_page_content_scale(_detail_body)
+ _show_no_selection()
+ _build_portrait_overlay()
+
+
+func _apply_artwork_rect(control: Control, source_rect: Rect2) -> void:
+ control.anchor_left = source_rect.position.x / LOGBOOK_ARTWORK_SOURCE_SIZE.x
+ control.anchor_top = source_rect.position.y / LOGBOOK_ARTWORK_SOURCE_SIZE.y
+ control.anchor_right = source_rect.end.x / LOGBOOK_ARTWORK_SOURCE_SIZE.x
+ control.anchor_bottom = source_rect.end.y / LOGBOOK_ARTWORK_SOURCE_SIZE.y
+ control.offset_left = 0.0
+ control.offset_top = 0.0
+ control.offset_right = 0.0
+ control.offset_bottom = 0.0
+
+
+func _apply_page_content_scale(control: Control) -> void:
+ control.scale = Vector2.ONE * PAGE_CONTENT_SCALE
+ control.resized.connect(_center_scaled_content.bind(control))
+ _center_scaled_content.call_deferred(control)
+
+
+func _center_scaled_content(control: Control) -> void:
+ if is_instance_valid(control):
+ control.pivot_offset = control.size * 0.5
+
+
+func _configure_catalog_scroll_bar() -> void:
+ _catalog_scroll_snap_timer = Timer.new()
+ _catalog_scroll_snap_timer.one_shot = true
+ _catalog_scroll_snap_timer.wait_time = CATALOG_SNAP_DELAY
+ _catalog_scroll_snap_timer.timeout.connect(_snap_catalog_scroll_to_row)
+ _catalog_scroll.add_child(_catalog_scroll_snap_timer)
+ var scroll_bar: VScrollBar = _catalog_scroll.get_v_scroll_bar()
+ scroll_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ scroll_bar.focus_mode = Control.FOCUS_NONE
+ scroll_bar.self_modulate = Color.TRANSPARENT
+ scroll_bar.custom_minimum_size.x = 0.0
+ scroll_bar.add_theme_constant_override("scroll_size", 0)
+ var empty_style := StyleBoxEmpty.new()
+ for style_name: StringName in [
+ &"scroll",
+ &"scroll_focus",
+ &"grabber",
+ &"grabber_highlight",
+ &"grabber_pressed",
+ ]:
+ scroll_bar.add_theme_stylebox_override(style_name, empty_style)
+ scroll_bar.changed.connect(_refresh_catalog_scroll_indicators)
+ scroll_bar.value_changed.connect(_on_catalog_scroll_value_changed)
+
+
+func _make_scroll_indicator(
+ indicator_name: String,
+ indicator_texture: Texture2D,
+ at_top: bool,
+) -> TextureRect:
+ var indicator := TextureRect.new()
+ indicator.name = indicator_name
+ indicator.anchor_left = 0.5
+ indicator.anchor_right = 0.5
+ indicator.anchor_top = 0.0 if at_top else 1.0
+ indicator.anchor_bottom = indicator.anchor_top
+ indicator.offset_left = -14.0
+ indicator.offset_right = 14.0
+ indicator.offset_top = -8.0 if at_top else -35.0
+ indicator.offset_bottom = 20.0 if at_top else -7.0
+ indicator.z_index = 30
+ indicator.texture = indicator_texture
+ indicator.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ indicator.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ indicator.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ indicator.visible = false
+ return indicator
+
+
+func _on_catalog_scroll_value_changed(_value: float) -> void:
+ if _snapping_catalog_scroll:
+ return
+ _catalog_scroll_snap_timer.start()
+ _refresh_catalog_scroll_indicators()
+
+
+func _snap_catalog_scroll_to_row() -> void:
+ var scroll_bar: VScrollBar = _catalog_scroll.get_v_scroll_bar()
+ var value: float = scroll_bar.value
+ var maximum_scroll: float = scroll_bar.max_value - scroll_bar.page
+ var snapped_value: float = clampf(
+ roundf(value / CATALOG_ROW_STEP) * CATALOG_ROW_STEP,
+ scroll_bar.min_value,
+ maximum_scroll,
+ )
+ if not is_equal_approx(value, snapped_value):
+ _snapping_catalog_scroll = true
+ _catalog_scroll.scroll_vertical = roundi(snapped_value)
+ _snapping_catalog_scroll = false
+ _refresh_catalog_scroll_indicators()
+
+
+func _refresh_catalog_scroll_indicators() -> void:
+ if (
+ _catalog_scroll == null
+ or _catalog_scroll_up_indicator == null
+ or _catalog_scroll_down_indicator == null
+ ):
+ return
+ var scroll_bar: VScrollBar = _catalog_scroll.get_v_scroll_bar()
+ var maximum_scroll: float = scroll_bar.max_value - scroll_bar.page
+ var has_overflow: bool = (
+ _catalog_scroll.visible
+ and maximum_scroll > scroll_bar.min_value + 0.5
+ )
+ _catalog_scroll_up_indicator.visible = (
+ has_overflow and scroll_bar.value > scroll_bar.min_value + 0.5
+ )
+ _catalog_scroll_down_indicator.visible = (
+ has_overflow and scroll_bar.value < maximum_scroll - 0.5
+ )
+
+
+func _refresh_catalog() -> void:
+ if not is_node_ready():
+ return
+ var preserved_selection: StringName = _selected_id
+ var preserved_entry_key: StringName = _selected_entry_key
+ _clear_entries()
+ var species: Array[FishDataType] = []
+ if _catalog != null:
+ species = LogbookCatalog.ordered_species(_catalog.candidates)
+ for fish: FishDataType in species:
+ if LogbookCatalog.category_for(fish) != _category:
+ continue
+ var discovered: bool = _collection_log.has_discovered(fish.id)
+ var entry: Button = _make_entry(fish, discovered)
+ var selection_key: StringName = (
+ fish.id if discovered else _unknown_selection_key(fish)
+ )
+ _entry_buttons[selection_key] = entry
+ entry.pressed.connect(
+ _select_entry.bind(
+ selection_key,
+ fish.id if discovered else StringName(),
+ )
+ )
+ entry.focus_entered.connect(
+ _select_entry.bind(
+ selection_key,
+ fish.id if discovered else StringName(),
+ )
+ )
+ _catalog_grid.add_child(entry)
+ _empty_state.text = LogbookCatalog.empty_state(_category)
+ _empty_state.visible = _entry_buttons.is_empty()
+ _catalog_scroll.visible = not _entry_buttons.is_empty()
+ if (
+ not preserved_selection.is_empty()
+ and _entry_buttons.has(preserved_selection)
+ ):
+ _selected_id = preserved_selection
+ _selected_entry_key = preserved_selection
+ elif _entry_buttons.has(preserved_entry_key):
+ _selected_id = StringName()
+ _selected_entry_key = preserved_entry_key
+ else:
+ _selected_id = StringName()
+ _selected_entry_key = StringName()
+ _refresh_selection_styles()
+ _refresh_details(false)
+ set_interactive(_interactive)
+ _refresh_catalog_scroll_indicators.call_deferred()
+
+
+func _make_entry(fish: FishDataType, discovered: bool) -> Button:
+ var entry := Button.new()
+ entry.set_meta(&"controller_focus_inversion_disabled", true)
+ entry.custom_minimum_size = CATALOG_ENTRY_SIZE
+ entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
+ entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER
+ entry.toggle_mode = true
+ entry.add_theme_stylebox_override("normal", _entry_style(false))
+ entry.add_theme_stylebox_override("hover", _entry_style(true))
+ entry.add_theme_stylebox_override("hover_pressed", _entry_style(true))
+ entry.add_theme_stylebox_override("focus", _entry_style(true))
+ entry.add_theme_stylebox_override("pressed", _entry_style(true))
+ entry.text = ""
+ if not discovered:
+ entry.tooltip_text = ""
+ entry.accessibility_name = "Unknown catalog entry"
+ _add_entry_content(entry, fish.display_texture, "???", true)
+ else:
+ entry.tooltip_text = fish.display_name
+ entry.accessibility_name = fish.display_name
+ _add_entry_content(
+ entry, fish.display_texture, fish.display_name, false
+ )
+ return entry
+
+
+func _add_entry_content(
+ entry: Button,
+ portrait: Texture2D,
+ entry_name: String,
+ unknown: bool,
+) -> void:
+ var content_margin := MarginContainer.new()
+ content_margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ content_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ content_margin.add_theme_constant_override("margin_left", 5)
+ content_margin.add_theme_constant_override("margin_top", 8)
+ content_margin.add_theme_constant_override("margin_right", 5)
+ content_margin.add_theme_constant_override("margin_bottom", 2)
+ entry.add_child(content_margin)
+
+ var content := VBoxContainer.new()
+ content.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ content.alignment = BoxContainer.ALIGNMENT_CENTER
+ content.add_theme_constant_override("separation", 4)
+ content_margin.add_child(content)
+
+ var portrait_view := LogbookPortraitType.new()
+ portrait_view.configure(
+ portrait,
+ CATALOG_PORTRAIT_SIZE,
+ _silhouette_material if unknown else null,
+ )
+ content.add_child(portrait_view)
+
+ var name_label := _label(_entry_label_text(entry_name), 16)
+ name_label.custom_minimum_size.y = 38.0
+ name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ content.add_child(name_label)
+
+
+func _select_category(category: WaterType.Type) -> void:
+ if category == _category:
+ return
+ _category = category
+ _selected_id = StringName()
+ _selected_entry_key = StringName()
+ for index: int in _category_tabs.size():
+ (_category_tabs[index] as OrganizerTabType).set_selected(
+ index == int(_category)
+ )
+ _begin_category_transition()
+
+
+func _configure_category_focus() -> void:
+ for index: int in _category_tabs.size():
+ var tab: Button = _category_tabs[index]
+ tab.focus_neighbor_left = tab.get_path_to(
+ _category_tabs[maxi(index - 1, 0)]
+ )
+ tab.focus_neighbor_right = tab.get_path_to(
+ _category_tabs[mini(index + 1, _category_tabs.size() - 1)]
+ )
+
+
+func _animate_tab_entry() -> void:
+ for index: int in _category_tabs.size():
+ (_category_tabs[index] as OrganizerTabType).animate_entrance(
+ float(index) * 0.025
+ )
+
+
+func _settle_tabs_for_close() -> void:
+ for tab: OrganizerTabType in _category_tabs:
+ tab.settle_for_close()
+
+
+func _begin_category_transition() -> void:
+ _cancel_category_tween()
+ _category_generation += 1
+ var generation: int = _category_generation
+ var restore_interactive: bool = _interactive
+ set_interactive(false)
+ _category_tween = create_tween()
+ _category_tween.tween_method(
+ _set_category_content_alpha,
+ 1.0,
+ 0.0,
+ CATEGORY_FADE_DURATION * 0.5,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _category_tween.tween_callback(
+ func() -> void:
+ if generation != _category_generation:
+ return
+ _refresh_catalog()
+ _set_category_content_alpha(0.0)
+ )
+ _category_tween.tween_method(
+ _set_category_content_alpha,
+ 0.0,
+ 1.0,
+ CATEGORY_FADE_DURATION * 0.5,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _category_tween.finished.connect(
+ func() -> void:
+ if generation != _category_generation:
+ return
+ _category_tween = null
+ _set_category_content_alpha(1.0)
+ set_interactive(restore_interactive)
+ focus_initial(),
+ CONNECT_ONE_SHOT,
+ )
+
+
+func _cancel_category_tween() -> void:
+ _category_generation += 1
+ if _category_tween != null:
+ _category_tween.kill()
+ _category_tween = null
+ _set_category_content_alpha(1.0)
+
+
+func _set_category_content_alpha(alpha: float) -> void:
+ if _catalog_scroll != null:
+ _catalog_scroll.modulate.a = alpha
+ if _empty_state != null:
+ _empty_state.modulate.a = alpha
+ if _detail_body != null:
+ _detail_body.modulate.a = alpha
+
+
+func _select_entry(entry_key: StringName, fish_id: StringName) -> void:
+ if fish_id.is_empty():
+ _selected_id = StringName()
+ _selected_entry_key = entry_key
+ _refresh_selection_styles()
+ _show_unknown()
+ return
+ if (
+ _collection_log == null
+ or not _collection_log.has_discovered(fish_id)
+ ):
+ return
+ _selected_id = fish_id
+ _selected_entry_key = entry_key
+ _refresh_selection_styles()
+ _refresh_details(true)
+
+
+func _refresh_details(animate: bool) -> void:
+ if _selected_id.is_empty():
+ _show_no_selection()
+ return
+ if (
+ _collection_log == null
+ or not _collection_log.has_discovered(_selected_id)
+ ):
+ _show_unknown()
+ return
+ var fish: FishDataType = (
+ _catalog.get_fish_by_id(_selected_id)
+ if _catalog != null
+ else null
+ )
+ if fish == null:
+ _show_no_selection()
+ return
+ var rebuild: Callable = _build_known_details.bind(fish)
+ if animate:
+ _crossfade_details(rebuild)
+ else:
+ rebuild.call()
+
+
+func _build_known_details(fish: FishDataType) -> void:
+ _clear_details()
+ var catalog_number: int = LogbookCatalog.catalog_number(fish.id)
+ var summary_margin := MarginContainer.new()
+ summary_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _set_margins(summary_margin, 40, 0, 40, 0)
+ _detail_body.add_child(summary_margin)
+ var summary_columns := HBoxContainer.new()
+ summary_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ summary_columns.add_theme_constant_override("separation", 24)
+ summary_margin.add_child(summary_columns)
+
+ var portrait_column := VBoxContainer.new()
+ portrait_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ portrait_column.size_flags_stretch_ratio = 1.0
+ portrait_column.add_theme_constant_override("separation", 4)
+ summary_columns.add_child(portrait_column)
+ var title := _label(fish.display_name, 26)
+ title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ portrait_column.add_child(title)
+ if fish.display_texture != null:
+ _detail_portrait_button = Button.new()
+ _detail_portrait_button.custom_minimum_size = DETAIL_PORTRAIT_SIZE
+ _detail_portrait_button.size_flags_horizontal = (
+ Control.SIZE_SHRINK_CENTER
+ )
+ _detail_portrait_button.clip_contents = true
+ _detail_portrait_button.tooltip_text = "View larger artwork"
+ _detail_portrait_button.accessibility_name = (
+ "View larger artwork for %s" % fish.display_name
+ )
+ _detail_portrait_button.add_theme_stylebox_override(
+ "normal", _portrait_button_style(false)
+ )
+ _detail_portrait_button.add_theme_stylebox_override(
+ "hover", _portrait_button_style(true)
+ )
+ _detail_portrait_button.add_theme_stylebox_override(
+ "focus", _portrait_button_style(true)
+ )
+ _detail_portrait_button.add_theme_stylebox_override(
+ "pressed", _portrait_button_style(true)
+ )
+ _detail_portrait_button.pressed.connect(
+ _show_portrait_overlay.bind(fish.display_texture)
+ )
+ portrait_column.add_child(_detail_portrait_button)
+ var artwork_center := CenterContainer.new()
+ artwork_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ artwork_center.set_anchors_and_offsets_preset(
+ Control.PRESET_FULL_RECT
+ )
+ _detail_portrait_button.add_child(artwork_center)
+ var artwork := LogbookPortraitType.new()
+ artwork.configure(
+ fish.display_texture,
+ DETAIL_PORTRAIT_SIZE,
+ )
+ artwork_center.add_child(artwork)
+
+ var facts_column := VBoxContainer.new()
+ facts_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ facts_column.size_flags_stretch_ratio = 1.0
+ facts_column.add_theme_constant_override("separation", 6)
+ summary_columns.add_child(facts_column)
+ var facts_heading := _field_label("fish facts", 16)
+ facts_column.add_child(facts_heading)
+ var facts := _label(LogbookCatalog.facts_for(fish.id), 16)
+ facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ facts_column.add_child(facts)
+ _detail_body.add_child(_build_quality_progress(fish.id))
+
+ var stats_anchor := VBoxContainer.new()
+ stats_anchor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ stats_anchor.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ stats_anchor.alignment = BoxContainer.ALIGNMENT_END
+ _detail_body.add_child(stats_anchor)
+ var stats_columns := HBoxContainer.new()
+ stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ stats_columns.add_theme_constant_override("separation", 28)
+ stats_anchor.add_child(stats_columns)
+ var left_stats := _make_stats_column()
+ var right_stats := _make_stats_column()
+ stats_columns.add_child(left_stats)
+ stats_columns.add_child(right_stats)
+ _add_detail_row(
+ left_stats,
+ "catalog number",
+ "#%03d" % catalog_number if catalog_number > 0 else "unknown",
+ )
+ _add_detail_row(
+ left_stats,
+ "body of water",
+ WaterType.label(fish.get_primary_water_type()).to_lower(),
+ )
+ _add_detail_row(
+ left_stats,
+ "weight range",
+ "%.2f–%.2f lb" % [
+ fish.get_minimum_weight(),
+ fish.get_maximum_weight(),
+ ],
+ )
+ _add_detail_row(left_stats, "number caught", "unknown")
+ _add_detail_row(right_stats, "rarity", fish.get_rarity_name().to_lower())
+ _add_detail_row(right_stats, "time of day", _availability_text(fish))
+ _add_currency_detail_row(
+ right_stats,
+ "value range",
+ FishQualityType.apply_sale_value(
+ fish.sell_value_min,
+ FishQualityType.Tier.BORING,
+ ),
+ FishQualityType.apply_sale_value(
+ fish.sell_value_max,
+ FishQualityType.Tier.SHINY,
+ ),
+ )
+ _add_detail_row(
+ right_stats,
+ "number owned",
+ str(_inventory.get_count(fish.id) if _inventory != null else 0),
+ )
+ var bottom_inset := Control.new()
+ bottom_inset.custom_minimum_size.y = DETAIL_BOTTOM_INSET
+ bottom_inset.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ stats_anchor.add_child(bottom_inset)
+
+
+func _build_quality_progress(fish_id: StringName) -> VBoxContainer:
+ var quality_section := VBoxContainer.new()
+ quality_section.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ quality_section.add_theme_constant_override("separation", 6)
+ var discovered_count: int = 0
+ for quality: int in FishQualityType.TIER_COUNT:
+ if _collection_log.has_discovered_quality(fish_id, quality):
+ discovered_count += 1
+ var heading := _field_label(
+ "quality collection • %d / %d" % [
+ discovered_count,
+ FishQualityType.TIER_COUNT,
+ ],
+ 17,
+ )
+ heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ heading.add_theme_color_override("font_color", MUTED_INK)
+ quality_section.add_child(heading)
+ var tiers := VBoxContainer.new()
+ tiers.add_theme_constant_override("separation", 6)
+ quality_section.add_child(tiers)
+ for quality: int in FishQualityType.TIER_COUNT:
+ var row: HBoxContainer
+ if quality % 2 == 0:
+ row = HBoxContainer.new()
+ row.alignment = BoxContainer.ALIGNMENT_CENTER
+ row.add_theme_constant_override("separation", 28)
+ tiers.add_child(row)
+ else:
+ row = tiers.get_child(tiers.get_child_count() - 1) as HBoxContainer
+ var discovered: bool = _collection_log.has_discovered_quality(
+ fish_id,
+ quality,
+ )
+ var tier := HBoxContainer.new()
+ tier.alignment = BoxContainer.ALIGNMENT_CENTER
+ tier.add_theme_constant_override("separation", 5)
+ var quality_color: Color = UIPalette.get_quality_color(quality)
+ var dot := _label("●" if discovered else "○", 17)
+ dot.add_theme_color_override("font_color", quality_color)
+ dot.modulate.a = 1.0 if discovered else 0.48
+ tier.add_child(dot)
+ var tier_label := _label(FishQualityType.display_name(quality), 16)
+ tier_label.add_theme_color_override(
+ "font_color",
+ INK if discovered else MUTED_INK,
+ )
+ tier_label.modulate.a = 1.0 if discovered else 0.58
+ tier.tooltip_text = (
+ "%s quality collected"
+ if discovered
+ else "%s quality not yet collected"
+ ) % FishQualityType.display_name(quality)
+ tier.add_child(tier_label)
+ row.add_child(tier)
+ return quality_section
+
+
+func _show_no_selection() -> void:
+ _clear_details()
+ var instruction := _label("Select an entry to read its catch record.", 21)
+ instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ instruction.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ instruction.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _detail_body.add_child(instruction)
+
+
+func _show_unknown() -> void:
+ _clear_details()
+ var unknown := _label("???", 34)
+ unknown.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _detail_body.add_child(unknown)
+ var instruction := _label("Catch this creature to learn more.", 20)
+ instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _detail_body.add_child(instruction)
+
+
+func _add_detail_row(
+ parent: VBoxContainer,
+ row_name: String,
+ value: String,
+) -> void:
+ var row := VBoxContainer.new()
+ row.add_theme_constant_override("separation", 2)
+ var heading := _field_label(row_name, 14)
+ heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ heading.add_theme_color_override("font_color", MUTED_INK)
+ var content := _handwritten_value_label(value.to_lower(), 16)
+ content.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ content.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ row.add_child(heading)
+ row.add_child(content)
+ parent.add_child(row)
+
+
+func _add_currency_detail_row(
+ parent: VBoxContainer,
+ row_name: String,
+ minimum_value: int,
+ maximum_value: int,
+) -> void:
+ var row := VBoxContainer.new()
+ row.add_theme_constant_override("separation", 2)
+ var heading := _field_label(row_name, 14)
+ heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ heading.add_theme_color_override("font_color", MUTED_INK)
+ row.add_child(heading)
+ var value: CurrencyAmount = (
+ CurrencyPresentationType.instantiate_amount(minimum_value, 16.0)
+ )
+ value.set_amount_text("%d–%d" % [minimum_value, maximum_value])
+ value.get_amount_label().add_theme_font_override(
+ "font", InventoryNotepadType.HANDWRITTEN_FONT
+ )
+ value.get_amount_label().add_theme_font_size_override("font_size", 16)
+ value.get_amount_label().add_theme_color_override("font_color", INK)
+ row.add_child(value)
+ parent.add_child(row)
+
+
+func _make_stats_column() -> VBoxContainer:
+ var column := VBoxContainer.new()
+ column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ column.size_flags_stretch_ratio = 1.0
+ column.add_theme_constant_override("separation", 12)
+ return column
+
+
+func _availability_text(fish: FishDataType) -> String:
+ if fish.availability == null:
+ return "unknown"
+ if fish.availability.allow_day and fish.availability.allow_night:
+ return "day and night"
+ if fish.availability.allow_day:
+ return "day"
+ if fish.availability.allow_night:
+ return "night"
+ return "unknown"
+
+
+func _crossfade_details(rebuild: Callable) -> void:
+ _cancel_detail_tween()
+ _detail_generation += 1
+ var generation: int = _detail_generation
+ _detail_tween = create_tween()
+ _detail_tween.tween_property(
+ _detail_body, "modulate:a", 0.0, DETAIL_FADE_DURATION * 0.5
+ )
+ _detail_tween.tween_callback(
+ func() -> void:
+ if generation == _detail_generation:
+ rebuild.call()
+ )
+ _detail_tween.tween_property(
+ _detail_body, "modulate:a", 1.0, DETAIL_FADE_DURATION * 0.5
+ )
+ _detail_tween.finished.connect(
+ func() -> void:
+ if generation == _detail_generation:
+ _detail_tween = null,
+ CONNECT_ONE_SHOT,
+ )
+
+
+func _cancel_detail_tween() -> void:
+ _detail_generation += 1
+ if _detail_tween != null:
+ _detail_tween.kill()
+ _detail_tween = null
+ if _detail_body != null:
+ _detail_body.modulate.a = 1.0
+
+
+func _clear_entries() -> void:
+ for child: Node in _catalog_grid.get_children():
+ _catalog_grid.remove_child(child)
+ child.queue_free()
+ _entry_buttons.clear()
+
+
+func _clear_details() -> void:
+ _detail_portrait_button = null
+ for child: Node in _detail_body.get_children():
+ _detail_body.remove_child(child)
+ child.queue_free()
+
+
+func _refresh_selection_styles() -> void:
+ for fish_id: StringName in _entry_buttons:
+ _entry_buttons[fish_id].button_pressed = (
+ fish_id == _selected_entry_key
+ )
+
+
+func _on_fish_discovered(fish_id: StringName) -> void:
+ if _catalog == null:
+ return
+ var fish: FishDataType = _catalog.get_fish_by_id(fish_id)
+ if fish == null:
+ return
+ if _selected_entry_key == _unknown_selection_key(fish):
+ _selected_id = fish_id
+ _selected_entry_key = fish_id
+ _refresh_catalog()
+
+
+func _on_collection_changed() -> void:
+ _refresh_catalog()
+
+
+func _on_inventory_changed() -> void:
+ _refresh_details(false)
+
+
+func _unknown_selection_key(fish: FishDataType) -> StringName:
+ var number: int = LogbookCatalog.catalog_number(fish.id)
+ return StringName("unknown_%d" % number)
+
+
+func _label(text: String, font_size: int) -> Label:
+ var label := Label.new()
+ label.text = text
+ label.add_theme_font_override(
+ "font", InventoryNotepadType.HANDWRITTEN_FONT
+ )
+ label.add_theme_font_size_override("font_size", font_size)
+ label.add_theme_color_override("font_color", INK)
+ return label
+
+
+func _field_label(text: String, font_size: int) -> Label:
+ var label := Label.new()
+ label.text = text
+ label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ label.add_theme_font_size_override("font_size", font_size)
+ label.add_theme_color_override("font_color", INK)
+ return label
+
+
+func _handwritten_value_label(text: String, font_size: int) -> Label:
+ var adjusted_size: int = font_size
+ if _contains_ascii_digit(text):
+ adjusted_size = roundi(
+ float(font_size) * HANDWRITTEN_NUMERIC_SCALE
+ )
+ return _label(text, adjusted_size)
+
+
+func _contains_ascii_digit(text: String) -> bool:
+ for codepoint: int in text.to_utf32_buffer():
+ if codepoint >= 48 and codepoint <= 57:
+ return true
+ return false
+
+
+func _entry_label_text(entry_name: String) -> String:
+ var split_index: int = entry_name.find(" ")
+ if split_index < 0:
+ return entry_name
+ return (
+ entry_name.substr(0, split_index)
+ + "\n"
+ + entry_name.substr(split_index + 1)
+ )
+
+
+func _build_portrait_overlay() -> void:
+ _portrait_overlay = Control.new()
+ _portrait_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ _portrait_overlay.z_index = 500
+ _portrait_overlay.visible = false
+ _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ add_child(_portrait_overlay)
+
+ _portrait_overlay_backdrop = Button.new()
+ _portrait_overlay_backdrop.set_anchors_and_offsets_preset(
+ Control.PRESET_FULL_RECT
+ )
+ _portrait_overlay_backdrop.text = ""
+ _portrait_overlay_backdrop.tooltip_text = "Return to logbook"
+ _portrait_overlay_backdrop.accessibility_name = "Return to logbook"
+ _portrait_overlay_backdrop.focus_neighbor_top = NodePath(".")
+ _portrait_overlay_backdrop.focus_neighbor_bottom = NodePath(".")
+ _portrait_overlay_backdrop.focus_neighbor_left = NodePath(".")
+ _portrait_overlay_backdrop.focus_neighbor_right = NodePath(".")
+ _portrait_overlay_backdrop.focus_next = NodePath(".")
+ _portrait_overlay_backdrop.focus_previous = NodePath(".")
+ var transparent_style := StyleBoxEmpty.new()
+ for state: StringName in [
+ &"normal", &"hover", &"focus", &"pressed", &"disabled",
+ ]:
+ _portrait_overlay_backdrop.add_theme_stylebox_override(
+ state, transparent_style
+ )
+ _portrait_overlay_backdrop.pressed.connect(_hide_portrait_overlay)
+ _portrait_overlay_backdrop.gui_input.connect(
+ _on_portrait_overlay_backdrop_input
+ )
+ _portrait_overlay.add_child(_portrait_overlay_backdrop)
+
+ var center := CenterContainer.new()
+ center.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ _portrait_overlay.add_child(center)
+ var card := PanelContainer.new()
+ card.mouse_filter = Control.MOUSE_FILTER_STOP
+ card.add_theme_stylebox_override("panel", _portrait_view_style())
+ center.add_child(card)
+ var margin := MarginContainer.new()
+ _set_margins(margin, 22, 22, 22, 22)
+ card.add_child(margin)
+ _portrait_overlay_artwork = LogbookPortraitType.new()
+ margin.add_child(_portrait_overlay_artwork)
+
+
+func _show_portrait_overlay(portrait_texture: Texture2D) -> void:
+ if portrait_texture == null or _portrait_overlay == null:
+ return
+ _portrait_overlay_artwork.configure_fitted(
+ portrait_texture,
+ PORTRAIT_VIEW_MAX_SIZE,
+ )
+ _portrait_overlay.visible = true
+ _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
+ _portrait_overlay_backdrop.grab_focus()
+
+
+func _hide_portrait_overlay(restore_focus: bool = true) -> void:
+ if _portrait_overlay == null or not _portrait_overlay.visible:
+ return
+ _portrait_overlay.visible = false
+ _portrait_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ if (
+ restore_focus
+ and _detail_portrait_button != null
+ and is_instance_valid(_detail_portrait_button)
+ and _active
+ and _interactive
+ ):
+ _detail_portrait_button.grab_focus()
+
+
+func _on_portrait_overlay_backdrop_input(event: InputEvent) -> void:
+ if event.is_action_pressed("ui_cancel"):
+ _hide_portrait_overlay()
+ get_viewport().set_input_as_handled()
+
+
+func _portrait_button_style(highlighted: bool) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = (
+ Color(1.0, 0.96, 0.86, 0.48)
+ if highlighted
+ else Color.TRANSPARENT
+ )
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(12)
+ return style
+
+
+func _portrait_view_style() -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = Color("fff5dc")
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(18)
+ return style
+
+
+func _entry_style(highlighted: bool) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = Color("fff5dc") if highlighted else Color.TRANSPARENT
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(51)
+ style.content_margin_left = 5
+ style.content_margin_top = 5
+ style.content_margin_right = 5
+ style.content_margin_bottom = 5
+ return style
+
+
+func _set_margins(
+ container: MarginContainer,
+ left: int,
+ top: int,
+ right: int,
+ bottom: int,
+) -> void:
+ container.add_theme_constant_override("margin_left", left)
+ container.add_theme_constant_override("margin_top", top)
+ container.add_theme_constant_override("margin_right", right)
+ container.add_theme_constant_override("margin_bottom", bottom)
diff --git a/ui/logbook_page.gd.uid b/ui/logbook_page.gd.uid
new file mode 100644
index 0000000..6234c9c
--- /dev/null
+++ b/ui/logbook_page.gd.uid
@@ -0,0 +1 @@
+uid://oo5eek8i04xy
diff --git a/ui/logbook_page.tscn b/ui/logbook_page.tscn
new file mode 100644
index 0000000..f5c8c51
--- /dev/null
+++ b/ui/logbook_page.tscn
@@ -0,0 +1,12 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/logbook_page.gd" id="1_logbook"]
+
+[node name="LogbookPage" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_logbook")
diff --git a/ui/logbook_silhouette.gdshader b/ui/logbook_silhouette.gdshader
new file mode 100644
index 0000000..89ac208
--- /dev/null
+++ b/ui/logbook_silhouette.gdshader
@@ -0,0 +1,8 @@
+shader_type canvas_item;
+
+uniform vec4 silhouette_color : source_color = vec4(0.22, 0.23, 0.24, 1.0);
+
+void fragment() {
+ vec4 source = texture(TEXTURE, UV);
+ COLOR = vec4(silhouette_color.rgb, source.a * silhouette_color.a);
+}
diff --git a/ui/logbook_silhouette.gdshader.uid b/ui/logbook_silhouette.gdshader.uid
new file mode 100644
index 0000000..2dd5442
--- /dev/null
+++ b/ui/logbook_silhouette.gdshader.uid
@@ -0,0 +1 @@
+uid://1lqrwon78hwm
diff --git a/ui/mail_page.gd b/ui/mail_page.gd
new file mode 100644
index 0000000..f1bd37d
--- /dev/null
+++ b/ui/mail_page.gd
@@ -0,0 +1,796 @@
+class_name MailPage
+extends Control
+
+const GREETING_LABELS := {
+ "dear": "Dear",
+ "to": "To",
+ "hey": "Hey",
+ "greetings": "Greetings",
+}
+const SALUTATION_LABELS := {
+ "love": "Love",
+ "from": "From",
+ "cheers": "Cheers",
+ "salutations": "Salutations",
+ "good_luck_have_fun": "Good Luck Have Fun",
+}
+const FishQualityType = preload("res://fish/fish_quality.gd")
+const CurrencyPresentationType = preload(
+ "res://ui/currency_presentation.gd"
+)
+
+var _service: NetworkMailService
+var _reservations: PlayerAssetReservationService
+var _inventory: FishInventory
+var _wallet: PlayerWallet
+var _bag: PlayerBag
+var _catalog: ItemCatalog
+
+var _inbox: Control
+var _compose: Control
+var _letter: Control
+var _inbox_list: VBoxContainer
+var _empty_label: Label
+var _recipient: OptionButton
+var _greeting: OptionButton
+var _body: TextEdit
+var _salutation: OptionButton
+var _signature: Label
+var _attachment_kind: OptionButton
+var _attachment_choice: OptionButton
+var _attachment_amount: LineEdit
+var _coin_available_heading: Label
+var _coin_available: CurrencyAmount
+var _attachment_amount_currency_icon: TextureRect
+var _amount_minus: Button
+var _amount_plus: Button
+var _attachment_summary: RichTextLabel
+var _send_button: Button
+var _status: Label
+var _letter_text: Label
+var _letter_gift: RichTextLabel
+var _accept: Button
+var _decline: Button
+var _archive: Button
+var _delete: Button
+var _current_mail_id := ""
+var _showing_archive := false
+
+
+func _ready() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ UtilityPageStyle.apply_page(self)
+ _build_ui()
+
+
+func setup(
+ service: NetworkMailService,
+ reservations: PlayerAssetReservationService,
+ inventory: FishInventory,
+ wallet: PlayerWallet,
+ bag: PlayerBag,
+ catalog: ItemCatalog,
+) -> void:
+ _service = service
+ _reservations = reservations
+ _inventory = inventory
+ _wallet = wallet
+ _bag = bag
+ _catalog = catalog
+ _service.mailbox_changed.connect(_refresh_inbox)
+ _service.peers_changed.connect(_refresh_recipients)
+ _service.operation_finished.connect(_on_operation_finished)
+ _refresh_inbox()
+ _refresh_recipients()
+
+
+func activate() -> void:
+ _show_inbox()
+ UtilityPageStyle.animate_in(self)
+ call_deferred("_focus_first")
+
+
+func deactivate() -> void:
+ _current_mail_id = ""
+ _show_inbox()
+
+
+func consume_escape() -> bool:
+ if _compose.visible or _letter.visible:
+ _show_inbox()
+ return true
+ return false
+
+
+func is_composing_letter() -> bool:
+ return _compose != null and _compose.visible
+
+
+func set_interactive(value: bool) -> void:
+ mouse_filter = Control.MOUSE_FILTER_PASS if value else Control.MOUSE_FILTER_IGNORE
+ for node: Node in find_children("*", "BaseButton", true, false):
+ (node as BaseButton).focus_mode = (
+ Control.FOCUS_ALL if value and node.is_visible_in_tree()
+ else Control.FOCUS_NONE
+ )
+
+
+func _build_ui() -> void:
+ var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self)
+ var root := Control.new()
+ margin.add_child(root)
+ _inbox = _build_inbox()
+ _compose = _build_compose()
+ _letter = _build_letter()
+ for page: Control in [_inbox, _compose, _letter]:
+ page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ root.add_child(page)
+ _status = Label.new()
+ _status.position = Vector2(18, 408)
+ _status.size = Vector2(1024, 28)
+ _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _status.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ root.add_child(_status)
+ _style_controls(root)
+ _show_inbox()
+
+
+func _build_inbox() -> Control:
+ var page := Control.new()
+ var title := Label.new()
+ title.text = "mail"
+ title.position = Vector2(12, 0)
+ title.size = Vector2(500, 42)
+ title.add_theme_font_size_override("font_size", 30)
+ page.add_child(title)
+ var send := Button.new()
+ send.text = "send mail"
+ send.position = Vector2(820, 0)
+ send.size = Vector2(150, 48)
+ send.pressed.connect(_show_compose)
+ page.add_child(send)
+ var archive_view := Button.new()
+ archive_view.text = "archive"
+ archive_view.position = Vector2(654, 0)
+ archive_view.size = Vector2(154, 48)
+ archive_view.pressed.connect(func() -> void:
+ _showing_archive = not _showing_archive
+ archive_view.text = "inbox" if _showing_archive else "archive"
+ _refresh_inbox()
+ )
+ page.add_child(archive_view)
+ var scroll := ScrollContainer.new()
+ scroll.position = Vector2(12, 62)
+ scroll.size = Vector2(1036, 334)
+ page.add_child(scroll)
+ _inbox_list = VBoxContainer.new()
+ _inbox_list.custom_minimum_size = Vector2(1008, 0)
+ _inbox_list.add_theme_constant_override("separation", 8)
+ scroll.add_child(_inbox_list)
+ _empty_label = Label.new()
+ _empty_label.text = "No letters yet."
+ _empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _empty_label.custom_minimum_size = Vector2(1008, 80)
+ _inbox_list.add_child(_empty_label)
+ return page
+
+
+func _build_compose() -> Control:
+ var page := Control.new()
+ var title := Label.new()
+ title.text = "write a letter"
+ title.position = Vector2(12, 0)
+ title.size = Vector2(300, 36)
+ title.add_theme_font_size_override("font_size", 26)
+ page.add_child(title)
+ _greeting = OptionButton.new()
+ _greeting.position = Vector2(12, 48)
+ _greeting.size = Vector2(180, 46)
+ for id: String in NetworkMailProtocol.GREETINGS:
+ _greeting.add_item(GREETING_LABELS[id])
+ _greeting.set_item_metadata(_greeting.item_count - 1, id)
+ page.add_child(_greeting)
+ _recipient = OptionButton.new()
+ _recipient.position = Vector2(204, 48)
+ _recipient.size = Vector2(330, 46)
+ page.add_child(_recipient)
+ _body = TextEdit.new()
+ _body.position = Vector2(12, 106)
+ _body.size = Vector2(650, 218)
+ _body.placeholder_text = "write your letter…"
+ _body.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
+ _body.text_changed.connect(_update_send_state)
+ page.add_child(_body)
+ _salutation = OptionButton.new()
+ _salutation.position = Vector2(12, 336)
+ _salutation.size = Vector2(250, 46)
+ for id: String in NetworkMailProtocol.SALUTATIONS:
+ _salutation.add_item(SALUTATION_LABELS[id])
+ _salutation.set_item_metadata(_salutation.item_count - 1, id)
+ page.add_child(_salutation)
+ _signature = Label.new()
+ _signature.position = Vector2(274, 342)
+ _signature.size = Vector2(360, 36)
+ page.add_child(_signature)
+ _attachment_kind = OptionButton.new()
+ _attachment_kind.position = Vector2(688, 48)
+ _attachment_kind.size = Vector2(280, 46)
+ for label: String in ["No gift", "Currency", "Fish", "Consumable"]:
+ _attachment_kind.add_item(label)
+ _attachment_kind.item_selected.connect(_refresh_attachment_choices)
+ page.add_child(_attachment_kind)
+ _attachment_choice = OptionButton.new()
+ _attachment_choice.position = Vector2(688, 106)
+ _attachment_choice.size = Vector2(280, 46)
+ _attachment_choice.item_selected.connect(
+ func(_index: int) -> void:
+ _update_attachment_amount_limit()
+ _update_attachment_summary()
+ )
+ page.add_child(_attachment_choice)
+ _coin_available_heading = Label.new()
+ _coin_available_heading.text = "Available"
+ _coin_available_heading.position = Vector2(688, 158)
+ _coin_available_heading.size = Vector2(80, 28)
+ page.add_child(_coin_available_heading)
+ _coin_available = CurrencyPresentationType.instantiate_amount(0, 18.0)
+ _coin_available.position = Vector2(770, 158)
+ _coin_available.size = Vector2(198, 28)
+ _coin_available.alignment = BoxContainer.ALIGNMENT_BEGIN
+ page.add_child(_coin_available)
+ _attachment_amount_currency_icon = TextureRect.new()
+ _attachment_amount_currency_icon.position = Vector2(746, 204)
+ _attachment_amount_currency_icon.size = Vector2(18, 18)
+ _attachment_amount_currency_icon.texture = preload(
+ "res://items/icons/shop/32_currency.png"
+ )
+ _attachment_amount_currency_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ _attachment_amount_currency_icon.stretch_mode = (
+ TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ )
+ _attachment_amount_currency_icon.texture_filter = (
+ CanvasItem.TEXTURE_FILTER_NEAREST
+ )
+ _attachment_amount_currency_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ page.add_child(_attachment_amount_currency_icon)
+ _attachment_amount = LineEdit.new()
+ _attachment_amount.position = Vector2(770, 190)
+ _attachment_amount.size = Vector2(140, 46)
+ _attachment_amount.placeholder_text = "0"
+ _attachment_amount.text = "0"
+ _attachment_amount.text_changed.connect(_on_attachment_amount_changed)
+ page.add_child(_attachment_amount)
+ _amount_minus = Button.new()
+ _amount_minus.text = "−"
+ _amount_minus.position = Vector2(688, 190)
+ _amount_minus.size = Vector2(48, 46)
+ _amount_minus.pressed.connect(_step_attachment_amount.bind(-1))
+ page.add_child(_amount_minus)
+ _amount_plus = Button.new()
+ _amount_plus.text = "+"
+ _amount_plus.position = Vector2(920, 190)
+ _amount_plus.size = Vector2(48, 46)
+ _amount_plus.pressed.connect(_step_attachment_amount.bind(1))
+ page.add_child(_amount_plus)
+ _attachment_summary = RichTextLabel.new()
+ _attachment_summary.position = Vector2(688, 246)
+ _attachment_summary.size = Vector2(280, 78)
+ _attachment_summary.bbcode_enabled = true
+ _attachment_summary.fit_content = true
+ _attachment_summary.scroll_active = false
+ _attachment_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ page.add_child(_attachment_summary)
+ var cancel := Button.new()
+ cancel.text = "cancel"
+ cancel.position = Vector2(688, 342)
+ cancel.size = Vector2(126, 48)
+ cancel.pressed.connect(_show_inbox)
+ page.add_child(cancel)
+ _send_button = Button.new()
+ _send_button.text = "send letter"
+ _send_button.position = Vector2(826, 342)
+ _send_button.size = Vector2(142, 48)
+ _send_button.pressed.connect(_send)
+ page.add_child(_send_button)
+ _recipient.item_selected.connect(func(_i: int) -> void:
+ _reset_attachment_controls()
+ _update_send_state()
+ )
+ _refresh_attachment_choices(0)
+ return page
+
+
+func _build_letter() -> Control:
+ var page := Control.new()
+ _letter_text = Label.new()
+ _letter_text.position = Vector2(26, 20)
+ _letter_text.size = Vector2(680, 350)
+ _letter_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _letter_text.vertical_alignment = VERTICAL_ALIGNMENT_TOP
+ page.add_child(_letter_text)
+ _letter_gift = RichTextLabel.new()
+ _letter_gift.position = Vector2(730, 46)
+ _letter_gift.size = Vector2(230, 180)
+ _letter_gift.bbcode_enabled = true
+ _letter_gift.fit_content = true
+ _letter_gift.scroll_active = false
+ _letter_gift.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ page.add_child(_letter_gift)
+ _accept = Button.new()
+ _accept.text = "accept gift"
+ _accept.position = Vector2(730, 250)
+ _accept.size = Vector2(230, 48)
+ _accept.pressed.connect(func() -> void:
+ _service.accept_gift(_current_mail_id)
+ )
+ page.add_child(_accept)
+ _decline = Button.new()
+ _decline.text = "decline gift"
+ _decline.position = Vector2(730, 308)
+ _decline.size = Vector2(230, 48)
+ _decline.pressed.connect(func() -> void:
+ _service.decline_gift(_current_mail_id)
+ )
+ page.add_child(_decline)
+ var close := Button.new()
+ close.text = "close"
+ close.position = Vector2(26, 370)
+ close.size = Vector2(150, 48)
+ close.pressed.connect(_show_inbox)
+ page.add_child(close)
+ _archive = Button.new()
+ _archive.text = "archive"
+ _archive.position = Vector2(550, 370)
+ _archive.size = Vector2(150, 48)
+ _archive.pressed.connect(_archive_current)
+ page.add_child(_archive)
+ _delete = Button.new()
+ _delete.text = "delete"
+ _delete.position = Vector2(710, 370)
+ _delete.size = Vector2(150, 48)
+ _delete.pressed.connect(_delete_current)
+ page.add_child(_delete)
+ return page
+
+
+func _show_inbox() -> void:
+ _inbox.show()
+ _compose.hide()
+ _letter.hide()
+ _current_mail_id = ""
+ _refresh_inbox()
+
+
+func _show_compose() -> void:
+ _inbox.hide()
+ _compose.show()
+ _letter.hide()
+ _reset_compose()
+ _signature.text = _service.get_local_display_name()
+ _refresh_recipients()
+ _update_send_state()
+ _greeting.grab_focus()
+
+
+func _reset_compose() -> void:
+ _recipient.select(0)
+ _greeting.select(0)
+ _body.clear()
+ _salutation.select(0)
+ _status.text = ""
+ _reset_attachment_controls()
+
+
+func _reset_attachment_controls() -> void:
+ _attachment_kind.select(0)
+ _attachment_choice.clear()
+ _attachment_choice.add_item("No attachment")
+ _attachment_amount.text = "0"
+ _refresh_attachment_choices(0)
+
+
+func _open_letter(mail_id: String) -> void:
+ var letter := _service.get_letter(mail_id)
+ if letter.is_empty():
+ return
+ _current_mail_id = mail_id
+ _service.mark_read(mail_id)
+ _inbox.hide()
+ _compose.hide()
+ _letter.show()
+ _letter_text.text = "%s %s,\n\n%s\n\n%s,\n%s" % [
+ GREETING_LABELS.get(letter["greeting_id"], "Dear"),
+ letter.get("recipient_display_name", "you"),
+ letter["body"],
+ SALUTATION_LABELS.get(letter["salutation_id"], "From"),
+ letter["sender_display_name"],
+ ]
+ _letter_gift.text = _attachment_state_text(letter)
+ var pending := (
+ int(letter["attachment"].get("type", 0)) != 0
+ and int(letter["state"]) in [
+ NetworkMailProtocol.State.SENT_UNREAD,
+ NetworkMailProtocol.State.READ,
+ ]
+ and _service.is_local_recipient(letter)
+ )
+ _accept.visible = pending
+ _decline.visible = pending
+ _archive.text = (
+ "restore to inbox"
+ if _service.is_letter_archived(mail_id)
+ else "archive"
+ )
+ _delete.disabled = pending
+ _delete.tooltip_text = (
+ "Resolve this gift before deleting the letter." if pending else ""
+ )
+ if pending:
+ _accept.grab_focus()
+
+
+func _refresh_inbox() -> void:
+ if _service == null or _inbox_list == null:
+ return
+ for child: Node in _inbox_list.get_children():
+ child.queue_free()
+ var letters := _service.get_local_letters(_showing_archive)
+ if letters.is_empty():
+ var empty := Label.new()
+ empty.text = (
+ "No archived letters." if _showing_archive else "No letters yet."
+ )
+ empty.custom_minimum_size = Vector2(1008, 80)
+ empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _inbox_list.add_child(empty)
+ return
+ for letter: Dictionary in letters:
+ var button := Button.new()
+ var first_line: String = str(letter["body"]).split("\n", false)[0]
+ var unread := (
+ _service.is_local_recipient(letter)
+ and int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD
+ )
+ var gift := int(letter["attachment"].get("type", 0)) != 0
+ var local_is_sender := (
+ int(letter["sender_peer_id"]) != 0
+ and not _service.is_local_recipient(letter)
+ )
+ button.text = "%s%s%s — %s%s" % [
+ "new · " if unread else "",
+ "to " if local_is_sender else "",
+ (
+ letter["recipient_display_name"]
+ if local_is_sender
+ else letter["sender_display_name"]
+ ),
+ first_line.left(72),
+ " · gift enclosed" if gift else "",
+ ]
+ button.custom_minimum_size = Vector2(1008, 54)
+ button.alignment = HORIZONTAL_ALIGNMENT_LEFT
+ button.pressed.connect(_open_letter.bind(str(letter["mail_id"])))
+ UtilityPageStyle.apply_ocean_button(button)
+ _inbox_list.add_child(button)
+
+
+func _refresh_recipients() -> void:
+ if _service == null or _recipient == null:
+ return
+ var selected_peer := (
+ int(_recipient.get_selected_metadata())
+ if _recipient.selected >= 0
+ and _recipient.get_selected_metadata() != null else 0
+ )
+ _recipient.clear()
+ _recipient.add_item("select a player")
+ _recipient.set_item_metadata(0, 0)
+ var retained := false
+ for choice: Dictionary in _service.get_recipient_choices():
+ _recipient.add_item(choice["label"])
+ _recipient.set_item_metadata(
+ _recipient.item_count - 1, choice["peer_id"]
+ )
+ if int(choice["peer_id"]) == selected_peer:
+ _recipient.select(_recipient.item_count - 1)
+ retained = true
+ if selected_peer > 0 and not retained:
+ _recipient.select(0)
+ if _status != null:
+ _status.text = "That player is no longer connected."
+ _update_send_state()
+
+
+func _refresh_attachment_choices(_index: int) -> void:
+ if _attachment_choice == null:
+ return
+ _attachment_choice.clear()
+ var amount_visible := _attachment_kind.selected in [1, 3]
+ _attachment_amount.visible = amount_visible
+ _amount_minus.visible = amount_visible
+ _amount_plus.visible = amount_visible
+ var currency_selected: bool = _attachment_kind.selected == 1
+ _coin_available_heading.visible = currency_selected
+ _coin_available.visible = currency_selected
+ _attachment_amount_currency_icon.visible = currency_selected
+ _attachment_amount.position.x = 770.0 if currency_selected else 746.0
+ _attachment_amount.size.x = 140.0 if currency_selected else 164.0
+ match _attachment_kind.selected:
+ 0:
+ _attachment_choice.add_item("No attachment")
+ 1:
+ _attachment_choice.add_item("Currency")
+ _coin_available.set_amount(
+ _reservations.get_available_fish_coin()
+ )
+ 2:
+ for fish_catch: FishCatch in _inventory.get_all_catches():
+ if (
+ not fish_catch.is_favorited
+ and not _reservations.is_fish_reserved(fish_catch.catch_id)
+ ):
+ _attachment_choice.add_item(
+ "%s — %.1f lb" % [
+ FishQualityType.qualified_name(
+ fish_catch.fish.display_name,
+ fish_catch.quality,
+ ),
+ fish_catch.weight_lb,
+ ]
+ )
+ _attachment_choice.set_item_metadata(
+ _attachment_choice.item_count - 1, str(fish_catch.catch_id)
+ )
+ 3:
+ for item: ItemData in _catalog.get_valid_items():
+ var available := _reservations.get_available_item_quantity(
+ item.item_id
+ )
+ if item.category == ItemData.Category.CONSUMABLE and available > 0:
+ _attachment_choice.add_item(
+ "%s ×%d" % [item.display_name, available]
+ )
+ _attachment_choice.set_item_metadata(
+ _attachment_choice.item_count - 1, str(item.item_id)
+ )
+ _update_attachment_amount_limit()
+ _update_attachment_summary()
+
+
+func _update_attachment_amount_limit() -> void:
+ _set_attachment_amount(_attachment_amount_value())
+
+
+func _make_attachment() -> Dictionary:
+ match _attachment_kind.selected:
+ 1:
+ return {
+ "type": PlayerAssetReservationService.AttachmentType.FISH_COIN,
+ "amount": _attachment_amount_value(),
+ }
+ 2:
+ if _attachment_choice.selected < 0:
+ return {}
+ var catch_id := StringName(str(_attachment_choice.get_selected_metadata()))
+ var fish_catch := _inventory.get_catch_by_id(catch_id)
+ if fish_catch == null:
+ return {}
+ return {
+ "type": PlayerAssetReservationService.AttachmentType.FISH,
+ "catch_id": str(catch_id),
+ "catch": fish_catch.to_network_dict(),
+ }
+ 3:
+ if _attachment_choice.selected < 0:
+ return {}
+ return {
+ "type": PlayerAssetReservationService.AttachmentType.CONSUMABLE,
+ "item_id": str(_attachment_choice.get_selected_metadata()),
+ "quantity": _attachment_amount_value(),
+ }
+ return {"type": PlayerAssetReservationService.AttachmentType.NONE}
+
+
+func _update_attachment_summary() -> void:
+ if _attachment_summary == null:
+ return
+ var attachment := _make_attachment()
+ _attachment_summary.text = _attachment_text(attachment)
+ _update_send_state()
+
+
+func _update_send_state() -> void:
+ if _send_button == null:
+ return
+ var recipient_id := (
+ int(_recipient.get_selected_metadata())
+ if _recipient.selected >= 0 and _recipient.get_selected_metadata() != null
+ else 0
+ )
+ var attachment := _make_attachment()
+ _send_button.disabled = (
+ recipient_id <= 0
+ or NetworkMailProtocol.sanitize_body(_body.text).is_empty()
+ or attachment.is_empty()
+ or not _reservations.validate_attachment(attachment)
+ or not _attachment_is_available(attachment)
+ )
+
+
+func _attachment_is_available(attachment: Dictionary) -> bool:
+ match int(attachment.get("type", 0)):
+ PlayerAssetReservationService.AttachmentType.NONE:
+ return true
+ PlayerAssetReservationService.AttachmentType.FISH_COIN:
+ return (
+ int(attachment["amount"])
+ <= _reservations.get_available_fish_coin()
+ )
+ PlayerAssetReservationService.AttachmentType.FISH:
+ return not _reservations.is_fish_reserved(
+ StringName(str(attachment["catch_id"]))
+ )
+ PlayerAssetReservationService.AttachmentType.CONSUMABLE:
+ return (
+ int(attachment["quantity"])
+ <= _reservations.get_available_item_quantity(
+ StringName(str(attachment["item_id"]))
+ )
+ )
+ return false
+
+
+func _send() -> void:
+ _set_attachment_amount(_attachment_amount_value())
+ var recipient_id := int(_recipient.get_selected_metadata())
+ var greeting_id := str(_greeting.get_selected_metadata())
+ var salutation_id := str(_salutation.get_selected_metadata())
+ if _service.send_letter(
+ recipient_id, greeting_id, _body.text, salutation_id, _make_attachment()
+ ):
+ _send_button.disabled = true
+ _status.text = "Sending letter…"
+
+
+func _on_operation_finished(success: bool, message: String) -> void:
+ _status.text = message
+ if success and _compose.visible:
+ _show_inbox()
+ elif _letter.visible and not _current_mail_id.is_empty():
+ _open_letter(_current_mail_id)
+ else:
+ _update_send_state()
+
+
+func _attachment_amount_value() -> int:
+ return int(_attachment_amount.text) if _attachment_amount.text.is_valid_int() else 0
+
+
+func _attachment_amount_maximum() -> int:
+ if _attachment_kind.selected == 1:
+ return _reservations.get_available_fish_coin()
+ if _attachment_kind.selected == 3 and _attachment_choice.selected >= 0:
+ return _reservations.get_available_item_quantity(
+ StringName(str(_attachment_choice.get_selected_metadata()))
+ )
+ return 0
+
+
+func _set_attachment_amount(value: int) -> void:
+ var clamped := clampi(value, 0, _attachment_amount_maximum())
+ var text := str(clamped)
+ if _attachment_amount.text != text:
+ _attachment_amount.text = text
+ _attachment_amount.caret_column = text.length()
+ _amount_minus.disabled = clamped <= 0
+ _amount_plus.disabled = clamped >= _attachment_amount_maximum()
+ _update_attachment_summary()
+
+
+func _on_attachment_amount_changed(value: String) -> void:
+ var digits := ""
+ for character: String in value:
+ if character >= "0" and character <= "9":
+ digits += character
+ _set_attachment_amount(int(digits) if not digits.is_empty() else 0)
+
+
+func _step_attachment_amount(delta: int) -> void:
+ _set_attachment_amount(_attachment_amount_value() + delta)
+ _attachment_amount.grab_focus()
+
+
+func _archive_current() -> void:
+ if _current_mail_id.is_empty():
+ return
+ var archived := not _service.is_letter_archived(_current_mail_id)
+ if _service.archive_local_letter(_current_mail_id, archived):
+ _status.text = "Letter archived." if archived else "Letter restored."
+ _show_inbox()
+
+
+func _delete_current() -> void:
+ if _current_mail_id.is_empty():
+ return
+ if _service.delete_local_letter(_current_mail_id):
+ _status.text = "Letter deleted locally."
+ _show_inbox()
+ else:
+ _status.text = "Resolve the gift before deleting this letter."
+
+
+func _attachment_state_text(letter: Dictionary) -> String:
+ var attachment: Dictionary = letter["attachment"]
+ if int(attachment.get("type", 0)) == 0:
+ return ""
+ match int(letter["state"]):
+ NetworkMailProtocol.State.ACCEPTED:
+ return "Gift accepted.\n\n%s" % _attachment_text(attachment)
+ NetworkMailProtocol.State.DECLINED:
+ return "Gift declined."
+ NetworkMailProtocol.State.ATTACHMENT_RECALLED, NetworkMailProtocol.State.CANCELLED:
+ return "Gift returned to sender."
+ NetworkMailProtocol.State.ACCEPTANCE_PENDING:
+ return "Transferring gift…"
+ return "Gift enclosed\n\n%s" % _attachment_text(attachment)
+
+
+func _attachment_text(attachment: Dictionary) -> String:
+ match int(attachment.get("type", 0)):
+ PlayerAssetReservationService.AttachmentType.NONE:
+ return "No gift attached."
+ PlayerAssetReservationService.AttachmentType.FISH_COIN:
+ return CurrencyPresentationType.bbcode_amount(
+ int(attachment.get("amount", 0)), 18
+ )
+ PlayerAssetReservationService.AttachmentType.FISH:
+ var fish_catch := _inventory.get_catch_by_id(
+ StringName(str(attachment.get("catch_id", "")))
+ )
+ if fish_catch != null:
+ return "%s — %.1f lb" % [
+ FishQualityType.qualified_name(
+ fish_catch.fish.display_name,
+ fish_catch.quality,
+ ),
+ fish_catch.weight_lb,
+ ]
+ var data: Dictionary = attachment.get("catch", {})
+ return "%s — %.1f lb" % [
+ FishQualityType.qualified_name(
+ str(data.get("fish_id", "fish")).capitalize(),
+ int(data.get("quality", FishQualityType.Tier.BORING)),
+ ),
+ float(data.get("weight_lb", 0.0)),
+ ]
+ PlayerAssetReservationService.AttachmentType.CONSUMABLE:
+ var item := _catalog.get_item_by_id(
+ StringName(str(attachment.get("item_id", "")))
+ )
+ return "%s ×%d" % [
+ item.display_name if item != null else "Consumable",
+ int(attachment.get("quantity", 0)),
+ ]
+ return "Invalid gift."
+
+
+func _focus_first() -> void:
+ var buttons := _inbox_list.find_children("*", "Button", true, false)
+ if not buttons.is_empty():
+ (buttons.front() as Button).grab_focus()
+
+
+func _style_controls(root: Node) -> void:
+ for node: Node in root.find_children("*", "", true, false):
+ if node is BaseButton:
+ UtilityPageStyle.apply_ocean_button(node)
+ elif node is LineEdit:
+ UtilityPageStyle.apply_ocean_line_edit(node)
+ elif node is Label:
+ node.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ elif node is TextEdit:
+ UtilityPageStyle.apply_text_edit(node)
diff --git a/ui/mail_page.gd.uid b/ui/mail_page.gd.uid
new file mode 100644
index 0000000..d6de35d
--- /dev/null
+++ b/ui/mail_page.gd.uid
@@ -0,0 +1 @@
+uid://urjasxokrp8j
diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd
new file mode 100644
index 0000000..ca3fbe5
--- /dev/null
+++ b/ui/network/join_game_page.gd
@@ -0,0 +1,839 @@
+class_name JoinGamePage
+extends Control
+
+signal join_requested(endpoint: String)
+signal back_requested
+
+enum Mode {
+ DISCOVER,
+ DIRECT,
+ SAVED,
+ RECENT,
+}
+
+const DISCOVERY_REFRESH_SECONDS: float = 8.0
+
+const ADDRESS_FORMAT_HELP: String = (
+ "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; "
+ + "include the port shown by a host when it differs."
+)
+const DIRECT_WORKFLOW_HELP: String = (
+ "%s\nJoin Now connects once; Save Server stores this address locally."
+ % ADDRESS_FORMAT_HELP
+)
+
+@onready var _address: LineEdit = %Address
+@onready var _address_label: Label = %AddressLabel
+@onready var _address_helper: Label = %AddressHelper
+@onready var _name_edit: LineEdit = %NameEdit
+@onready var _name_label: Label = %NameLabel
+@onready var _name_helper: Label = %NameHelper
+@onready var _server_list: ItemList = %ServerList
+@onready var _details: Label = %Details
+@onready var _discover_button: Button = %DiscoverButton
+@onready var _direct_button: Button = %DirectButton
+@onready var _saved_button: Button = %SavedButton
+@onready var _recent_button: Button = %RecentButton
+@onready var _join_button: Button = %JoinButton
+@onready var _refresh_button: Button = %RefreshButton
+@onready var _save_button: Button = %SaveButton
+@onready var _edit_button: Button = %EditButton
+@onready var _favorite_button: Button = %FavoriteButton
+@onready var _delete_button: Button = %DeleteButton
+@onready var _cancel_button: Button = %CancelButton
+@onready var _back_button: Button = %BackButton
+@onready var _status: Label = %Status
+@onready var _session_summary: Label = %SessionSummary
+@onready var _delete_confirmation: BubbleConfirmationPage = (
+ %DeleteConfirmation
+)
+
+var _network_session: NetworkSession
+var _saved_servers: SavedServerStore
+var _server_trust: ServerTrustStore
+var _discovery: DiscoveryClient
+var _gameplay_context: bool = false
+var _mode: Mode = Mode.DISCOVER
+var _visible_entries: Array[SavedServerEntry] = []
+var _selected_entry: SavedServerEntry
+var _discovery_rooms: Array[Dictionary] = []
+var _selected_discovery_index: int = -1
+var _discovery_refresh_timer: Timer
+var _editing_entry_id: String = ""
+var _name_entry_active: bool = false
+var _delete_armed: bool = false
+var _connection_error_latched: bool = false
+
+
+func _ready() -> void:
+ UtilityPageStyle.apply_page(self)
+ var paper := get_node("Paper") as PanelContainer
+ paper.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.panel_style()
+ )
+ for button: BaseButton in [
+ _discover_button, _direct_button, _saved_button, _recent_button,
+ _refresh_button, _join_button,
+ _save_button, _edit_button, _favorite_button, _delete_button,
+ _cancel_button, _back_button,
+ ]:
+ UtilityPageStyle.apply_ocean_button(button)
+ UtilityPageStyle.apply_ocean_line_edit(_address)
+ UtilityPageStyle.apply_ocean_line_edit(_name_edit)
+ _discover_button.pressed.connect(_set_mode.bind(Mode.DISCOVER))
+ _direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT))
+ _saved_button.pressed.connect(_set_mode.bind(Mode.SAVED))
+ _recent_button.pressed.connect(_set_mode.bind(Mode.RECENT))
+ _refresh_button.pressed.connect(_request_discovery_refresh)
+ _join_button.pressed.connect(_request_join)
+ _save_button.pressed.connect(_on_save_pressed)
+ _edit_button.pressed.connect(_on_edit_pressed)
+ _favorite_button.pressed.connect(_on_favorite_pressed)
+ _delete_button.pressed.connect(_on_delete_pressed)
+ _cancel_button.pressed.connect(_on_cancel_pressed)
+ _back_button.pressed.connect(_on_back_pressed)
+ _address.text_submitted.connect(func(_value: String) -> void:
+ if _name_entry_active:
+ _name_edit.grab_focus()
+ _name_edit.select_all()
+ else:
+ _request_join()
+ )
+ _name_edit.text_submitted.connect(func(_value: String) -> void:
+ _commit_name_entry()
+ )
+ _server_list.item_selected.connect(_on_list_item_selected)
+ _server_list.item_activated.connect(func(_index: int) -> void:
+ _request_join()
+ )
+ _delete_confirmation.confirmed.connect(_confirm_delete)
+ _delete_confirmation.cancelled.connect(_cancel_delete)
+ _discovery_refresh_timer = Timer.new()
+ _discovery_refresh_timer.wait_time = DISCOVERY_REFRESH_SECONDS
+ _discovery_refresh_timer.one_shot = false
+ _discovery_refresh_timer.timeout.connect(_request_discovery_refresh)
+ add_child(_discovery_refresh_timer)
+ hide()
+
+
+func setup(
+ network_session: NetworkSession,
+ saved_servers: SavedServerStore,
+ gameplay_context: bool,
+ server_trust: ServerTrustStore = null,
+ discovery: DiscoveryClient = null,
+) -> void:
+ _network_session = network_session
+ _saved_servers = saved_servers
+ _gameplay_context = gameplay_context
+ _server_trust = server_trust
+ _discovery = discovery
+ if not _network_session.state_changed.is_connected(_on_state_changed):
+ _network_session.state_changed.connect(_on_state_changed)
+ if not _network_session.status_message_changed.is_connected(
+ _on_status_message_changed
+ ):
+ _network_session.status_message_changed.connect(
+ _on_status_message_changed
+ )
+ if not _network_session.connection_error.is_connected(
+ _on_connection_error
+ ):
+ _network_session.connection_error.connect(_on_connection_error)
+ if not _network_session.peer_count_changed.is_connected(
+ _on_peer_count_changed
+ ):
+ _network_session.peer_count_changed.connect(_on_peer_count_changed)
+ if (
+ _saved_servers != null
+ and not _saved_servers.data_changed.is_connected(_on_store_changed)
+ ):
+ _saved_servers.data_changed.connect(_on_store_changed)
+ if _discovery != null:
+ if not _discovery.rooms_updated.is_connected(
+ _on_discovery_rooms_updated
+ ):
+ _discovery.rooms_updated.connect(_on_discovery_rooms_updated)
+ if not _discovery.browse_status_changed.is_connected(
+ _on_discovery_status_changed
+ ):
+ _discovery.browse_status_changed.connect(
+ _on_discovery_status_changed
+ )
+ if not _discovery.public_join_prepared.is_connected(
+ _on_public_join_prepared
+ ):
+ _discovery.public_join_prepared.connect(
+ _on_public_join_prepared
+ )
+ if not _discovery.public_join_status_changed.is_connected(
+ _on_public_join_status_changed
+ ):
+ _discovery.public_join_status_changed.connect(
+ _on_public_join_status_changed
+ )
+ _refresh()
+
+
+func open_page(preserved_endpoint: String = "") -> void:
+ if not preserved_endpoint.is_empty():
+ _address.text = preserved_endpoint
+ elif _address.text.is_empty():
+ _address.text = "127.0.0.1:7777"
+ show()
+ UtilityPageStyle.animate_in(self)
+ _set_mode(_mode, false)
+ if _mode == Mode.DIRECT:
+ _address.grab_focus()
+ _address.select_all()
+
+
+func close_page() -> void:
+ _clear_edit_state()
+ _discovery_refresh_timer.stop()
+ hide()
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+
+
+func get_endpoint_text() -> String:
+ return _address.text
+
+
+func set_status(message: String) -> void:
+ _connection_error_latched = true
+ _set_status(_friendly_connection_message(message), true)
+
+
+func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
+ if clear_connection_error:
+ _connection_error_latched = false
+ _mode = mode
+ _selected_entry = null
+ _selected_discovery_index = -1
+ _clear_edit_state()
+ _refresh_entries()
+ _refresh()
+ if mode == Mode.DISCOVER:
+ _discovery_refresh_timer.start()
+ _request_discovery_refresh()
+ else:
+ _discovery_refresh_timer.stop()
+ if not is_visible_in_tree():
+ return
+ if mode == Mode.DIRECT:
+ _address.grab_focus()
+ else:
+ _server_list.grab_focus()
+
+
+func _request_join() -> void:
+ _connection_error_latched = false
+ if _network_session.state in [
+ NetworkSession.State.CONNECTION_FAILED,
+ NetworkSession.State.SERVER_LOST,
+ ]:
+ _network_session.reset_failure()
+ var endpoint_text: String = _address.text
+ if _mode == Mode.DISCOVER:
+ var room: Dictionary = _selected_discovery_room()
+ if _discovery != null and _discovery.is_own_room(room):
+ _set_status("You are already hosting this room.", true)
+ return
+ if _discovery_room_is_full(room):
+ _set_status("That room is full.", true)
+ return
+ if _discovery == null or not _discovery.prepare_public_join(room):
+ _set_status("Could not prepare the public connection.", true)
+ return
+ elif _mode != Mode.DIRECT and _selected_entry != null:
+ endpoint_text = _selected_entry.normalized_endpoint
+ var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
+ if not endpoint.is_valid():
+ _set_status(endpoint.error_message, true)
+ return
+ _set_status("Connecting…")
+ _address.text = endpoint.normalized_display
+ join_requested.emit(endpoint.normalized_display)
+
+
+func _on_public_join_prepared(endpoint_text: String) -> void:
+ if _mode != Mode.DISCOVER or not is_visible_in_tree():
+ return
+ var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
+ if not endpoint.is_valid():
+ _set_status("Discovery returned an invalid room address.", true)
+ return
+ _address.text = endpoint.normalized_display
+ _set_status("Connecting…")
+ join_requested.emit(endpoint.normalized_display)
+
+
+func _on_public_join_status_changed(message: String, is_error: bool) -> void:
+ if _mode == Mode.DISCOVER and is_visible_in_tree():
+ if is_error:
+ _connection_error_latched = true
+ elif _connection_error_latched:
+ return
+ _set_status(message, is_error)
+ _refresh()
+
+
+func _on_save_pressed() -> void:
+ if _name_entry_active:
+ _commit_name_entry()
+ return
+ var endpoint: ConnectionEndpoint = (
+ _selected_entry.get_endpoint()
+ if _mode == Mode.RECENT and _selected_entry != null
+ else EndpointParser.parse(_address.text)
+ )
+ if not endpoint.is_valid():
+ _set_status(endpoint.error_message, true)
+ return
+ var existing: SavedServerEntry = (
+ _saved_servers.find_saved_by_endpoint(endpoint)
+ )
+ if existing != null:
+ _set_status(
+ "Already saved locally as “%s”." % existing.display_name,
+ true,
+ )
+ _set_mode(Mode.SAVED)
+ _select_entry_id(existing.entry_id)
+ return
+ _address.text = endpoint.normalized_display
+ _name_edit.text = (
+ _selected_entry.last_observed_server_name
+ if _mode == Mode.RECENT
+ and _selected_entry != null
+ and not _selected_entry.last_observed_server_name.is_empty()
+ else endpoint.host
+ )
+ _name_entry_active = true
+ _name_edit.show()
+ _save_button.text = "save"
+ _name_edit.grab_focus()
+ _name_edit.select_all()
+ _refresh()
+
+
+func _commit_name_entry() -> void:
+ var endpoint := EndpointParser.parse(_address.text)
+ if not endpoint.is_valid():
+ _set_status(endpoint.error_message, true)
+ return
+ var saved: SavedServerEntry = _saved_servers.save_or_update_entry(
+ _name_edit.text,
+ endpoint,
+ _editing_entry_id,
+ )
+ if saved == null:
+ _set_status(
+ "Could not save. Check the server name and address.",
+ true,
+ )
+ return
+ var was_editing: bool = not _editing_entry_id.is_empty()
+ _set_status(
+ "Server updated."
+ if was_editing
+ else "Saved locally as “%s”." % saved.display_name
+ )
+ _clear_edit_state()
+ _mode = Mode.SAVED
+ _refresh_entries()
+ _select_entry_id(saved.entry_id)
+ _refresh()
+
+
+func _on_edit_pressed() -> void:
+ if _selected_entry == null or _mode != Mode.SAVED:
+ return
+ _editing_entry_id = _selected_entry.entry_id
+ _address.text = _selected_entry.normalized_endpoint
+ _name_edit.text = _selected_entry.display_name
+ _name_entry_active = true
+ _name_edit.show()
+ _save_button.text = "save"
+ _name_edit.grab_focus()
+ _name_edit.select_all()
+ _refresh()
+
+
+func _on_favorite_pressed() -> void:
+ if _selected_entry == null or _mode != Mode.SAVED:
+ return
+ var entry_id: String = _selected_entry.entry_id
+ _saved_servers.set_favorite(entry_id, not _selected_entry.favorite)
+ _refresh_entries()
+ _select_entry_id(entry_id)
+ _refresh()
+
+
+func _on_delete_pressed() -> void:
+ if _selected_entry == null or _mode == Mode.DIRECT:
+ return
+ _delete_armed = true
+ _delete_confirmation.configure(
+ (
+ "Remove this recent connection from this device?"
+ if _mode == Mode.RECENT
+ else "Delete this saved-server bookmark from this device?"
+ ),
+ "remove" if _mode == Mode.RECENT else "delete",
+ "cancel",
+ BubbleConfirmationPage.InitialFocus.CANCEL,
+ )
+ _delete_confirmation.transition_in(0.18, func() -> void: pass)
+
+
+func _confirm_delete() -> void:
+ if _selected_entry == null or not _delete_armed:
+ _cancel_delete()
+ return
+ var removed: bool = (
+ _saved_servers.remove_recent_entry(_selected_entry.entry_id)
+ if _mode == Mode.RECENT
+ else _saved_servers.remove_saved_entry(_selected_entry.entry_id)
+ )
+ _set_status(
+ (
+ "Removed from recent connections."
+ if _mode == Mode.RECENT
+ else "Removed from saved servers."
+ )
+ if removed
+ else "Could not remove the local entry.",
+ not removed,
+ )
+ _selected_entry = null
+ _delete_armed = false
+ _refresh_entries()
+ _refresh()
+ _delete_confirmation.hide_page()
+
+
+func _cancel_delete() -> void:
+ _delete_armed = false
+ _delete_confirmation.hide_page()
+ _delete_button.grab_focus()
+
+
+func _on_list_item_selected(index: int) -> void:
+ if _mode == Mode.DISCOVER:
+ if index < 0 or index >= _discovery_rooms.size():
+ return
+ _selected_discovery_index = index
+ _selected_entry = null
+ _refresh()
+ return
+ if index < 0 or index >= _visible_entries.size():
+ return
+ _selected_entry = _visible_entries[index]
+ _delete_armed = false
+ _refresh()
+
+
+func _select_entry_id(entry_id: String) -> void:
+ for index: int in range(_visible_entries.size()):
+ if _visible_entries[index].entry_id == entry_id:
+ _server_list.select(index)
+ _selected_entry = _visible_entries[index]
+ return
+
+
+func _refresh_entries() -> void:
+ _visible_entries.clear()
+ _server_list.clear()
+ if _mode == Mode.DISCOVER:
+ for room: Dictionary in _discovery_rooms:
+ var player_count: int = int(room.get("current_players", 0))
+ var maximum: int = int(room.get("max_players", 0))
+ var own_room: bool = (
+ _discovery != null and _discovery.is_own_room(room)
+ )
+ _server_list.add_item(
+ "%s — %d / %d players%s%s" % [
+ str(room.get("room_name", "Public room")),
+ player_count,
+ maximum,
+ " — full" if player_count >= maximum else "",
+ " — your room" if own_room else "",
+ ]
+ )
+ return
+ if _saved_servers == null or _mode == Mode.DIRECT:
+ return
+ _visible_entries = (
+ _saved_servers.get_saved_entries()
+ if _mode == Mode.SAVED
+ else _saved_servers.get_recent_entries()
+ )
+ for entry: SavedServerEntry in _visible_entries:
+ var prefix: String = "★ " if entry.favorite else ""
+ var count: String = (
+ " — %d / %d players"
+ % [
+ entry.last_observed_player_count,
+ entry.last_observed_max_players,
+ ]
+ if entry.last_observed_max_players > 0
+ else ""
+ )
+ _server_list.add_item(
+ "%s%s — %s%s"
+ % [
+ prefix,
+ entry.display_name,
+ entry.normalized_endpoint,
+ count,
+ ]
+ )
+
+
+func _refresh() -> void:
+ if not is_node_ready() or _network_session == null:
+ return
+ var connecting: bool = _network_session.state in [
+ NetworkSession.State.CONNECTING,
+ NetworkSession.State.AUTHENTICATING,
+ ] or (_discovery != null and _discovery.is_public_join_preparing())
+ var direct: bool = _mode == Mode.DIRECT
+ var discovery_mode: bool = _mode == Mode.DISCOVER
+ var selected: bool = (
+ _selected_discovery_index >= 0
+ if discovery_mode
+ else _selected_entry != null
+ )
+ _discover_button.button_pressed = discovery_mode
+ _direct_button.button_pressed = direct
+ _saved_button.button_pressed = _mode == Mode.SAVED
+ _recent_button.button_pressed = _mode == Mode.RECENT
+ _address.visible = direct or _name_entry_active
+ _address_label.visible = _address.visible
+ _address_helper.visible = _address.visible
+ _address_helper.text = (
+ ADDRESS_FORMAT_HELP if _name_entry_active else DIRECT_WORKFLOW_HELP
+ )
+ _address.editable = not connecting
+ _name_edit.visible = _name_entry_active
+ _name_label.visible = _name_entry_active
+ _name_helper.visible = _name_entry_active
+ _server_list.visible = not direct and not _name_entry_active
+ _details.visible = not direct and not _name_entry_active
+ _join_button.disabled = (
+ connecting
+ or (not direct and not selected)
+ or (
+ discovery_mode
+ and selected
+ and _discovery_room_is_full(_selected_discovery_room())
+ )
+ or (
+ discovery_mode
+ and selected
+ and _discovery != null
+ and _discovery.is_own_room(_selected_discovery_room())
+ )
+ )
+ _join_button.text = "join\nnow" if direct else "join"
+ _refresh_button.visible = discovery_mode and not _name_entry_active
+ _save_button.visible = (
+ direct or _mode == Mode.RECENT or _name_entry_active
+ )
+ _save_button.disabled = connecting or (
+ _mode == Mode.RECENT and not selected and not _name_entry_active
+ )
+ _save_button.text = "save" if _name_entry_active else "save\nserver"
+ _edit_button.visible = _mode == Mode.SAVED and selected
+ _favorite_button.visible = _mode == Mode.SAVED and selected
+ _favorite_button.text = (
+ "unfavorite"
+ if _mode == Mode.SAVED
+ and _selected_entry != null
+ and _selected_entry.favorite
+ else "favorite"
+ )
+ _delete_button.visible = (
+ _mode in [Mode.SAVED, Mode.RECENT] and selected
+ )
+ _delete_button.text = "remove" if _mode == Mode.RECENT else "delete"
+ _cancel_button.visible = connecting
+ _session_summary.visible = (
+ _gameplay_context
+ and _network_session.state != NetworkSession.State.INACTIVE
+ )
+ if _session_summary.visible:
+ _session_summary.text = (
+ "UDP %d • %d / %d players"
+ % [
+ _network_session.get_host_port(),
+ _network_session.get_player_count(),
+ _network_session.get_session_max_players(),
+ ]
+ if _network_session.is_host()
+ else "%d / %d players" % [
+ _network_session.get_player_count(),
+ _network_session.get_session_max_players(),
+ ]
+ )
+ if not direct:
+ if selected:
+ _details.text = (
+ _format_discovery_details(_selected_discovery_room())
+ if discovery_mode
+ else _format_entry_details(_selected_entry)
+ )
+ elif (
+ _discovery_rooms.is_empty()
+ if discovery_mode
+ else _visible_entries.is_empty()
+ ):
+ _details.text = (
+ "No public rooms are available."
+ if discovery_mode
+ else "No saved servers yet."
+ if _mode == Mode.SAVED
+ else "No recent connections yet."
+ )
+ else:
+ _details.text = "Select a server."
+ var warning: String = (
+ _saved_servers.get_recovery_warning()
+ if _saved_servers != null and not discovery_mode
+ else ""
+ )
+ if not warning.is_empty() and _status.text.is_empty():
+ _status.text = warning
+
+
+func _format_entry_details(entry: SavedServerEntry) -> String:
+ var parts: Array[String] = []
+ if _mode == Mode.SAVED:
+ parts.append("Name: %s" % entry.display_name)
+ parts.append("Address: %s" % entry.normalized_endpoint)
+ if _server_trust != null:
+ var trust := _server_trust.get_record(entry.get_endpoint())
+ if trust.is_empty():
+ parts.append("Host identity: Not verified yet")
+ else:
+ parts.append(
+ "Host identity: %s"
+ % NetworkIdentityCrypto.format_fingerprint(
+ str(trust.get("fingerprint", ""))
+ )
+ )
+ if (
+ not entry.last_observed_server_name.is_empty()
+ and (
+ _mode != Mode.SAVED
+ or entry.last_observed_server_name != entry.display_name
+ )
+ ):
+ parts.append("Server name: %s" % entry.last_observed_server_name)
+ if entry.last_observed_max_players > 0:
+ parts.append(
+ "Players: %d / %d"
+ % [
+ entry.last_observed_player_count,
+ entry.last_observed_max_players,
+ ]
+ )
+ if entry.last_success_at_unix > 0:
+ parts.append(
+ "Last connected: %s"
+ % Time.get_datetime_string_from_unix_time(
+ entry.last_success_at_unix, true
+ )
+ )
+ if not entry.last_result_code.is_empty():
+ parts.append(
+ "Last result: %s" % _format_result_code(entry.last_result_code)
+ )
+ return "\n".join(parts)
+
+
+func _format_discovery_details(room: Dictionary) -> String:
+ if room.is_empty():
+ return "Select a public room."
+ var lines: Array[String] = [
+ "Room: %s" % str(room.get("room_name", "Public room")),
+ "Players: %d / %d" % [
+ int(room.get("current_players", 0)),
+ int(room.get("max_players", 0)),
+ ],
+ "Connection: %s" % (
+ _discovery.room_endpoint(room) if _discovery != null else "—"
+ ),
+ "Direct UDP connection • ping is measured after joining",
+ ]
+ if _discovery != null and _discovery.is_own_room(room):
+ lines.append("This is the room you are currently hosting.")
+ return "\n".join(lines)
+
+
+func _selected_discovery_room() -> Dictionary:
+ if (
+ _selected_discovery_index < 0
+ or _selected_discovery_index >= _discovery_rooms.size()
+ ):
+ return {}
+ return _discovery_rooms[_selected_discovery_index]
+
+
+func _discovery_room_is_full(room: Dictionary) -> bool:
+ if room.is_empty():
+ return false
+ return int(room.get("current_players", 0)) >= int(room.get("max_players", 0))
+
+
+func _request_discovery_refresh() -> void:
+ if (
+ _discovery == null
+ or _mode != Mode.DISCOVER
+ or not is_visible_in_tree()
+ ):
+ return
+ _discovery.request_rooms()
+
+
+func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void:
+ var selected_id: String = str(
+ _selected_discovery_room().get("room_id", "")
+ )
+ _discovery_rooms = rooms.duplicate(true)
+ _selected_discovery_index = -1
+ if _mode == Mode.DISCOVER:
+ _refresh_entries()
+ if not selected_id.is_empty():
+ for index: int in _discovery_rooms.size():
+ if str(_discovery_rooms[index].get("room_id", "")) == selected_id:
+ _selected_discovery_index = index
+ _server_list.select(index)
+ break
+ _refresh()
+
+
+func _on_discovery_status_changed(message: String, is_error: bool) -> void:
+ if (
+ _mode == Mode.DISCOVER
+ and is_visible_in_tree()
+ and not _connection_error_latched
+ ):
+ _set_status(message, is_error)
+
+
+func _format_result_code(result_code: String) -> String:
+ match result_code.strip_edges().to_upper():
+ "SUCCESS":
+ return "Connected successfully"
+ "PROTOCOL_MISMATCH":
+ return "Protocol versions did not match"
+ "SERVER_FULL":
+ return "Server was full"
+ "CANCELLED":
+ return "Connection cancelled"
+ "TIMEOUT", "CONNECTION_FAILED", "SERVER_UNAVAILABLE":
+ return "Could not reach the server"
+ _:
+ return "Connection was unsuccessful"
+
+
+func _friendly_connection_message(message: String) -> String:
+ var normalized: String = message.strip_edges().to_lower()
+ if "protocol" in normalized and (
+ "mismatch" in normalized or "version" in normalized
+ ):
+ return "Protocol versions do not match."
+ if "full" in normalized or "capacity" in normalized:
+ return "Server is full."
+ if "cancel" in normalized:
+ return "Connection cancelled."
+ if (
+ "timeout" in normalized
+ or "timed out" in normalized
+ or "unavailable" in normalized
+ or "refused" in normalized
+ or "reach" in normalized
+ or "failed" in normalized
+ ):
+ return "Could not reach the server."
+ return message
+
+
+func _set_status(message: String, is_error: bool = false) -> void:
+ _status.text = message
+ _status.add_theme_color_override(
+ "font_color",
+ UtilityPageStyle.OCEAN_DANGER
+ if is_error
+ else UtilityPageStyle.OCEAN_TEXT_PRIMARY,
+ )
+
+
+func _clear_edit_state() -> void:
+ _editing_entry_id = ""
+ _name_entry_active = false
+ _delete_armed = false
+ if is_node_ready():
+ _name_edit.hide()
+ _delete_confirmation.hide_page()
+
+
+func _on_cancel_pressed() -> void:
+ _connection_error_latched = false
+ if _network_session != null:
+ _network_session.cancel_connection()
+ _refresh()
+
+
+func _on_back_pressed() -> void:
+ if _name_entry_active or _delete_armed:
+ _clear_edit_state()
+ _refresh()
+ return
+ if (
+ _network_session != null
+ and _network_session.state in [
+ NetworkSession.State.CONNECTING,
+ NetworkSession.State.AUTHENTICATING,
+ ]
+ ):
+ _network_session.cancel_connection()
+ back_requested.emit()
+
+
+func _on_state_changed(_state: NetworkSession.State) -> void:
+ _refresh()
+
+
+func _on_status_message_changed(message: String) -> void:
+ if _connection_error_latched:
+ return
+ _set_status(_friendly_connection_message(message))
+ _refresh()
+
+
+func _on_connection_error(message: String) -> void:
+ _connection_error_latched = true
+ _set_status(_friendly_connection_message(message), true)
+ _refresh()
+
+
+func _on_peer_count_changed(player_count: int, max_players: int) -> void:
+ _session_summary.text = "%d / %d players" % [
+ player_count,
+ max_players,
+ ]
+
+
+func _on_store_changed() -> void:
+ var selected_id: String = (
+ _selected_entry.entry_id if _selected_entry != null else ""
+ )
+ _refresh_entries()
+ if not selected_id.is_empty():
+ _select_entry_id(selected_id)
+ _refresh()
diff --git a/ui/network/join_game_page.gd.uid b/ui/network/join_game_page.gd.uid
new file mode 100644
index 0000000..c1bdf89
--- /dev/null
+++ b/ui/network/join_game_page.gd.uid
@@ -0,0 +1 @@
+uid://dch42c44t4rj5
diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn
new file mode 100644
index 0000000..c4be4fa
--- /dev/null
+++ b/ui/network/join_game_page.tscn
@@ -0,0 +1,325 @@
+[gd_scene load_steps=12 format=3]
+
+[ext_resource type="Script" path="res://ui/network/join_game_page.gd" id="1_script"]
+[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="4_confirmation"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_paper"]
+bg_color = Color(0.051, 0.173, 0.227, 1)
+corner_radius_top_left = 54
+corner_radius_top_right = 46
+corner_radius_bottom_right = 58
+corner_radius_bottom_left = 48
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_list"]
+bg_color = Color(0.071, 0.247, 0.306, 1)
+corner_radius_top_left = 12
+corner_radius_top_right = 9
+corner_radius_bottom_right = 13
+corner_radius_bottom_left = 10
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_selected"]
+bg_color = Color(0.137, 0.525, 0.592, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 7
+corner_radius_bottom_right = 9
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxEmpty" id="StyleBox_focus"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_input"]
+content_margin_left = 12.0
+content_margin_top = 7.0
+content_margin_right = 12.0
+content_margin_bottom = 7.0
+bg_color = Color(0.031, 0.122, 0.169, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 7
+corner_radius_bottom_right = 9
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_input_focus"]
+content_margin_left = 12.0
+content_margin_top = 7.0
+content_margin_right = 12.0
+content_margin_bottom = 7.0
+bg_color = Color(0.137, 0.525, 0.592, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 7
+corner_radius_bottom_right = 9
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_input_read_only"]
+content_margin_left = 12.0
+content_margin_top = 7.0
+content_margin_right = 12.0
+content_margin_bottom = 7.0
+bg_color = Color(0.031, 0.122, 0.169, 0.82)
+corner_radius_top_left = 8
+corner_radius_top_right = 7
+corner_radius_bottom_right = 9
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_status"]
+content_margin_left = 10.0
+content_margin_top = 4.0
+content_margin_right = 10.0
+content_margin_bottom = 4.0
+bg_color = Color(0.071, 0.247, 0.306, 0.9)
+corner_radius_top_left = 7
+corner_radius_top_right = 6
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 5
+
+[node name="JoinGamePage" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme = ExtResource("2_theme")
+script = ExtResource("1_script")
+
+[node name="Paper" type="PanelContainer" parent="."]
+layout_mode = 0
+offset_left = 230.0
+offset_top = 64.0
+offset_right = 1050.0
+offset_bottom = 656.0
+theme_override_styles/panel = SubResource("StyleBox_paper")
+
+[node name="Margin" type="MarginContainer" parent="Paper"]
+layout_mode = 2
+theme_override_constants/margin_left = 54
+theme_override_constants/margin_top = 30
+theme_override_constants/margin_right = 54
+theme_override_constants/margin_bottom = 30
+
+[node name="Layout" type="VBoxContainer" parent="Paper/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 10
+alignment = 1
+
+[node name="Title" type="Label" parent="Paper/Margin/Layout"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 30
+text = "join game"
+horizontal_alignment = 1
+
+[node name="Modes" type="HBoxContainer" parent="Paper/Margin/Layout"]
+layout_mode = 2
+theme_override_constants/separation = 14
+alignment = 1
+
+[node name="DiscoverButton" type="Button" parent="Paper/Margin/Layout/Modes"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 72)
+layout_mode = 2
+toggle_mode = true
+text = "discover"
+
+[node name="DirectButton" type="Button" parent="Paper/Margin/Layout/Modes"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 72)
+layout_mode = 2
+toggle_mode = true
+text = "direct"
+
+[node name="SavedButton" type="Button" parent="Paper/Margin/Layout/Modes"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 72)
+layout_mode = 2
+toggle_mode = true
+text = "saved"
+
+[node name="RecentButton" type="Button" parent="Paper/Margin/Layout/Modes"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 72)
+layout_mode = 2
+toggle_mode = true
+text = "recent"
+
+[node name="AddressLabel" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 17
+text = "server address"
+horizontal_alignment = 1
+
+[node name="Address" type="LineEdit" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 42)
+layout_mode = 2
+theme_override_colors/font_color = Color(0.96, 0.93, 0.82, 1)
+theme_override_colors/font_uneditable_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_colors/font_selected_color = Color(1, 0.98, 0.9, 1)
+theme_override_colors/font_placeholder_color = Color(0.624, 0.812, 0.824, 0.78)
+theme_override_colors/caret_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_colors/selection_color = Color(0.2, 0.48, 0.59, 0.9)
+theme_override_font_sizes/font_size = 19
+theme_override_styles/normal = SubResource("StyleBox_input")
+theme_override_styles/focus = SubResource("StyleBox_input_focus")
+theme_override_styles/read_only = SubResource("StyleBox_input_read_only")
+placeholder_text = "example.net or 192.168.1.50:7777"
+alignment = 1
+max_length = 300
+
+[node name="AddressHelper" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 13
+text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; include the port shown by a host when it differs.\nJoin Now connects once; Save Server stores this address locally."
+horizontal_alignment = 1
+autowrap_mode = 2
+
+[node name="NameLabel" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 17
+text = "server name"
+horizontal_alignment = 1
+
+[node name="NameEdit" type="LineEdit" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(0, 42)
+layout_mode = 2
+theme_override_colors/font_color = Color(0.96, 0.93, 0.82, 1)
+theme_override_colors/font_uneditable_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_colors/font_selected_color = Color(1, 0.98, 0.9, 1)
+theme_override_colors/font_placeholder_color = Color(0.624, 0.812, 0.824, 0.78)
+theme_override_colors/caret_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_colors/selection_color = Color(0.2, 0.48, 0.59, 0.9)
+theme_override_font_sizes/font_size = 19
+theme_override_styles/normal = SubResource("StyleBox_input")
+theme_override_styles/focus = SubResource("StyleBox_input_focus")
+theme_override_styles/read_only = SubResource("StyleBox_input_read_only")
+placeholder_text = "Friend's server"
+alignment = 1
+max_length = 80
+
+[node name="NameHelper" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 13
+text = "Only visible on this device."
+horizontal_alignment = 1
+
+[node name="ServerList" type="ItemList" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(0, 145)
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_colors/font_selected_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_colors/guide_color = Color(0.624, 0.812, 0.824, 0.22)
+theme_override_font_sizes/font_size = 17
+theme_override_styles/panel = SubResource("StyleBox_list")
+theme_override_styles/focus = SubResource("StyleBox_focus")
+theme_override_styles/selected = SubResource("StyleBox_selected")
+theme_override_styles/selected_focus = SubResource("StyleBox_selected")
+allow_reselect = true
+same_column_width = true
+
+[node name="Details" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(0, 92)
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 16
+text = "Select a server."
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
+
+[node name="Status" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 38)
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 15
+theme_override_styles/normal = SubResource("StyleBox_status")
+text = "Direct UDP connection • default port 7777"
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
+
+[node name="SessionSummary" type="Label" parent="Paper/Margin/Layout"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "1 / 8 players"
+horizontal_alignment = 1
+
+[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"]
+layout_mode = 2
+theme_override_constants/separation = 7
+alignment = 1
+
+[node name="RefreshButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(82, 72)
+layout_mode = 2
+text = "refresh"
+
+[node name="JoinButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(82, 72)
+layout_mode = 2
+text = "join"
+
+[node name="SaveButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(82, 72)
+layout_mode = 2
+text = "save\nserver"
+
+[node name="EditButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(76, 68)
+layout_mode = 2
+text = "edit"
+
+[node name="FavoriteButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(88, 68)
+layout_mode = 2
+text = "favorite"
+
+[node name="DeleteButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(82, 68)
+layout_mode = 2
+text = "delete"
+
+[node name="CancelButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(76, 68)
+layout_mode = 2
+text = "cancel"
+
+[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(76, 68)
+layout_mode = 2
+text = "back"
+
+[node name="DeleteConfirmation" parent="." instance=ExtResource("4_confirmation")]
+unique_name_in_owner = true
diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd
new file mode 100644
index 0000000..0d7315d
--- /dev/null
+++ b/ui/on_screen_keyboard.gd
@@ -0,0 +1,456 @@
+class_name OnScreenKeyboard
+extends Control
+
+const ControllerFocusNavigationType = preload(
+ "res://ui/controller_focus_navigation.gd"
+)
+const CHECK_ICON: Texture2D = preload(
+ "res://ui/icons/pictograms/check_mark_dark.png"
+)
+const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0)
+const CHARACTER_FONT_SIZE: int = 34
+const TRIGGER_PRESS_THRESHOLD: float = 0.55
+const TRIGGER_RELEASE_THRESHOLD: float = 0.25
+
+signal text_submitted(value: String)
+
+enum Page {
+ LOWER,
+ UPPER,
+ SYMBOLS,
+}
+
+var _enabled: bool = false
+var _page: Page = Page.LOWER
+var _target: Control
+var _target_virtual_keyboard_enabled: bool = true
+var _buffer: String = ""
+var _preview: Label
+var _page_buttons: Array[Button] = []
+var _keys_host: VBoxContainer
+var _key_buttons: Array[Button] = []
+var _caret_left_button: Button
+var _caret_right_button: Button
+var _backspace_button: Button
+var _space_button: Button
+var _check_button: Button
+var _left_trigger_pressed: bool = false
+var _right_trigger_pressed: bool = false
+
+
+func _ready() -> void:
+ process_mode = Node.PROCESS_MODE_ALWAYS
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ z_index = 1000
+ mouse_filter = Control.MOUSE_FILTER_STOP
+ _build_interface()
+ hide()
+ set_process_input(true)
+
+
+func set_enabled(enabled: bool) -> void:
+ _enabled = enabled
+ if not _enabled and visible:
+ _close_keyboard(false)
+
+
+func is_enabled() -> bool:
+ return _enabled
+
+
+func is_open() -> bool:
+ return visible
+
+
+func _input(event: InputEvent) -> void:
+ if not _enabled:
+ return
+ if visible:
+ var joy_motion := event as InputEventJoypadMotion
+ if joy_motion != null and _handle_trigger_shortcut(joy_motion):
+ get_viewport().set_input_as_handled()
+ return
+ var joy_button := event as InputEventJoypadButton
+ if joy_button != null and joy_button.pressed:
+ if joy_button.button_index == JOY_BUTTON_X:
+ _backspace()
+ get_viewport().set_input_as_handled()
+ return
+ if joy_button.button_index == JOY_BUTTON_LEFT_SHOULDER:
+ _close_keyboard(false)
+ get_viewport().set_input_as_handled()
+ return
+ if joy_button.button_index == JOY_BUTTON_RIGHT_SHOULDER:
+ _set_page(wrapi(int(_page) + 1, 0, Page.size()))
+ get_viewport().set_input_as_handled()
+ return
+ if event.is_action_pressed(&"ui_cancel"):
+ _close_keyboard(true)
+ get_viewport().set_input_as_handled()
+ return
+ var joy_event := event as InputEventJoypadButton
+ if (
+ joy_event == null
+ or not joy_event.pressed
+ or (
+ joy_event.button_index != JOY_BUTTON_A
+ and not event.is_action_pressed(&"ui_accept")
+ )
+ ):
+ return
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if _can_edit(focus_owner):
+ _open_for(focus_owner)
+ get_viewport().set_input_as_handled()
+
+
+func _can_edit(control: Control) -> bool:
+ if control is LineEdit:
+ return (control as LineEdit).editable
+ if control is TextEdit:
+ return (control as TextEdit).editable
+ return false
+
+
+func _open_for(control: Control) -> void:
+ _target = control
+ _target_virtual_keyboard_enabled = bool(
+ _target.get("virtual_keyboard_enabled")
+ )
+ _target.set("virtual_keyboard_enabled", false)
+ _buffer = str(_target.get("text"))
+ _page = Page.LOWER
+ show()
+ _refresh_preview()
+ _rebuild_keys()
+
+
+func _close_keyboard(restore_focus: bool) -> void:
+ var prior_target: Control = _target
+ if is_instance_valid(prior_target):
+ prior_target.set(
+ "virtual_keyboard_enabled",
+ _target_virtual_keyboard_enabled
+ )
+ hide()
+ get_viewport().gui_release_focus()
+ _target = null
+ if restore_focus and is_instance_valid(prior_target):
+ prior_target.grab_focus()
+
+
+func _submit() -> void:
+ var submitted_target: Control = _target
+ var submitted_text: String = _buffer
+ _close_keyboard(false)
+ if submitted_target is LineEdit:
+ (submitted_target as LineEdit).text_submitted.emit(submitted_text)
+ elif submitted_target is TextEdit:
+ (submitted_target as TextEdit).text_changed.emit()
+ text_submitted.emit(submitted_text)
+
+
+func _type_character(character: String) -> void:
+ if not is_instance_valid(_target):
+ _close_keyboard(false)
+ return
+ var caret: int = _get_caret_column()
+ var next_text: String = (
+ _buffer.substr(0, caret)
+ + character
+ + _buffer.substr(caret)
+ )
+ if _target is LineEdit:
+ var line_edit := _target as LineEdit
+ if line_edit.max_length > 0 and next_text.length() > line_edit.max_length:
+ return
+ _buffer = next_text
+ _set_target_text(caret + character.length())
+
+
+func _type_space() -> void:
+ _type_character(" ")
+
+
+func _backspace() -> void:
+ if not is_instance_valid(_target) or _buffer.is_empty():
+ return
+ var caret: int = _get_caret_column()
+ if caret <= 0:
+ return
+ _buffer = _buffer.erase(caret - 1, 1)
+ _set_target_text(caret - 1)
+
+
+func _move_caret(direction: int) -> void:
+ if not is_instance_valid(_target):
+ _close_keyboard(false)
+ return
+ var caret: int = clampi(
+ _get_caret_column() + direction,
+ 0,
+ _buffer.length(),
+ )
+ if _target is LineEdit:
+ (_target as LineEdit).caret_column = caret
+ elif _target is TextEdit:
+ (_target as TextEdit).set_caret_column(caret)
+ _refresh_preview()
+
+
+func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool:
+ if event.axis == JOY_AXIS_TRIGGER_LEFT:
+ if event.axis_value <= TRIGGER_RELEASE_THRESHOLD:
+ _left_trigger_pressed = false
+ elif (
+ event.axis_value >= TRIGGER_PRESS_THRESHOLD
+ and not _left_trigger_pressed
+ ):
+ _left_trigger_pressed = true
+ _move_caret(-1)
+ return true
+ elif event.axis == JOY_AXIS_TRIGGER_RIGHT:
+ if event.axis_value <= TRIGGER_RELEASE_THRESHOLD:
+ _right_trigger_pressed = false
+ elif (
+ event.axis_value >= TRIGGER_PRESS_THRESHOLD
+ and not _right_trigger_pressed
+ ):
+ _right_trigger_pressed = true
+ _move_caret(1)
+ return true
+ return false
+
+
+func _get_caret_column() -> int:
+ if _target is LineEdit:
+ return clampi(
+ (_target as LineEdit).caret_column,
+ 0,
+ _buffer.length()
+ )
+ return _buffer.length()
+
+
+func _set_target_text(caret: int) -> void:
+ if _target is LineEdit:
+ var line_edit := _target as LineEdit
+ line_edit.text = _buffer
+ line_edit.caret_column = caret
+ line_edit.text_changed.emit(_buffer)
+ elif _target is TextEdit:
+ var text_edit := _target as TextEdit
+ text_edit.text = _buffer
+ text_edit.text_changed.emit()
+ _refresh_preview()
+
+
+func _refresh_preview() -> void:
+ if _preview == null:
+ return
+ var displayed_text: String = _buffer
+ if _target is LineEdit and (_target as LineEdit).secret:
+ displayed_text = "*".repeat(_buffer.length())
+ var caret: int = clampi(_get_caret_column(), 0, displayed_text.length())
+ _preview.text = displayed_text.insert(caret, "|")
+
+
+func _set_page(page_index: int) -> void:
+ _page = page_index as Page
+ _rebuild_keys()
+
+
+func _rebuild_keys() -> void:
+ for child: Node in _keys_host.get_children():
+ _keys_host.remove_child(child)
+ child.queue_free()
+ _key_buttons.clear()
+ var rows: Array = _rows_for_page()
+ for row_value: Variant in rows:
+ var row := HBoxContainer.new()
+ row.alignment = BoxContainer.ALIGNMENT_CENTER
+ row.size_flags_vertical = Control.SIZE_SHRINK_CENTER
+ row.add_theme_constant_override("separation", 10)
+ _keys_host.add_child(row)
+ for character_value: Variant in row_value as Array:
+ var character: String = str(character_value)
+ var key_button: Button = _make_button(character, CHARACTER_KEY_SIZE)
+ key_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
+ key_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
+ key_button.add_theme_font_size_override(
+ "font_size", CHARACTER_FONT_SIZE
+ )
+ key_button.pressed.connect(_type_character.bind(character))
+ row.add_child(key_button)
+ _key_buttons.append(key_button)
+ for index: int in _page_buttons.size():
+ _page_buttons[index].button_pressed = index == int(_page)
+ call_deferred("_configure_key_focus")
+
+
+func _configure_key_focus() -> void:
+ var focus_controls: Array[Control] = []
+ for button: Button in _key_buttons:
+ focus_controls.append(button)
+ for button: Button in [
+ _caret_left_button,
+ _caret_right_button,
+ _backspace_button,
+ _space_button,
+ _check_button,
+ ]:
+ focus_controls.append(button)
+ ControllerFocusNavigationType.configure_spatial_neighbors(focus_controls)
+ if not _key_buttons.is_empty():
+ _key_buttons[0].grab_focus()
+
+
+func _rows_for_page() -> Array:
+ match _page:
+ Page.UPPER:
+ return [
+ ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
+ ["A", "S", "D", "F", "G", "H", "J", "K", "L"],
+ ["Z", "X", "C", "V", "B", "N", "M"],
+ ]
+ Page.SYMBOLS:
+ return [
+ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
+ ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
+ ["-", "_", "=", "+", "[", "]", "{", "}"],
+ [".", ",", "?", "/", ":", ";", "'", "\""]
+ ]
+ _:
+ return [
+ ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
+ ["a", "s", "d", "f", "g", "h", "j", "k", "l"],
+ ["z", "x", "c", "v", "b", "n", "m"],
+ ]
+
+
+func _build_interface() -> void:
+ var backdrop := ColorRect.new()
+ backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ backdrop.color = Color(0.012, 0.075, 0.105, 0.82)
+ backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
+ add_child(backdrop)
+ var screen_margin := MarginContainer.new()
+ screen_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ for side: StringName in [
+ &"margin_left", &"margin_top", &"margin_right", &"margin_bottom"
+ ]:
+ screen_margin.add_theme_constant_override(side, 14)
+ add_child(screen_margin)
+ var panel := PanelContainer.new()
+ panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ panel.add_theme_stylebox_override(
+ "panel",
+ _make_style(Color(0.075, 0.27, 0.34, 0.98), 18, 4)
+ )
+ screen_margin.add_child(panel)
+ var margin := MarginContainer.new()
+ for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]:
+ margin.add_theme_constant_override(side, 18)
+ panel.add_child(margin)
+ var layout := VBoxContainer.new()
+ layout.add_theme_constant_override("separation", 10)
+ margin.add_child(layout)
+ var preview_panel := PanelContainer.new()
+ preview_panel.custom_minimum_size = Vector2(0.0, 86.0)
+ preview_panel.add_theme_stylebox_override(
+ "panel",
+ _make_style(Color(0.82, 0.94, 0.95, 1.0), 10, 2)
+ )
+ layout.add_child(preview_panel)
+ _preview = Label.new()
+ _preview.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0))
+ _preview.add_theme_font_size_override("font_size", 30)
+ _preview.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
+ _preview.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _preview.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
+ _preview.add_theme_constant_override("outline_size", 0)
+ preview_panel.add_child(_preview)
+ var page_row := HBoxContainer.new()
+ page_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ page_row.add_theme_constant_override("separation", 12)
+ layout.add_child(page_row)
+ for page_name: String in ["lower", "upper", "symbols"]:
+ var page_button: Button = _make_button(page_name, Vector2(150.0, 44.0))
+ page_button.toggle_mode = true
+ page_button.focus_mode = Control.FOCUS_NONE
+ page_button.pressed.connect(_set_page.bind(_page_buttons.size()))
+ page_row.add_child(page_button)
+ _page_buttons.append(page_button)
+ _keys_host = VBoxContainer.new()
+ _keys_host.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _keys_host.alignment = BoxContainer.ALIGNMENT_CENTER
+ _keys_host.add_theme_constant_override("separation", 12)
+ layout.add_child(_keys_host)
+ var utility_row := HBoxContainer.new()
+ utility_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ utility_row.add_theme_constant_override("separation", 12)
+ layout.add_child(utility_row)
+ _caret_left_button = _make_button("LT <", Vector2(118.0, 64.0))
+ _caret_left_button.tooltip_text = "move text cursor left"
+ _caret_left_button.pressed.connect(_move_caret.bind(-1))
+ utility_row.add_child(_caret_left_button)
+ _caret_right_button = _make_button("> RT", Vector2(118.0, 64.0))
+ _caret_right_button.tooltip_text = "move text cursor right"
+ _caret_right_button.pressed.connect(_move_caret.bind(1))
+ utility_row.add_child(_caret_right_button)
+ _backspace_button = _make_button("backspace X", Vector2(180.0, 64.0))
+ _backspace_button.pressed.connect(_backspace)
+ utility_row.add_child(_backspace_button)
+ _space_button = _make_button("space", Vector2(350.0, 64.0))
+ _space_button.size_flags_stretch_ratio = 4.0
+ _space_button.pressed.connect(_type_space)
+ utility_row.add_child(_space_button)
+ _check_button = _make_button("", Vector2(92.0, 64.0))
+ _check_button.icon = CHECK_ICON
+ _check_button.expand_icon = true
+ _check_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _check_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _check_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
+ _check_button.tooltip_text = "submit"
+ _check_button.accessibility_name = "submit text"
+ _check_button.add_theme_constant_override("icon_max_width", 42)
+ _check_button.pressed.connect(_submit)
+ utility_row.add_child(_check_button)
+ _rebuild_keys()
+
+
+func _make_button(label: String, minimum_size: Vector2) -> Button:
+ var button := Button.new()
+ button.text = label
+ button.custom_minimum_size = minimum_size
+ button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ button.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ button.focus_mode = Control.FOCUS_ALL
+ button.add_theme_font_size_override("font_size", 22)
+ button.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0))
+ button.add_theme_color_override("font_hover_color", Color(0.025, 0.12, 0.17, 1.0))
+ button.add_theme_color_override("font_focus_color", Color(0.025, 0.12, 0.17, 1.0))
+ button.add_theme_stylebox_override(
+ "normal",
+ _make_style(Color(0.72, 0.88, 0.91, 1.0), 10, 2)
+ )
+ button.add_theme_stylebox_override(
+ "hover",
+ _make_style(Color(0.87, 0.96, 0.96, 1.0), 10, 3)
+ )
+ button.add_theme_stylebox_override("focus", button.get_theme_stylebox("hover"))
+ button.add_theme_stylebox_override(
+ "pressed",
+ _make_style(Color(0.44, 0.72, 0.77, 1.0), 10, 3)
+ )
+ return button
+
+
+func _make_style(color: Color, radius: int, border_width: int) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = color
+ style.border_color = Color(0.025, 0.12, 0.17, 1.0)
+ style.set_border_width_all(border_width)
+ style.set_corner_radius_all(radius)
+ return style
diff --git a/ui/on_screen_keyboard.gd.uid b/ui/on_screen_keyboard.gd.uid
new file mode 100644
index 0000000..da300a2
--- /dev/null
+++ b/ui/on_screen_keyboard.gd.uid
@@ -0,0 +1 @@
+uid://dcanibf8hordh
diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd
index eb1a30a..dccac73 100644
--- a/ui/pause_menu.gd
+++ b/ui/pause_menu.gd
@@ -2,24 +2,36 @@ class_name PauseMenu
extends Control
const INPUT_OWNER: StringName = &"game_menu"
+const PAUSE_DESKTOP_REFERENCE_SIZE: Vector2 = Vector2(1280.0, 720.0)
+const PAUSE_COMPACT_REFERENCE_SIZE: Vector2 = Vector2(640.0, 480.0)
+const COMPACT_HEIGHT_THRESHOLD: float = 560.0
+const VISIBILITY_FADE_DURATION: float = 0.55
const PlayerType = preload("res://player/player.gd")
const SaveManagerType = preload("res://save/player_save_manager.gd")
const SettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
const SettingsPanelType = preload("res://ui/settings_panel.gd")
+const BubbleConfirmationPageType = preload(
+ "res://ui/components/bubble_menu/bubble_confirmation_page.gd"
+)
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
+const NetworkSessionType = preload("res://network/network_session.gd")
+const SavedServerStoreType = preload("res://network/saved_server_store.gd")
+const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
signal return_to_title_requested
signal reset_progress_requested
signal quit_requested
signal menu_visibility_changed(is_open: bool)
+signal join_game_requested(endpoint: String)
enum ConfirmationAction {
NONE,
RETURN_TO_TITLE,
RESET_PROGRESS,
QUIT_ANYWAY,
+ JOIN_ANOTHER,
}
enum CloseReason {
@@ -32,20 +44,25 @@ enum CloseReason {
TEARDOWN,
}
-@onready var _root_panel: PanelContainer = %RootPanel
+@onready var _presentation_scale_root: Control = %PausePresentationScaleRoot
+@onready var _root_page: SettingsBubblePage = %RootPage
@onready var _settings_panel: SettingsPanelType = %SettingsPanel
-@onready var _confirmation_panel: PanelContainer = %ConfirmationPanel
-@onready var _confirmation_title: Label = %ConfirmationTitle
-@onready var _confirmation_text: Label = %ConfirmationText
-@onready var _confirm_button: Button = %ConfirmButton
-@onready var _cancel_button: Button = %CancelConfirmButton
+@onready var _transition_flurry: BubbleTransitionFlurry = (
+ %TransitionBubbleLayer
+)
+@onready var _confirmation_page: BubbleConfirmationPageType = (
+ %ConfirmationPage
+)
@onready var _feedback: Label = %FeedbackLabel
-@onready var _save_button: Button = %SaveButton
+@onready var _save_button: BubbleButton = %SaveButton
+@onready var _join_game_page: JoinGamePageType = %JoinGamePage
var _player: PlayerType
var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType
var _fishing_spot: FishingSpotType
+var _network_session: NetworkSessionType
+var _saved_servers: SavedServerStoreType
var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
@@ -53,19 +70,33 @@ var _control_snapshot_stored: bool = false
var _mouse_snapshot_stored: bool = false
var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE
var _action_in_progress: bool = false
+var _root_transition_active: bool = false
+var _root_transition_generation: int = 0
+var _closing_menu: bool = false
+var _pending_join_endpoint: String = ""
func _ready() -> void:
%ResumeButton.pressed.connect(resume)
_save_button.pressed.connect(_save_now)
%SettingsButton.pressed.connect(_open_settings)
+ %JoinGameButton.pressed.connect(_open_join_game)
%ReturnToTitleButton.pressed.connect(_confirm_return_to_title)
%ResetProgressButton.pressed.connect(_confirm_reset_progress)
%QuitButton.pressed.connect(_request_quit)
- _confirm_button.pressed.connect(_accept_confirmation)
- _cancel_button.pressed.connect(_close_confirmation)
+ _confirmation_page.confirmed.connect(_accept_confirmation)
+ _confirmation_page.cancelled.connect(_close_confirmation)
_settings_panel.applied.connect(_on_settings_applied)
_settings_panel.closed.connect(_on_settings_closed)
+ _settings_panel.closing.connect(_on_settings_closing)
+ _settings_panel.navigation_transition_started.connect(
+ _emit_transition_flurry
+ )
+ _join_game_page.join_requested.connect(_on_join_game_requested)
+ _join_game_page.back_requested.connect(_close_join_game)
+ _confirmation_page.hide_page()
+ resized.connect(_update_responsive_pause_stage)
+ call_deferred("_update_responsive_pause_stage")
func setup(
@@ -73,11 +104,20 @@ func setup(
save_manager: SaveManagerType,
settings_manager: SettingsManagerType,
fishing_spot: FishingSpotType,
+ network_session: NetworkSessionType,
+ saved_servers: SavedServerStoreType,
+ server_trust: ServerTrustStore,
+ discovery: DiscoveryClient,
) -> void:
_player = player
_save_manager = save_manager
_settings_manager = settings_manager
_fishing_spot = fishing_spot
+ _network_session = network_session
+ _saved_servers = saved_servers
+ _join_game_page.setup(
+ network_session, saved_servers, true, server_trust, discovery
+ )
if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
_fishing_spot.bite_activated.connect(_on_bite_activated)
@@ -95,19 +135,28 @@ func open_menu() -> void:
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_feedback.text = ""
- _root_panel.show()
_settings_panel.hide()
- _confirmation_panel.hide()
+ _join_game_page.close_page()
+ _confirmation_page.hide_page()
_confirmation_action = ConfirmationAction.NONE
+ _action_in_progress = false
+ _root_page.hide_page()
show()
- %ResumeButton.grab_focus()
+ _update_responsive_pause_stage()
+ _begin_root_entry(false)
menu_visibility_changed.emit(true)
func resume() -> void:
- if not visible or _action_in_progress:
+ if (
+ not visible
+ or _action_in_progress
+ or _root_transition_active
+ or _settings_panel.visible
+ or _confirmation_action != ConfirmationAction.NONE
+ ):
return
- close_menu(CloseReason.USER_RETURN)
+ _begin_root_exit(_finish_user_close)
func close_for_title_transition() -> void:
@@ -124,39 +173,27 @@ func close_menu(
) -> void:
if not visible:
return
- if _settings_panel.visible:
- _settings_panel.close_panel()
- hide()
- _root_panel.show()
- _settings_panel.hide()
- _confirmation_panel.hide()
- _confirmation_action = ConfirmationAction.NONE
- _action_in_progress = false
- if _fishing_spot != null and is_instance_valid(_fishing_spot):
- _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
- get_viewport().gui_release_focus()
- if restore_controls:
- _restore_controls()
- else:
- _control_snapshot_stored = false
- _apply_mouse_close_policy(reason)
- menu_visibility_changed.emit(false)
+ _finish_close(reason, restore_controls)
func handle_escape() -> bool:
if not visible:
return false
- if _confirmation_panel.visible:
+ if _root_transition_active or _confirmation_page.is_transitioning():
+ return true
+ if _confirmation_page.visible:
_close_confirmation()
+ elif _join_game_page.visible:
+ _close_join_game()
elif _settings_panel.visible:
- _settings_panel.close_panel()
+ _settings_panel.handle_back()
else:
resume()
return true
func _save_now() -> void:
- if _action_in_progress:
+ if _action_in_progress or _root_transition_active:
return
_action_in_progress = true
_save_button.disabled = true
@@ -173,21 +210,82 @@ func _reenable_save_button() -> void:
func _open_settings() -> void:
- if _action_in_progress or _confirmation_panel.visible:
+ if (
+ _action_in_progress
+ or _root_transition_active
+ or _confirmation_action != ConfirmationAction.NONE
+ ):
return
- _root_panel.hide()
- _settings_panel.open_panel(_settings_manager)
+ _begin_root_exit(_finish_open_settings)
+
+
+func _open_join_game() -> void:
+ if (
+ _action_in_progress
+ or _root_transition_active
+ or _confirmation_action != ConfirmationAction.NONE
+ ):
+ return
+ _begin_root_exit(_finish_open_join_game)
+
+
+func _finish_open_join_game() -> void:
+ _join_game_page.open_page()
+
+
+func _close_join_game() -> void:
+ _join_game_page.close_page()
+ _begin_root_entry(true)
+
+
+func _on_join_game_requested(endpoint: String) -> void:
+ if _action_in_progress or _root_transition_active:
+ return
+ _pending_join_endpoint = endpoint
+ _open_confirmation(
+ ConfirmationAction.JOIN_ANOTHER,
+ "join another game?",
+ (
+ "your progression will be saved first. "
+ + "current players will be disconnected if you are hosting."
+ ),
+ "save and join",
+ false
+ )
+
+
+func report_network_error(message: String) -> void:
+ _feedback.text = message
+ if _join_game_page.visible:
+ _join_game_page.set_status(message)
+
+
+func _finish_open_settings() -> void:
+ _settings_panel.open_panel(
+ _settings_manager,
+ SettingsPanelType.PresentationMode.GAMEPLAY_MODAL,
+ true
+ )
func _on_settings_applied() -> void:
- _root_panel.show()
+ if _closing_menu:
+ return
_feedback.text = "settings saved."
- %SettingsButton.grab_focus()
func _on_settings_closed() -> void:
- _root_panel.show()
- %SettingsButton.grab_focus()
+ pass
+
+
+func _on_settings_closing() -> void:
+ if _closing_menu:
+ return
+ await get_tree().create_timer(
+ UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
+ ).timeout
+ if _settings_panel.visible:
+ _begin_root_entry(true)
func _confirm_return_to_title() -> void:
@@ -215,7 +313,7 @@ func _confirm_reset_progress() -> void:
func _request_quit() -> void:
- if _action_in_progress:
+ if _action_in_progress or _root_transition_active:
return
_action_in_progress = true
var settings_saved: bool = _settings_manager.save_if_dirty()
@@ -240,59 +338,201 @@ func _open_confirmation(
confirm_text: String,
dangerous: bool,
) -> void:
- if _action_in_progress:
+ if _action_in_progress or _root_transition_active:
return
_confirmation_action = action
- _confirmation_title.text = title
- _confirmation_text.text = message
- _confirm_button.text = confirm_text
- _confirm_button.theme_type_variation = (
- &"DangerButton" if dangerous else StringName()
+ _confirmation_page.configure(
+ "%s\n\n%s" % [title, message],
+ confirm_text,
+ "cancel",
+ (
+ BubbleConfirmationPageType.InitialFocus.CANCEL
+ if dangerous
+ else BubbleConfirmationPageType.InitialFocus.CONFIRM
+ )
)
- _root_panel.hide()
- _confirmation_panel.show()
- if dangerous:
- _cancel_button.grab_focus()
- else:
- _confirm_button.grab_focus()
+ # Confirmation actions reject input while the root page is still leaving.
+ # Finish that exit before exposing an interactive confirmation page so a
+ # pointer click can never land in the overlap window and be discarded.
+ _begin_root_exit(_show_confirmation, false)
+
+
+func _show_confirmation() -> void:
+ _confirmation_page.transition_in(
+ 0.0,
+ _finish_confirmation_open
+ )
+
+
+func _finish_confirmation_open() -> void:
+ pass
func _close_confirmation() -> void:
+ if _root_transition_active or _confirmation_page.is_transitioning():
+ return
_confirmation_action = ConfirmationAction.NONE
- _confirmation_panel.hide()
- _root_panel.show()
- %ResumeButton.grab_focus()
+ _emit_transition_flurry()
+ _confirmation_page.transition_out(
+ 0.0,
+ _finish_confirmation_cancel
+ )
+
+
+func _finish_confirmation_cancel() -> void:
+ _begin_root_entry(true)
func _accept_confirmation() -> void:
- if _action_in_progress:
+ if (
+ _action_in_progress
+ or _root_transition_active
+ or _confirmation_page.is_transitioning()
+ ):
return
var action: ConfirmationAction = _confirmation_action
_confirmation_action = ConfirmationAction.NONE
- _confirmation_panel.hide()
_action_in_progress = true
+ _confirmation_page.transition_out(
+ VISIBILITY_FADE_DURATION,
+ _finish_confirmation_accept.bind(action)
+ )
+
+
+func _finish_confirmation_accept(action: ConfirmationAction) -> void:
match action:
ConfirmationAction.RETURN_TO_TITLE:
if _save_manager.save_now():
return_to_title_requested.emit()
else:
_feedback.text = "save failed. previous save was preserved."
- _root_panel.show()
+ _action_in_progress = false
+ _begin_root_entry(false)
+ return
ConfirmationAction.RESET_PROGRESS:
reset_progress_requested.emit()
ConfirmationAction.QUIT_ANYWAY:
quit_requested.emit()
+ ConfirmationAction.JOIN_ANOTHER:
+ _join_game_page.close_page()
+ join_game_requested.emit(_pending_join_endpoint)
+ _pending_join_endpoint = ""
_:
- _root_panel.show()
+ _action_in_progress = false
+ _begin_root_entry(false)
+ return
_action_in_progress = false
func report_reset_failure() -> void:
_action_in_progress = false
- _root_panel.show()
- _confirmation_panel.hide()
+ _confirmation_action = ConfirmationAction.NONE
_feedback.text = "reset failed. your progression was preserved."
- %ResumeButton.grab_focus()
+ if visible:
+ _begin_root_entry(false)
+
+
+func _begin_root_entry(flurry_already_emitted: bool) -> void:
+ if _root_transition_active:
+ return
+ _root_transition_generation += 1
+ _root_transition_active = true
+ if not flurry_already_emitted:
+ _emit_transition_flurry()
+ var generation: int = _root_transition_generation
+ _root_page.transition_in(
+ true,
+ _finish_root_entry.bind(generation),
+ 0.0
+ )
+
+
+func _finish_root_entry(generation: int) -> void:
+ if generation != _root_transition_generation or not visible:
+ return
+ _root_transition_active = false
+
+
+func _begin_root_exit(
+ completed: Callable,
+ overlap_next_page: bool = true,
+) -> void:
+ if _root_transition_active or not _root_page.visible:
+ return
+ _root_transition_generation += 1
+ _root_transition_active = true
+ _emit_transition_flurry()
+ var generation: int = _root_transition_generation
+ _root_page.transition_out(
+ _finish_root_exit.bind(
+ generation,
+ Callable() if overlap_next_page else completed,
+ ),
+ 0.0
+ )
+ if not overlap_next_page:
+ return
+ await get_tree().create_timer(
+ UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
+ ).timeout
+ if generation != _root_transition_generation or not visible:
+ return
+ completed.call()
+
+
+func _finish_root_exit(
+ generation: int,
+ completed: Callable,
+) -> void:
+ if generation != _root_transition_generation or not visible:
+ return
+ _root_transition_active = false
+ if completed.is_valid():
+ completed.call()
+
+
+func _finish_user_close() -> void:
+ _finish_close(CloseReason.USER_RETURN, true)
+
+
+func _finish_close(reason: CloseReason, restore_controls: bool) -> void:
+ _closing_menu = true
+ if _settings_panel.visible:
+ _settings_panel.close_panel(true)
+ _closing_menu = false
+ _root_transition_generation += 1
+ _root_transition_active = false
+ _settings_panel.hide()
+ _join_game_page.close_page()
+ _root_page.hide_page()
+ _confirmation_page.hide_page()
+ _confirmation_action = ConfirmationAction.NONE
+ _action_in_progress = false
+ _transition_flurry.clear_flurries()
+ hide()
+ if _fishing_spot != null and is_instance_valid(_fishing_spot):
+ _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+ if restore_controls:
+ _restore_controls()
+ else:
+ _control_snapshot_stored = false
+ _apply_mouse_close_policy(reason)
+ menu_visibility_changed.emit(false)
+
+
+func _emit_transition_flurry() -> void:
+ _transition_flurry.emit_flurry()
+
+
+func _update_responsive_pause_stage() -> void:
+ if not is_node_ready():
+ return
+ _presentation_scale_root.size = PAUSE_DESKTOP_REFERENCE_SIZE
+ _presentation_scale_root.scale = Vector2.ONE
+ _presentation_scale_root.position = Vector2.ZERO
func _restore_controls() -> void:
diff --git a/ui/pause_menu.tscn b/ui/pause_menu.tscn
index e94252a..941e26e 100644
--- a/ui/pause_menu.tscn
+++ b/ui/pause_menu.tscn
@@ -1,8 +1,20 @@
-[gd_scene load_steps=4 format=3]
+[gd_scene load_steps=16 format=3]
[ext_resource type="Script" path="res://ui/pause_menu.gd" id="1_script"]
[ext_resource type="PackedScene" path="res://ui/settings_panel.tscn" id="2_settings"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="3_theme"]
+[ext_resource type="Script" path="res://ui/settings/settings_bubble_page.gd" id="4_page"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_button.tscn" id="5_bubble"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="6_cluster"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="7_profile"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_transition_flurry.gd" id="8_flurry"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="9_confirmation"]
+[ext_resource type="PackedScene" path="res://ui/network/join_game_page.tscn" id="10_join_page"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/online_dark.png" id="11_online_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/save_dark.png" id="12_save_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/settings_dark.png" id="13_settings_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/undo_dark.png" id="14_undo_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_dark.png" id="15_x_dark"]
[node name="PauseMenu" type="Control"]
unique_name_in_owner = true
@@ -18,92 +30,183 @@ mouse_filter = 0
theme = ExtResource("3_theme")
script = ExtResource("1_script")
-[node name="DimBackground" type="ColorRect" parent="."]
+[node name="TransitionBubbleLayer" type="Control" parent="."]
+unique_name_in_owner = true
+z_index = 1
+clip_contents = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-mouse_filter = 0
-color = Color(0.02, 0.05, 0.07, 0.78)
+mouse_filter = 2
+script = ExtResource("8_flurry")
-[node name="RootCenter" type="CenterContainer" parent="."]
+[node name="ResponsivePauseStage" type="Control" parent="."]
+unique_name_in_owner = true
+z_index = 2
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
+mouse_filter = 1
-[node name="RootPanel" type="PanelContainer" parent="RootCenter"]
+[node name="PausePresentationScaleRoot" type="Control" parent="ResponsivePauseStage"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(420, 0)
-layout_mode = 2
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
-[node name="Margin" type="MarginContainer" parent="RootCenter/RootPanel"]
-layout_mode = 2
-theme_override_constants/margin_left = 32
-theme_override_constants/margin_top = 26
-theme_override_constants/margin_right = 32
-theme_override_constants/margin_bottom = 26
-
-[node name="Content" type="VBoxContainer" parent="RootCenter/RootPanel/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 11
-
-[node name="Title" type="Label" parent="RootCenter/RootPanel/Margin/Content"]
-layout_mode = 2
-theme_override_font_sizes/font_size = 30
-text = "game menu"
-horizontal_alignment = 1
-
-[node name="ResumeButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"]
+[node name="RootPage" type="Control" parent="ResponsivePauseStage/PausePresentationScaleRoot"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 43)
-layout_mode = 2
-text = "return to game"
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("4_page")
+page_id = &"pause"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
+focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
+initial_focus_path = NodePath("BubbleCluster/ResumeButton")
+back_focus_path = NodePath("BubbleCluster/ResumeButton")
+maximum_layout_size = Vector2(720, 520)
+compact_maximum_layout_size = Vector2(544, 400)
+compact_width_threshold = 680.0
+compact_height_threshold = 500.0
-[node name="SaveButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 43)
-layout_mode = 2
-text = "save now"
+[node name="BubbleCluster" type="Control" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("6_cluster")
+profile = ExtResource("7_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
-[node name="SettingsButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"]
+[node name="ResumeButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 43)
-layout_mode = 2
-text = "settings"
+custom_minimum_size = Vector2(0, 0)
+texture_filter = 1
+icon = ExtResource("14_undo_dark")
+tooltip_text = "return to game"
+accessibility_name = "return to game"
+neutral_size = Vector2(174, 164)
+desktop_anchor = Vector2(335, 220)
+compact_anchor = Vector2(350, 225)
+compact_minimum_size = Vector2(132, 126)
+minimum_font_size = 18
+maximum_font_size = 27
+motion_phase = 0.3
-[node name="ReturnToTitleButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"]
+[node name="SaveButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 43)
-layout_mode = 2
-text = "return to title"
+custom_minimum_size = Vector2(0, 0)
+texture_filter = 1
+icon = ExtResource("12_save_dark")
+tooltip_text = "save now"
+accessibility_name = "save now"
+neutral_size = Vector2(126, 120)
+desktop_anchor = Vector2(185, 185)
+compact_anchor = Vector2(165, 180)
+compact_minimum_size = Vector2(100, 96)
+minimum_font_size = 15
+maximum_font_size = 21
+motion_phase = 1.15
-[node name="ResetProgressButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"]
+[node name="SettingsButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 43)
-layout_mode = 2
-theme_type_variation = &"DangerButton"
-text = "reset progress"
+custom_minimum_size = Vector2(0, 0)
+texture_filter = 1
+icon = ExtResource("13_settings_dark")
+tooltip_text = "settings"
+accessibility_name = "settings"
+neutral_size = Vector2(144, 136)
+desktop_anchor = Vector2(485, 170)
+compact_anchor = Vector2(515, 175)
+compact_minimum_size = Vector2(112, 108)
+minimum_font_size = 16
+maximum_font_size = 23
+motion_phase = 2.05
-[node name="QuitButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"]
+[node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 43)
-layout_mode = 2
-text = "quit"
+custom_minimum_size = Vector2(0, 0)
+texture_filter = 1
+icon = ExtResource("11_online_dark")
+tooltip_text = "join game"
+accessibility_name = "join game"
+neutral_size = Vector2(126, 120)
+desktop_anchor = Vector2(105, 320)
+compact_anchor = Vector2(105, 320)
+compact_minimum_size = Vector2(96, 92)
+minimum_font_size = 15
+maximum_font_size = 21
+motion_phase = 2.55
-[node name="FeedbackLabel" type="Label" parent="RootCenter/RootPanel/Margin/Content"]
+[node name="ReturnToTitleButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 34)
-layout_mode = 2
+custom_minimum_size = Vector2(0, 0)
+text = "return\nto title"
+accessibility_name = "return to title"
+neutral_size = Vector2(132, 126)
+desktop_anchor = Vector2(520, 340)
+compact_anchor = Vector2(535, 360)
+compact_minimum_size = Vector2(106, 102)
+minimum_font_size = 14
+maximum_font_size = 21
+motion_phase = 3.0
+
+[node name="ResetProgressButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "reset\nprogress"
+accessibility_name = "reset progress"
+neutral_size = Vector2(126, 120)
+desktop_anchor = Vector2(245, 365)
+compact_anchor = Vector2(200, 380)
+compact_minimum_size = Vector2(102, 98)
+minimum_font_size = 14
+maximum_font_size = 20
+motion_phase = 3.9
+
+[node name="QuitButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+texture_filter = 1
+icon = ExtResource("15_x_dark")
+tooltip_text = "quit"
+accessibility_name = "quit"
+neutral_size = Vector2(102, 98)
+desktop_anchor = Vector2(390, 395)
+compact_anchor = Vector2(390, 410)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 14
+maximum_font_size = 18
+motion_phase = 4.75
+
+[node name="FeedbackLabel" type="Label" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 12
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -38.0
+offset_bottom = -6.0
+grow_horizontal = 2
+grow_vertical = 0
+mouse_filter = 2
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
-[node name="SettingsCenter" type="CenterContainer" parent="."]
+[node name="SettingsCenter" type="CenterContainer" parent="ResponsivePauseStage/PausePresentationScaleRoot"]
+unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -112,64 +215,25 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
-[node name="SettingsPanel" parent="SettingsCenter" instance=ExtResource("2_settings")]
+[node name="SettingsPanel" parent="ResponsivePauseStage/PausePresentationScaleRoot/SettingsCenter" instance=ExtResource("2_settings")]
unique_name_in_owner = true
layout_mode = 2
-[node name="ConfirmationCenter" type="CenterContainer" parent="."]
+[node name="ConfirmationPage" parent="ResponsivePauseStage/PausePresentationScaleRoot" instance=ExtResource("9_confirmation")]
+unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-mouse_filter = 2
-[node name="ConfirmationPanel" type="PanelContainer" parent="ConfirmationCenter"]
+[node name="JoinGamePage" parent="ResponsivePauseStage/PausePresentationScaleRoot" instance=ExtResource("10_join_page")]
unique_name_in_owner = true
-visible = false
-custom_minimum_size = Vector2(520, 0)
-layout_mode = 2
-
-[node name="Margin" type="MarginContainer" parent="ConfirmationCenter/ConfirmationPanel"]
-layout_mode = 2
-theme_override_constants/margin_left = 28
-theme_override_constants/margin_top = 24
-theme_override_constants/margin_right = 28
-theme_override_constants/margin_bottom = 24
-
-[node name="Content" type="VBoxContainer" parent="ConfirmationCenter/ConfirmationPanel/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 15
-
-[node name="ConfirmationTitle" type="Label" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content"]
-unique_name_in_owner = true
-layout_mode = 2
-theme_override_colors/font_color = Color(0.937, 0.357, 0.384, 1)
-theme_override_font_sizes/font_size = 24
-text = "confirm"
-horizontal_alignment = 1
-
-[node name="ConfirmationText" type="Label" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "are you sure?"
-horizontal_alignment = 1
-autowrap_mode = 2
-
-[node name="Buttons" type="HBoxContainer" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content"]
-layout_mode = 2
-theme_override_constants/separation = 12
-alignment = 1
-
-[node name="ConfirmButton" type="Button" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content/Buttons"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(190, 43)
-layout_mode = 2
-text = "confirm"
-
-[node name="CancelConfirmButton" type="Button" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content/Buttons"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(130, 43)
-layout_mode = 2
-text = "cancel"
+z_index = 4
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
diff --git a/ui/pixelation_reset_overlay.gd b/ui/pixelation_reset_overlay.gd
new file mode 100644
index 0000000..733a29b
--- /dev/null
+++ b/ui/pixelation_reset_overlay.gd
@@ -0,0 +1,112 @@
+class_name PixelationResetOverlay
+extends CanvasLayer
+
+const SCREEN_MARGIN: float = 16.0
+const FADE_DURATION: float = 0.55
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+
+signal reset_requested
+signal return_to_settings_requested
+
+@onready var _presentation_root: Control = %ResetPresentationRoot
+@onready var _reset_button: Button = %ResetPixelationButton
+
+var _fade_tween: Tween
+var _fade_generation: int = 0
+var _settings_open: bool = false
+
+
+func _ready() -> void:
+ _reset_button.pressed.connect(reset_requested.emit)
+ _reset_button.gui_input.connect(_on_reset_button_gui_input)
+ get_viewport().size_changed.connect(_update_responsive_layout)
+ _presentation_root.modulate.a = 0.0
+ _set_interactive(false)
+ _update_responsive_layout()
+
+
+func set_settings_open(is_open: bool) -> void:
+ _settings_open = is_open
+ _fade_generation += 1
+ if _fade_tween != null:
+ _fade_tween.kill()
+ _fade_tween = null
+ if is_open:
+ show()
+ _set_interactive(true)
+ else:
+ _set_interactive(false)
+ var target_alpha: float = 1.0 if is_open else 0.0
+ if is_equal_approx(_presentation_root.modulate.a, target_alpha):
+ _finish_fade(_fade_generation, is_open)
+ return
+ var generation: int = _fade_generation
+ _fade_tween = create_tween()
+ _fade_tween.tween_property(
+ _presentation_root,
+ "modulate:a",
+ target_alpha,
+ FADE_DURATION
+ ).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
+ _fade_tween.finished.connect(
+ _finish_fade.bind(generation, is_open),
+ CONNECT_ONE_SHOT
+ )
+
+
+func focus_reset_button() -> void:
+ if visible and _settings_open:
+ _reset_button.grab_focus()
+
+
+func _on_reset_button_gui_input(event: InputEvent) -> void:
+ if (
+ event.is_action_pressed("ui_up")
+ or event.is_action_pressed("ui_focus_prev")
+ ):
+ return_to_settings_requested.emit()
+ get_viewport().set_input_as_handled()
+
+
+func _update_responsive_layout() -> void:
+ if not is_node_ready():
+ return
+ var display_size: Vector2 = Vector2(get_window().size)
+ if display_size.x <= 1.0 or display_size.y <= 1.0:
+ return
+ var presentation_scale: float = (
+ UIReferencePresentationType.get_scale(display_size)
+ )
+ _presentation_root.scale = Vector2.ONE * presentation_scale
+ _presentation_root.position = (
+ UIReferencePresentationType.get_offset(display_size)
+ + (
+ UIReferencePresentationType.REFERENCE_SIZE
+ - _presentation_root.size
+ - Vector2.ONE * SCREEN_MARGIN
+ ) * presentation_scale
+ )
+
+
+func _set_interactive(interactive: bool) -> void:
+ if not interactive and _reset_button.has_focus():
+ _reset_button.release_focus()
+ _reset_button.focus_mode = (
+ Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
+ )
+ _reset_button.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _finish_fade(generation: int, faded_in: bool) -> void:
+ if generation != _fade_generation or faded_in != _settings_open:
+ return
+ _fade_tween = null
+ _presentation_root.modulate.a = 1.0 if faded_in else 0.0
+ if not faded_in:
+ hide()
diff --git a/ui/pixelation_reset_overlay.gd.uid b/ui/pixelation_reset_overlay.gd.uid
new file mode 100644
index 0000000..c17c7e7
--- /dev/null
+++ b/ui/pixelation_reset_overlay.gd.uid
@@ -0,0 +1 @@
+uid://bg7x4pmgy6mhn
diff --git a/ui/pixelation_reset_overlay.tscn b/ui/pixelation_reset_overlay.tscn
new file mode 100644
index 0000000..0ffef5a
--- /dev/null
+++ b/ui/pixelation_reset_overlay.tscn
@@ -0,0 +1,104 @@
+[gd_scene load_steps=7 format=3]
+
+[ext_resource type="Script" path="res://ui/pixelation_reset_overlay.gd" id="1_script"]
+[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_reset_normal"]
+content_margin_left = 18.0
+content_margin_top = 18.0
+content_margin_right = 18.0
+content_margin_bottom = 18.0
+bg_color = Color(0.035, 0.145, 0.22, 1)
+border_width_left = 2
+border_width_top = 2
+border_width_right = 2
+border_width_bottom = 2
+border_color = Color(0.91, 0.98, 1, 0.85)
+corner_radius_top_left = 74
+corner_radius_top_right = 74
+corner_radius_bottom_right = 74
+corner_radius_bottom_left = 74
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_reset_hover"]
+content_margin_left = 18.0
+content_margin_top = 18.0
+content_margin_right = 18.0
+content_margin_bottom = 18.0
+bg_color = Color(0.055, 0.205, 0.29, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.91, 0.975, 0.985, 1)
+corner_radius_top_left = 74
+corner_radius_top_right = 74
+corner_radius_bottom_right = 74
+corner_radius_bottom_left = 74
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_reset_pressed"]
+content_margin_left = 18.0
+content_margin_top = 18.0
+content_margin_right = 18.0
+content_margin_bottom = 18.0
+bg_color = Color(0.025, 0.12, 0.19, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.94, 0.99, 1, 1)
+corner_radius_top_left = 74
+corner_radius_top_right = 74
+corner_radius_bottom_right = 74
+corner_radius_bottom_left = 74
+
+[sub_resource type="StyleBoxFlat" id="StyleBox_reset_disabled"]
+content_margin_left = 18.0
+content_margin_top = 18.0
+content_margin_right = 18.0
+content_margin_bottom = 18.0
+bg_color = Color(0.035, 0.145, 0.22, 0.72)
+border_width_left = 2
+border_width_top = 2
+border_width_right = 2
+border_width_bottom = 2
+border_color = Color(0.81, 0.9, 0.92, 0.55)
+corner_radius_top_left = 74
+corner_radius_top_right = 74
+corner_radius_bottom_right = 74
+corner_radius_bottom_left = 74
+
+[node name="PixelationResetOverlay" type="CanvasLayer"]
+layer = 300
+visible = false
+script = ExtResource("1_script")
+
+[node name="ResetPresentationRoot" type="Control" parent="."]
+unique_name_in_owner = true
+offset_right = 156.0
+offset_bottom = 148.0
+mouse_filter = 2
+theme = ExtResource("2_theme")
+
+[node name="ResetPixelationButton" type="Button" parent="ResetPresentationRoot"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(156, 148)
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+tooltip_text = "reset pixelation"
+accessibility_name = "reset pixelation"
+theme_override_colors/font_color = Color(0.82, 0.93, 0.96, 1)
+theme_override_colors/font_hover_color = Color(0.91, 0.975, 0.985, 1)
+theme_override_colors/font_focus_color = Color(0.91, 0.975, 0.985, 1)
+theme_override_colors/font_pressed_color = Color(0.7, 0.87, 0.92, 1)
+theme_override_colors/font_disabled_color = Color(0.66, 0.77, 0.8, 0.78)
+theme_override_font_sizes/font_size = 26
+theme_override_styles/normal = SubResource("StyleBox_reset_normal")
+theme_override_styles/hover = SubResource("StyleBox_reset_hover")
+theme_override_styles/pressed = SubResource("StyleBox_reset_pressed")
+theme_override_styles/focus = SubResource("StyleBox_reset_hover")
+theme_override_styles/disabled = SubResource("StyleBox_reset_disabled")
+text = "reset\npixelation"
diff --git a/ui/player_menu.gd b/ui/player_menu.gd
index 79b1fd0..7f73acd 100644
--- a/ui/player_menu.gd
+++ b/ui/player_menu.gd
@@ -5,10 +5,19 @@ const INPUT_OWNER: StringName = &"player_menu"
const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd")
+const FishQualityType = preload("res://fish/fish_quality.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
+const NetworkSessionType = preload("res://network/network_session.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
+const InventoryNotepadType = preload(
+ "res://ui/components/inventory_notepad.gd"
+)
+const CurrencyPresentationType = preload(
+ "res://ui/currency_presentation.gd"
+)
+const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const PlayerType = preload("res://player/player.gd")
@@ -18,20 +27,97 @@ const ItemDataType = preload("res://items/item_data.gd")
const OwnedItemType = preload("res://items/owned_item.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
-const ItemDragSourceType = preload("res://ui/item_drag_source.gd")
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
+const PlayerExperienceType = preload(
+ "res://progression/player_experience.gd"
+)
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
const FishBatchSelectionType = preload(
"res://ui/fish_batch_selection.gd"
)
+const BubbleButtonType = preload(
+ "res://ui/components/bubble_menu/bubble_button.gd"
+)
+const BubbleClusterType = preload(
+ "res://ui/components/bubble_menu/bubble_cluster.gd"
+)
+const BubbleContentShellType = preload(
+ "res://ui/components/bubble_menu/bubble_content_shell.gd"
+)
+const BubbleStatusBubbleType = preload(
+ "res://ui/components/bubble_menu/bubble_status_bubble.gd"
+)
+const NotepadInkActionType = preload(
+ "res://ui/components/bubble_menu/notepad_ink_action.gd"
+)
+const NotepadInkChoiceType = preload(
+ "res://ui/components/bubble_menu/notepad_ink_choice.gd"
+)
+const CoolerFishSpriteType = preload(
+ "res://ui/components/bubble_menu/cooler_fish_sprite.gd"
+)
+const CoolerFishSpriteScene = preload(
+ "res://ui/components/bubble_menu/cooler_fish_sprite.tscn"
+)
+const BagItemSpriteType = preload(
+ "res://ui/components/bubble_menu/bag_item_sprite.gd"
+)
+const BagItemSpriteScene = preload(
+ "res://ui/components/bubble_menu/bag_item_sprite.tscn"
+)
+const LogbookEntryType = preload(
+ "res://ui/components/bubble_menu/logbook_entry.gd"
+)
+const LogbookEntryScene = preload(
+ "res://ui/components/bubble_menu/logbook_entry.tscn"
+)
+
+const LOGBOOK_PAGE_DURATION: float = 0.18
+const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0)
+const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0)
+const COMPACT_HEIGHT_THRESHOLD: float = 560.0
+const NAVIGATION_PRESENTATION_SCALE: float = 0.60
+const NAVIGATION_CANONICAL_POSITION := Vector2(424.0, 44.0)
+const NAVIGATION_SELECTED_SCALE: float = 1.02
+const INVENTORY_TAB_LEFT_INSET: float = 96.0
+const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
signal menu_visibility_changed(is_open: bool)
+signal inventory_hotbar_context_changed(show_hotbar: bool)
+signal controller_hotbar_placement_requested(
+ assignment_kind: PlayerHotbarType.AssignmentKind,
+ identity: StringName,
+ initial_slot: int,
+)
+signal controller_hotbar_placement_ended
+signal controller_hotbar_management_requested(initial_slot: int)
+signal controller_hotbar_management_ended
+signal menu_exit_started
+signal shop_cooler_modal_changed(is_open: bool)
enum Section {
COOLER,
BAG,
+ TACKLE_BOX,
LOGBOOK,
+ NET,
+ MAIL,
+ PROFILE,
+ PLAYERS,
+}
+
+enum BagView {
+ EQUIPMENT,
+ CONSUMABLES,
+}
+
+enum TackleView {
+ BAIT,
+ LURES,
}
enum SortMode {
@@ -49,46 +135,174 @@ enum CloseReason {
TEARDOWN,
}
-@onready var _inventory_tab: Button = %InventoryTab
-@onready var _bag_tab: Button = %BagTab
-@onready var _logbook_tab: Button = %LogbookTab
-@onready var _close_button: Button = %CloseButton
-@onready var _wallet_balance: Label = %WalletBalance
+enum ControllerOwnership {
+ ITEM_LIST,
+ NOTEPAD_ACTIONS,
+ HOTBAR_MANAGEMENT,
+ HOTBAR_PLACEMENT,
+}
+
+const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0)
+const INVENTORY_MAIN_SIZE := Vector2(882.0, 484.0)
+const INVENTORY_PANEL_GAP := 16.0
+const INVENTORY_NOTEPAD_POSITION := Vector2(
+ INVENTORY_MAIN_POSITION.x + INVENTORY_MAIN_SIZE.x + INVENTORY_PANEL_GAP,
+ INVENTORY_MAIN_POSITION.y,
+)
+const INVENTORY_NOTEPAD_SIZE := Vector2(278.0, 484.0)
+const INVENTORY_HEADER_INSET := Vector2(24.0, 20.0)
+const INVENTORY_HEADER_HEIGHT := 40.0
+const INVENTORY_MAIN_CORNER_RADIUS: int = 58
+const INVENTORY_INNER_CORNER_RADIUS: int = 45
+const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0)
+
+@onready var _navigation_cluster: BubbleClusterType = %NavigationCluster
+@onready var _presentation_scale_root: Control = %PlayerMenuPresentationScaleRoot
+@onready var _cooler_page: Control = %CoolerPage
+@onready var _cooler_outer_wall: PanelContainer = %CoolerOuterWall
+@onready var _cooler_inner_liner: PanelContainer = %CoolerInnerLiner
+@onready var _cooler_water_surface: ColorRect = %WaterSurface
+@onready var _fish_field: Control = %FishField
+@onready var _cooler_sort_controls: HBoxContainer = %CoolerSortControls
+@onready var _cooler_sort_option: NotepadInkChoiceType = %CoolerSortOption
+@onready var _cooler_sort_direction: NotepadInkActionType = %CoolerSortDirection
+@onready var _detail_constellation: Control = %DetailConstellation
+@onready var _detail_bubble: Control = %DetailBubble
+@onready var _notepad_binding: Label = %BindingDecoration
+@onready var _notepad_title: Label = %NotepadTitle
+@onready var _notepad_rule: ColorRect = %NotepadRule
+@onready var _notepad_wallet_value: CurrencyAmount = %NotepadWalletValue
+@onready var _notepad_capacity_value: Label = %NotepadCapacityValue
+@onready var _cooler_detail_texture: TextureRect = %CoolerDetailTexture
+@onready var _cooler_detail_name: Label = %CoolerDetailName
+@onready var _cooler_detail_stats: Control = %CoolerDetailStats
+@onready var _cooler_weight_row: HBoxContainer = %CoolerWeightRow
+@onready var _cooler_weight_value: Label = %CoolerWeightValue
+@onready var _cooler_weight_unit: Label = %CoolerWeightUnit
+@onready var _cooler_offer_row: HBoxContainer = %CoolerOfferRow
+@onready var _cooler_offer_label: Label = %CoolerOfferLabel
+@onready var _cooler_offer_value: CurrencyAmount = %CoolerOfferValue
+@onready var _cooler_selection_summary: Control = %CoolerSelectionSummary
+@onready var _cooler_selection_empty: Label = %CoolerSelectionEmpty
+@onready var _cooler_selected_count_row: HBoxContainer = %CoolerSelectedCountRow
+@onready var _cooler_selected_count_value: Label = %CoolerSelectedCountValue
+@onready var _cooler_selected_count_label: Label = %CoolerSelectedCountLabel
+@onready var _cooler_combined_offer_row: HBoxContainer = %CoolerCombinedOfferRow
+@onready var _cooler_combined_offer_label: Label = %CoolerCombinedOfferLabel
+@onready var _cooler_combined_offer_value: CurrencyAmount = %CoolerCombinedOfferValue
+@onready var _favorite_bubble: NotepadInkActionType = %FavoriteBubble
+@onready var _sell_bubble: NotepadInkActionType = %SellBubble
+@onready var _sell_all_bubble: NotepadInkActionType = %SellAllBubble
+@onready var _bag_page: Control = %BagPage
+@onready var _tackle_box_page: Control = %TackleBoxPage
+@onready var _inventory_sub_tabs: HBoxContainer = %InventorySubTabs
+@onready var _cooler_sub_tab: Button = %CoolerSubTab
+@onready var _bag_sub_tab: Button = %BagSubTab
+@onready var _tackle_sub_tab: Button = %TackleSubTab
+@onready var _bag_filter_tabs: HBoxContainer = %BagFilterTabs
+@onready var _equipment_filter: Button = %EquipmentFilter
+@onready var _consumables_filter: Button = %ConsumablesFilter
+@onready var _tackle_main_panel: PanelContainer = %TackleMainPanel
+@onready var _tackle_detail_panel: PanelContainer = %TackleDetailPanel
+@onready var _bait_filter: Button = %BaitFilter
+@onready var _lures_filter: Button = %LuresFilter
+@onready var _tackle_empty: Label = %TackleEmpty
+@onready var _tackle_detail_text: Label = %TackleDetailText
+@onready var _tackle_equip_button: NotepadInkActionType = %TackleEquipButton
+@onready var _tackle_item_list: VBoxContainer = %TackleItemList
+@onready var _bag_outer_wall: PanelContainer = %BagOuterWall
+@onready var _bag_inner_liner: PanelContainer = %BagInnerLiner
+@onready var _bag_scroll: ScrollContainer = %BagScroll
+@onready var _bag_host: Control = %BagHost
+@onready var _bag_item_field: Control = %BagItemField
+@onready var _bag_empty_state: Label = %BagEmptyState
+@onready var _bag_detail_constellation: Control = %BagDetailConstellation
+@onready var _bag_sprite_detail_texture: TextureRect = %BagSpriteDetailTexture
+@onready var _bag_sprite_detail_name: Label = %BagSpriteDetailName
+@onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData
+@onready var _logbook_page: Control = %LogbookPage
+@onready var _catalog_logbook: LogbookPage = %CatalogLogbook
+@onready var _the_net_page: TheNetPage = %TheNetPage
+@onready var _mail_page: MailPage = %MailPage
+@onready var _profile_page: ProfilePage = %ProfilePage
+@onready var _players_page: PlayersPage = %PlayersPage
+@onready var _book_backing: PanelContainer = %BookBacking
+@onready var _book_spread: BoxContainer = %BookSpread
+@onready var _left_page: PanelContainer = %LeftPage
+@onready var _right_page: PanelContainer = %RightPage
+@onready var _left_heading: Label = %LeftHeading
+@onready var _right_heading: Label = %RightHeading
+@onready var _left_entry_field: BoxContainer = %LeftEntryField
+@onready var _right_entry_field: BoxContainer = %RightEntryField
+@onready var _logbook_empty_state: Label = %LogbookEmptyState
+@onready var _logbook_previous: BubbleButtonType = %LogbookPrevious
+@onready var _logbook_next: BubbleButtonType = %LogbookNext
+@onready var _logbook_page_status: BubbleStatusBubbleType = %LogbookPageStatus
+@onready var _inventory_tab: BubbleButtonType = %InventoryTab
+@onready var _logbook_tab: BubbleButtonType = %LogbookTab
+@onready var _the_net_tab: BubbleButtonType = %TheNetTab
+@onready var _mail_tab: BubbleButtonType = %MailTab
+@onready var _profile_tab: BubbleButtonType = %ProfileTab
+@onready var _players_tab: BubbleButtonType = %PlayersTab
+@onready var _mail_unread_badge: Label = %MailUnreadBadge
+@onready var _close_button: BubbleButtonType = %CloseButton
+@onready var _content_shell: BubbleContentShellType = %MenuPanel
+@onready var _content_stage: Control = %Content
+@onready var _cooler_scroll: ScrollContainer = %CoolerScroll
+@onready var _cooler_host: Control = %CoolerHost
+@onready var _wallet_balance: CurrencyAmount = %WalletBalance
+@onready var _header: Control = %Header
+@onready var _separator: Control = %Separator
+@onready var _wallet_status: BubbleStatusBubbleType = %WalletStatus
+@onready var _capacity_status: BubbleStatusBubbleType = %CapacityStatus
+@onready var _held_value_status: BubbleStatusBubbleType = %HeldValueStatus
+@onready var _selection_status: BubbleStatusBubbleType = %SelectionStatus
+@onready var _offer_status: BubbleStatusBubbleType = %OfferStatus
@onready var _inventory_section: Control = %InventorySection
@onready var _bag_section: Control = %BagSection
-@onready var _logbook_section: Control = %LogbookSection
@onready var _bag_empty: Label = %BagEmpty
@onready var _bag_grid: GridContainer = %BagGrid
+@onready var _bag_list: Control = %BagList
+@onready var _bag_detail: Control = %BagDetail
@onready var _bag_detail_texture: TextureRect = %BagDetailTexture
@onready var _bag_detail_name: Label = %BagDetailName
@onready var _bag_detail_data: Label = %BagDetailData
@onready var _sort_option: OptionButton = %SortOption
@onready var _sort_direction: Button = %SortDirection
-@onready var _held_value: Label = %HeldValue
+@onready var _held_value: CurrencyAmount = %HeldValue
@onready var _cooler_count: Label = %CoolerCount
@onready var _inventory_empty: Label = %InventoryEmpty
-@onready var _inventory_grid: GridContainer = %InventoryGrid
-@onready var _detail_texture: TextureRect = %DetailTexture
-@onready var _detail_name: Label = %DetailName
-@onready var _detail_data: Label = %DetailData
@onready var _selection_summary: Label = %SelectionSummary
@onready var _favorite_button: Button = %FavoriteButton
@onready var _sell_button: Button = %SellButton
@onready var _sale_unavailable: Label = %SaleUnavailable
-@onready var _transaction_feedback: Label = %TransactionFeedback
+@onready var _transaction_feedback: RichTextLabel = %TransactionFeedback
@onready var _sale_confirmation: PanelContainer = %SaleConfirmation
-@onready var _confirmation_message: Label = %ConfirmationMessage
+@onready var _confirmation_message: RichTextLabel = %ConfirmationMessage
@onready var _confirm_sale_button: Button = %ConfirmSaleButton
@onready var _cancel_sale_button: Button = %CancelSaleButton
@onready var _logbook_empty: Label = %LogbookEmpty
-@onready var _logbook_grid: GridContainer = %LogbookGrid
+var _compact_layout: bool = false
var _player: PlayerType
var _inventory: FishInventoryType
var _collection_log: CollectionLogType
var _wallet: PlayerWalletType
var _sale_service: FishSaleServiceType
+var _network_session: NetworkSessionType
+var _network_sale_service: NetworkSaleService
+var _network_mail_service: NetworkMailService
+var _reservations: PlayerAssetReservationService
+var _network_profile_service: NetworkProfileService
+var _network_player_list: NetworkPlayerListService
+var _controller_mapping_manager: ControllerMappingManagerType
var _default_buyer: FishBuyerProfileType
+var _sale_buyer_override: FishBuyerProfileType
+var _shop_cooler_context_active: bool = false
+var _cooler_original_parent: Node
+var _cooler_original_index: int = -1
+var _confirmation_original_parent: Node
+var _confirmation_original_index: int = -1
var _catalog: FishPoolType
var _fishing_spot: FishingSpotType
var _bag: PlayerBagType
@@ -96,6 +310,19 @@ var _hotbar: PlayerHotbarType
var _item_catalog: ItemCatalogType
var _cooler_capacity: PlayerCoolerCapacityType
var _current_section: Section = Section.COOLER
+var _last_inventory_section: Section = Section.COOLER
+var _bag_view: BagView = BagView.EQUIPMENT
+var _tackle_view: TackleView = TackleView.BAIT
+var _selected_tackle_item_id: StringName
+var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST
+var _controller_source_section: Section = Section.COOLER
+var _controller_source_identity: StringName
+var _controller_notepad_actions: Array[BaseButton] = []
+var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = (
+ PlayerHotbarType.AssignmentKind.EMPTY
+)
+var _controller_hotbar_identity: StringName
+var _controller_previous_hotbar_slot: int = 0
var _sort_mode: SortMode = SortMode.CATCH_ORDER
var _sort_descending: bool = true
var _fish_selection := FishBatchSelectionType.new()
@@ -111,35 +338,182 @@ var _confirmation_buyer: FishBuyerProfileType
var _confirmation_buyer_id: StringName
var _confirmation_generation: int = -1
var _sale_in_progress: bool = false
+var _presentation_tween: Tween
+var _page_tween: Tween
+var _transition_generation: int = 0
+var _page_transition_generation: int = 0
+var _transitioning: bool = false
+var _page_transitioning: bool = false
+var _presentation_rest_position: Vector2 = Vector2.ZERO
+var _content_rest_position: Vector2 = Vector2.ZERO
+var _cooler_rest_position: Vector2 = Vector2.ZERO
+var _bag_rest_position: Vector2 = Vector2.ZERO
+var _tackle_rest_position: Vector2 = Vector2.ZERO
+var _logbook_rest_position: Vector2 = Vector2.ZERO
+var _the_net_rest_position: Vector2 = Vector2.ZERO
+var _mail_rest_position: Vector2 = Vector2.ZERO
+var _profile_rest_position: Vector2 = Vector2.ZERO
+var _page_outgoing_root: Control
+var _page_incoming_root: Control
+var _page_outgoing_content_root: Control
+var _inventory_transition_group: Control
+var _fish_nodes: Dictionary[StringName, CoolerFishSpriteType] = {}
+var _sorted_catches: Array[FishCatchType] = []
+var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {}
+var _sorted_bag_items: Array[OwnedItemType] = []
+var _bag_drag_active: bool = false
+var _motion_elapsed: float = 0.0
+var _logbook_species: Array[FishDataType] = []
+var _logbook_current_page: int = 0
+var _logbook_page_count: int = 1
+var _logbook_page_transitioning: bool = false
+var _logbook_page_generation: int = 0
+var _logbook_page_tween: Tween
func _ready() -> void:
- _inventory_tab.pressed.connect(
- _show_section.bind(Section.COOLER)
+ _configure_inventory_transition_group()
+ _cooler_original_parent = _cooler_page.get_parent()
+ _cooler_original_index = _cooler_page.get_index()
+ _confirmation_original_parent = _sale_confirmation.get_parent()
+ _confirmation_original_index = _sale_confirmation.get_index()
+ _inventory_sub_tabs.move_child(_tackle_sub_tab, 1)
+ _inventory_sub_tabs.move_child(_bag_sub_tab, 2)
+ _inventory_tab.pressed.connect(_show_last_inventory_section)
+ _cooler_sub_tab.pressed.connect(_show_section.bind(Section.COOLER))
+ _bag_sub_tab.pressed.connect(_show_section.bind(Section.BAG))
+ _tackle_sub_tab.pressed.connect(_show_section.bind(Section.TACKLE_BOX))
+ _equipment_filter.pressed.connect(_set_bag_view.bind(BagView.EQUIPMENT))
+ _consumables_filter.pressed.connect(
+ _set_bag_view.bind(BagView.CONSUMABLES)
)
- _bag_tab.pressed.connect(_show_section.bind(Section.BAG))
+ _bait_filter.pressed.connect(_set_tackle_view.bind(TackleView.BAIT))
+ _lures_filter.pressed.connect(_set_tackle_view.bind(TackleView.LURES))
+ _tackle_equip_button.pressed.connect(_toggle_active_tackle)
_logbook_tab.pressed.connect(
_show_section.bind(Section.LOGBOOK)
)
+ _the_net_tab.pressed.connect(_show_section.bind(Section.NET))
+ _mail_tab.pressed.connect(_show_section.bind(Section.MAIL))
+ _profile_tab.pressed.connect(_show_section.bind(Section.PROFILE))
+ _players_tab.pressed.connect(_show_section.bind(Section.PLAYERS))
_close_button.pressed.connect(close_menu)
- _sort_option.item_selected.connect(_on_sort_selected)
+ _sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option))
_sort_direction.pressed.connect(_on_sort_direction_pressed)
+ _cooler_sort_option.item_selected.connect(_on_cooler_sort_selected)
+ _cooler_sort_direction.pressed.connect(_on_sort_direction_pressed)
_favorite_button.pressed.connect(_on_favorite_pressed)
_sell_button.pressed.connect(_on_sell_pressed)
+ _favorite_bubble.pressed.connect(_on_favorite_pressed)
+ _sell_bubble.pressed.connect(_on_sell_pressed)
+ _sell_all_bubble.pressed.connect(_on_sell_all_pressed)
_confirm_sale_button.pressed.connect(_on_confirm_sale_pressed)
_cancel_sale_button.pressed.connect(_close_sale_confirmation)
+ _configure_sale_confirmation_focus()
_sort_option.add_item("catch order", SortMode.CATCH_ORDER)
_sort_option.add_item("name", SortMode.NAME)
_sort_option.add_item("rarity", SortMode.RARITY)
_sort_option.select(SortMode.CATCH_ORDER)
+ _cooler_sort_option.add_item("catch order", SortMode.CATCH_ORDER)
+ _cooler_sort_option.add_item("name", SortMode.NAME)
+ _cooler_sort_option.add_item("rarity", SortMode.RARITY)
+ _cooler_sort_option.select(SortMode.CATCH_ORDER)
_update_sort_direction_text()
- _show_section(_current_section)
+ _navigation_cluster.configure([
+ _inventory_tab,
+ _logbook_tab,
+ _the_net_tab,
+ _mail_tab,
+ _profile_tab,
+ _players_tab,
+ _close_button,
+ ])
+ _configure_navigation_focus()
+ _apply_navigation_styles()
+ _apply_inventory_styles()
+ # Keep the exposed tab hitboxes above the full-page roots. The individual
+ # main panels retain a higher z-index and cover the tab bodies below y=166.
+ _inventory_sub_tabs.move_to_front()
+ _bag_filter_tabs.move_to_front()
+ _apply_cooler_control_styles()
+ _apply_cooler_wall_styles()
+ _apply_cooler_notepad_style()
+ _cooler_water_surface.resized.connect(_update_cooler_water_mask)
+ _fish_field.gui_input.connect(_on_fish_field_gui_input)
+ _apply_bag_styles()
+ _bag_item_field.gui_input.connect(_on_bag_field_gui_input)
+ _apply_mail_notification_style()
+ resized.connect(_update_shell_layout)
+ _show_section_immediate(_current_section)
+ call_deferred("_update_shell_layout")
+ call_deferred("_update_cooler_water_mask")
+ set_process(false)
+
+
+func _configure_inventory_transition_group() -> void:
+ # Keep the organizer row and the panel that masks its lower flange under one
+ # alpha owner. Top-level page crossfades can then never expose a complete tab.
+ var original_layer_index: int = mini(
+ _inventory_sub_tabs.get_index(),
+ mini(
+ _cooler_page.get_index(),
+ mini(_bag_page.get_index(), _tackle_box_page.get_index()),
+ ),
+ )
+ _inventory_transition_group = Control.new()
+ _inventory_transition_group.name = "InventoryTransitionGroup"
+ _inventory_transition_group.set_anchors_and_offsets_preset(
+ Control.PRESET_FULL_RECT
+ )
+ _inventory_transition_group.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _presentation_scale_root.add_child(_inventory_transition_group)
+ # Adding a runtime container normally places it after NavigationCluster in
+ # GUI picking order. Retain the pages' authored layer so their full-screen
+ # PASS roots cannot cover the top-level navigation hitboxes.
+ _presentation_scale_root.move_child(
+ _inventory_transition_group,
+ original_layer_index,
+ )
+ for control: Control in [
+ _inventory_sub_tabs,
+ _cooler_page,
+ _bag_page,
+ _tackle_box_page,
+ ]:
+ control.reparent(_inventory_transition_group, true)
+
+
+func _apply_mail_notification_style() -> void:
+ var background := StyleBoxFlat.new()
+ background.bg_color = Color(0.95, 0.88, 0.63, 0.98)
+ background.border_color = Color(0.20, 0.14, 0.08, 0.92)
+ background.set_border_width_all(2)
+ background.set_corner_radius_all(12)
+ background.content_margin_left = 5
+ background.content_margin_right = 5
+ background.content_margin_top = 2
+ background.content_margin_bottom = 2
+ _mail_unread_badge.add_theme_stylebox_override("normal", background)
+ _mail_unread_badge.add_theme_font_override(
+ "font", UtilityPageStyle.TuffyFont
+ )
+
+
+func _update_cooler_water_mask() -> void:
+ var shader_material := _cooler_water_surface.material as ShaderMaterial
+ if shader_material == null:
+ return
+ shader_material.set_shader_parameter(
+ "rendered_size",
+ _cooler_water_surface.size,
+ )
func setup(
player: PlayerType,
inventory: FishInventoryType,
collection_log: CollectionLogType,
+ experience: PlayerExperienceType,
wallet: PlayerWalletType,
sale_service: FishSaleServiceType,
default_buyer: FishBuyerProfileType,
@@ -149,6 +523,17 @@ func setup(
hotbar: PlayerHotbarType,
item_catalog: ItemCatalogType,
cooler_capacity: PlayerCoolerCapacityType,
+ network_session: NetworkSessionType,
+ network_sale_service: NetworkSaleService,
+ network_mail_service: NetworkMailService,
+ reservations: PlayerAssetReservationService,
+ network_profile_service: NetworkProfileService,
+ network_player_list: NetworkPlayerListService,
+ discovery: DiscoveryClient,
+ player_jobs: PlayerJobService,
+ world_time: WorldTimeService,
+ world_environment: WorldEnvironment,
+ world_sun: DirectionalLight3D,
) -> void:
_player = player
_inventory = inventory
@@ -162,17 +547,55 @@ func setup(
_hotbar = hotbar
_item_catalog = item_catalog
_cooler_capacity = cooler_capacity
+ _network_session = network_session
+ _network_sale_service = network_sale_service
+ _network_mail_service = network_mail_service
+ _reservations = reservations
+ _network_profile_service = network_profile_service
+ _network_player_list = network_player_list
+ _mail_page.setup(
+ network_mail_service, reservations, inventory, wallet, bag, item_catalog
+ )
+ _profile_page.setup(
+ network_profile_service,
+ experience,
+ world_environment,
+ world_sun,
+ )
+ _players_page.setup(network_player_list, discovery)
+ _catalog_logbook.setup(collection_log, inventory, catalog)
+ _the_net_page.setup(player_jobs, world_time)
+ _network_mail_service.unread_count_changed.connect(
+ _on_mail_unread_count_changed
+ )
+ _on_mail_unread_count_changed(_network_mail_service.get_unread_count())
+ if (
+ _network_sale_service != null
+ and not _network_sale_service.local_sale_pending.is_connected(
+ _on_network_sale_pending
+ )
+ ):
+ _network_sale_service.local_sale_pending.connect(
+ _on_network_sale_pending
+ )
+ _network_sale_service.local_sale_finished.connect(
+ _on_network_sale_finished
+ )
_fish_selection.clear()
if not _inventory.catches_changed.is_connected(_on_inventory_changed):
_inventory.catches_changed.connect(_on_inventory_changed)
- if not _collection_log.fish_discovered.is_connected(_on_fish_discovered):
- _collection_log.fish_discovered.connect(_on_fish_discovered)
if not _wallet.balance_changed.is_connected(_on_wallet_balance_changed):
_wallet.balance_changed.connect(_on_wallet_balance_changed)
if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
_fishing_spot.bite_activated.connect(_on_bite_activated)
if not _bag.contents_changed.is_connected(_on_bag_changed):
_bag.contents_changed.connect(_on_bag_changed)
+ if not _player.active_bait_changed.is_connected(_on_active_bait_changed):
+ _player.active_bait_changed.connect(_on_active_bait_changed)
+ if not _player.active_lure_changed.is_connected(_on_active_lure_changed):
+ _player.active_lure_changed.connect(_on_active_lure_changed)
+ if not _hotbar.slots_changed.is_connected(_on_hotbar_changed):
+ _hotbar.slots_changed.connect(_on_hotbar_changed)
if not _cooler_capacity.capacity_changed.is_connected(
_on_cooler_capacity_changed
):
@@ -180,12 +603,43 @@ func setup(
_refresh_all()
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+ _profile_page.setup_controller_mapping(_controller_mapping_manager)
+
+
+func set_profile_preview_world_pixel_size(pixel_size: int) -> void:
+ _profile_page.set_world_pixel_size(pixel_size)
+
+
+var _left_page_trigger_held: bool = false
+var _right_page_trigger_held: bool = false
+
+
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.echo:
return
+ if _handle_controller_ownership_input(event):
+ get_viewport().set_input_as_handled()
+ return
+ if visible:
+ _reserve_main_navigation_for_page_switching()
+ _reserve_visible_secondary_navigation()
+ if _handle_controller_page_switch(event):
+ get_viewport().set_input_as_handled()
+ return
+ if _handle_controller_secondary_switch(event):
+ get_viewport().set_input_as_handled()
+ return
+ if _handle_direct_page_shortcut(event):
+ get_viewport().set_input_as_handled()
+ return
if visible:
if event.is_action_pressed("open_backpack"):
- close_menu()
+ if not _transitioning and not _page_transitioning:
+ close_menu()
get_viewport().set_input_as_handled()
return
if (
@@ -197,11 +651,641 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
+func _handle_controller_ownership_input(event: InputEvent) -> bool:
+ if not visible:
+ return false
+ var button_event := event as InputEventJoypadButton
+ var accept_pressed: bool = (
+ button_event != null
+ and button_event.pressed
+ and _event_matches_controller_role(
+ event,
+ ControllerMappingManagerType.ROLE_A,
+ JOY_BUTTON_A,
+ )
+ )
+ var cancel_pressed: bool = (
+ button_event != null
+ and button_event.pressed
+ and _event_matches_controller_role(
+ event,
+ ControllerMappingManagerType.ROLE_B,
+ JOY_BUTTON_B,
+ )
+ )
+ var alternate_pressed: bool = (
+ button_event != null
+ and button_event.pressed
+ and _event_matches_controller_role(
+ event,
+ ControllerMappingManagerType.ROLE_Y,
+ JOY_BUTTON_Y,
+ )
+ )
+ if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
+ if cancel_pressed:
+ _release_controller_ownership(true, false)
+ return true
+ if alternate_pressed:
+ if _hotbar != null:
+ _hotbar.clear_slot(_hotbar.get_selected_slot())
+ return true
+ if accept_pressed:
+ return true
+ return false
+ if _controller_ownership == ControllerOwnership.HOTBAR_PLACEMENT:
+ if accept_pressed:
+ _confirm_controller_hotbar_placement()
+ return true
+ if cancel_pressed:
+ _release_controller_ownership(true, true)
+ return true
+ if alternate_pressed:
+ return true
+ return false
+ if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS:
+ if cancel_pressed:
+ _release_controller_ownership(true, false)
+ return true
+ if alternate_pressed:
+ return true
+ var direction: int = 0
+ if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"):
+ direction = -1
+ elif (
+ event.is_action_pressed("ui_right")
+ or event.is_action_pressed("ui_down")
+ ):
+ direction = 1
+ if direction != 0:
+ _focus_next_notepad_action(direction)
+ return true
+ return false
+ if alternate_pressed:
+ return _try_begin_controller_hotbar_placement()
+ if accept_pressed:
+ return _try_enter_notepad_controller_ownership()
+ return false
+
+
+func _event_matches_controller_role(
+ event: InputEvent,
+ role: StringName,
+ fallback_button: JoyButton,
+) -> bool:
+ if _controller_mapping_manager != null:
+ return _controller_mapping_manager.event_matches_role(event, role)
+ var button_event := event as InputEventJoypadButton
+ return button_event != null and button_event.button_index == fallback_button
+
+
+func _try_enter_notepad_controller_ownership() -> bool:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if focus_owner == null:
+ return false
+ var source_identity: StringName
+ var actions: Array[BaseButton] = []
+ if _current_section == Section.COOLER:
+ var fish_node := focus_owner as CoolerFishSpriteType
+ if fish_node == null or fish_node.catch_id.is_empty():
+ return false
+ source_identity = fish_node.catch_id
+ _on_catch_card_pressed(source_identity)
+ if not _favorite_bubble.disabled:
+ actions.append(_favorite_bubble)
+ if not _sell_bubble.disabled:
+ actions.append(_sell_bubble)
+ if not _sell_all_bubble.disabled:
+ actions.append(_sell_all_bubble)
+ elif _current_section == Section.TACKLE_BOX:
+ if not focus_owner.has_meta(&"controller_tackle_item_id"):
+ return false
+ source_identity = StringName(
+ str(focus_owner.get_meta(&"controller_tackle_item_id"))
+ )
+ _select_tackle_item(source_identity)
+ if _tackle_equip_button.visible and not _tackle_equip_button.disabled:
+ actions.append(_tackle_equip_button)
+ else:
+ return false
+ if actions.is_empty():
+ _restore_controller_item_focus(_current_section, source_identity)
+ return true
+ _controller_source_section = _current_section
+ _controller_source_identity = source_identity
+ _controller_notepad_actions = actions
+ _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS
+ actions.front().call_deferred("grab_focus")
+ return true
+
+
+func _focus_next_notepad_action(direction: int) -> void:
+ var available: Array[BaseButton] = []
+ for action: BaseButton in _controller_notepad_actions:
+ if (
+ is_instance_valid(action)
+ and action.visible
+ and not action.disabled
+ and action.focus_mode != Control.FOCUS_NONE
+ ):
+ available.append(action)
+ if available.is_empty():
+ return
+ var focused: Control = get_viewport().gui_get_focus_owner()
+ var current_index: int = available.find(focused)
+ if current_index < 0:
+ current_index = 0 if direction > 0 else available.size() - 1
+ else:
+ current_index = wrapi(
+ current_index + direction,
+ 0,
+ available.size(),
+ )
+ available[current_index].grab_focus()
+
+
+func _try_begin_controller_hotbar_placement() -> bool:
+ if _hotbar == null:
+ return false
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if focus_owner == null:
+ return false
+ var assignment_kind: PlayerHotbarType.AssignmentKind = (
+ PlayerHotbarType.AssignmentKind.EMPTY
+ )
+ var identity: StringName
+ if _current_section == Section.COOLER:
+ var fish_node := focus_owner as CoolerFishSpriteType
+ if fish_node == null or fish_node.catch_id.is_empty():
+ return false
+ assignment_kind = PlayerHotbarType.AssignmentKind.FISH
+ identity = fish_node.catch_id
+ elif _current_section == Section.BAG:
+ var item_node := focus_owner as BagItemSpriteType
+ if item_node == null or item_node.item_id.is_empty():
+ return false
+ var item: ItemDataType = (
+ _item_catalog.get_item_by_id(item_node.item_id)
+ if _item_catalog != null
+ else null
+ )
+ if item == null or not item.hotbar_allowed:
+ return true
+ assignment_kind = PlayerHotbarType.AssignmentKind.ITEM
+ identity = item_node.item_id
+ else:
+ return false
+ _controller_source_section = _current_section
+ _controller_source_identity = identity
+ _controller_hotbar_assignment_kind = assignment_kind
+ _controller_hotbar_identity = identity
+ _controller_previous_hotbar_slot = _hotbar.get_selected_slot()
+ var initial_slot: int = _find_controller_hotbar_assignment(
+ assignment_kind,
+ identity,
+ )
+ if initial_slot < 0:
+ initial_slot = _controller_previous_hotbar_slot
+ _controller_ownership = ControllerOwnership.HOTBAR_PLACEMENT
+ controller_hotbar_placement_requested.emit(
+ assignment_kind,
+ identity,
+ initial_slot,
+ )
+ return true
+
+
+func _find_controller_hotbar_assignment(
+ assignment_kind: PlayerHotbarType.AssignmentKind,
+ identity: StringName,
+) -> int:
+ for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
+ if (
+ assignment_kind == PlayerHotbarType.AssignmentKind.FISH
+ and _hotbar.get_fish_catch_id(slot_index) == identity
+ ):
+ return slot_index
+ if (
+ assignment_kind == PlayerHotbarType.AssignmentKind.ITEM
+ and _hotbar.get_item_id(slot_index) == identity
+ ):
+ return slot_index
+ return -1
+
+
+func _confirm_controller_hotbar_placement() -> void:
+ if (
+ _controller_ownership != ControllerOwnership.HOTBAR_PLACEMENT
+ or _hotbar == null
+ ):
+ return
+ var slot_index: int = _hotbar.get_selected_slot()
+ var assigned: bool = false
+ if (
+ _controller_hotbar_assignment_kind
+ == PlayerHotbarType.AssignmentKind.FISH
+ ):
+ assigned = _hotbar.assign_fish(
+ slot_index,
+ _controller_hotbar_identity,
+ )
+ elif (
+ _controller_hotbar_assignment_kind
+ == PlayerHotbarType.AssignmentKind.ITEM
+ ):
+ assigned = _hotbar.assign_item(
+ slot_index,
+ _controller_hotbar_identity,
+ )
+ if assigned:
+ _release_controller_ownership(true, false)
+
+
+func _release_controller_ownership(
+ restore_source_focus: bool,
+ restore_previous_hotbar_slot: bool,
+) -> void:
+ if _controller_ownership == ControllerOwnership.ITEM_LIST:
+ return
+ var prior_ownership: ControllerOwnership = _controller_ownership
+ var source_section: Section = _controller_source_section
+ var source_identity: StringName = _controller_source_identity
+ _controller_ownership = ControllerOwnership.ITEM_LIST
+ _controller_notepad_actions.clear()
+ _controller_source_identity = StringName()
+ _controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY
+ _controller_hotbar_identity = StringName()
+ if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT:
+ if restore_previous_hotbar_slot and _hotbar != null:
+ _hotbar.select_slot(_controller_previous_hotbar_slot)
+ controller_hotbar_placement_ended.emit()
+ if prior_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
+ controller_hotbar_management_ended.emit()
+ inventory_hotbar_context_changed.emit(
+ _current_section in [Section.COOLER, Section.BAG]
+ )
+ if restore_source_focus:
+ call_deferred(
+ "_restore_controller_item_focus",
+ source_section,
+ source_identity,
+ )
+
+
+func _restore_controller_item_focus(
+ section: Section,
+ identity: StringName,
+) -> void:
+ if not visible or section != _current_section:
+ return
+ if identity.is_empty():
+ _focus_current_section()
+ return
+ var target: Control
+ if section == Section.COOLER:
+ target = _fish_nodes.get(identity) as CoolerFishSpriteType
+ elif section == Section.BAG:
+ target = _bag_item_nodes.get(identity) as BagItemSpriteType
+ elif section == Section.TACKLE_BOX:
+ for child: Node in _tackle_item_list.get_children():
+ var button := child as BaseButton
+ if (
+ button != null
+ and button.has_meta(&"controller_tackle_item_id")
+ and StringName(str(button.get_meta(
+ &"controller_tackle_item_id"
+ ))) == identity
+ ):
+ target = button
+ break
+ if (
+ target != null
+ and is_instance_valid(target)
+ and target.is_visible_in_tree()
+ and target.focus_mode != Control.FOCUS_NONE
+ ):
+ target.grab_focus()
+ else:
+ _focus_current_section()
+
+
+func _handle_controller_page_switch(event: InputEvent) -> bool:
+ var button_event: InputEventJoypadButton = event as InputEventJoypadButton
+ var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion
+ var use_mapping: bool = (
+ _controller_mapping_manager != null
+ )
+ var uses_left_trigger: bool = (
+ _controller_mapping_manager.event_uses_role(
+ event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER
+ )
+ if use_mapping
+ else (
+ motion_event != null
+ and motion_event.axis == JOY_AXIS_TRIGGER_LEFT
+ )
+ )
+ var uses_right_trigger: bool = (
+ _controller_mapping_manager.event_uses_role(
+ event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM
+ )
+ if use_mapping
+ else (
+ motion_event != null
+ and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT
+ )
+ )
+ if (
+ not (uses_left_trigger or uses_right_trigger)
+ or not visible
+ ):
+ return false
+ var is_pressed: bool = (
+ button_event.pressed
+ if button_event != null
+ else motion_event.axis_value > 0.5
+ )
+ if not is_pressed:
+ if uses_left_trigger:
+ _left_page_trigger_held = false
+ if uses_right_trigger:
+ _right_page_trigger_held = false
+ return true
+ if (
+ (uses_left_trigger and _left_page_trigger_held)
+ or (uses_right_trigger and _right_page_trigger_held)
+ ):
+ return true
+ if uses_left_trigger:
+ _left_page_trigger_held = true
+ if uses_right_trigger:
+ _right_page_trigger_held = true
+ # Trigger input belongs to the Player Menu while it is visible, even when a
+ # transition or modal temporarily prevents changing pages.
+ if (
+ _transitioning
+ or _page_transitioning
+ or _sale_confirmation.visible
+ or get_viewport().gui_is_dragging()
+ or _controller_ownership != ControllerOwnership.ITEM_LIST
+ ):
+ return true
+ var sections: Array[Section] = [
+ _last_inventory_section,
+ Section.LOGBOOK,
+ Section.NET,
+ Section.MAIL,
+ Section.PROFILE,
+ Section.PLAYERS,
+ ]
+ var current_index: int = 0
+ if not _is_inventory_section(_current_section):
+ current_index = sections.find(_current_section)
+ if current_index < 0:
+ current_index = 0
+ var direction: int = (
+ -1 if uses_left_trigger else 1
+ )
+ var next_index: int = wrapi(current_index + direction, 0, sections.size())
+ _show_section(sections[next_index])
+ return true
+
+
+func _handle_controller_secondary_switch(event: InputEvent) -> bool:
+ var button_event := event as InputEventJoypadButton
+ if button_event == null or not visible:
+ return false
+ var use_mapping: bool = _controller_mapping_manager != null
+ var uses_left_bumper: bool = (
+ _controller_mapping_manager.event_uses_role(
+ event, ControllerMappingManagerType.ROLE_LB
+ )
+ if use_mapping
+ else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER
+ )
+ var uses_right_bumper: bool = (
+ _controller_mapping_manager.event_uses_role(
+ event, ControllerMappingManagerType.ROLE_RB
+ )
+ if use_mapping
+ else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER
+ )
+ if not (uses_left_bumper or uses_right_bumper):
+ return false
+ if not button_event.pressed:
+ return true
+ var direction: int = -1 if uses_left_bumper else 1
+ if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT:
+ _cycle_from_controller_hotbar_management(direction)
+ return true
+ if (
+ _transitioning
+ or _page_transitioning
+ or _sale_confirmation.visible
+ or get_viewport().gui_is_dragging()
+ or _controller_ownership != ControllerOwnership.ITEM_LIST
+ ):
+ return true
+ if _is_inventory_section(_current_section):
+ if (
+ (direction > 0 and _current_section == Section.BAG)
+ or (direction < 0 and _current_section == Section.COOLER)
+ ):
+ _begin_controller_hotbar_management()
+ return true
+ var inventory_sections: Array[Section] = [
+ Section.COOLER,
+ Section.TACKLE_BOX,
+ Section.BAG,
+ ]
+ var inventory_index: int = inventory_sections.find(_current_section)
+ _show_section(inventory_sections[wrapi(
+ inventory_index + direction,
+ 0,
+ inventory_sections.size(),
+ )])
+ return true
+ _cycle_visible_secondary_tabs(direction)
+ return true
+
+
+func _begin_controller_hotbar_management() -> void:
+ if _hotbar == null:
+ return
+ _controller_source_section = _current_section
+ _controller_source_identity = StringName()
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if _current_section == Section.COOLER:
+ var fish_node := focus_owner as CoolerFishSpriteType
+ if fish_node != null:
+ _controller_source_identity = fish_node.catch_id
+ elif _current_section == Section.BAG:
+ var item_node := focus_owner as BagItemSpriteType
+ if item_node != null:
+ _controller_source_identity = item_node.item_id
+ _controller_ownership = ControllerOwnership.HOTBAR_MANAGEMENT
+ controller_hotbar_management_requested.emit(_hotbar.get_selected_slot())
+
+
+func _cycle_from_controller_hotbar_management(direction: int) -> void:
+ _release_controller_ownership(false, false)
+ var target_section: Section = (
+ Section.COOLER if direction > 0 else Section.BAG
+ )
+ if target_section == _current_section:
+ call_deferred("_focus_current_section")
+ else:
+ _show_section(target_section)
+
+
+func _cycle_visible_secondary_tabs(direction: int) -> bool:
+ var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
+ var grouped_buttons: Dictionary = {}
+ _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons)
+ var selected_group: Variant = null
+ var selected_group_y: float = INF
+ for group_value: Variant in grouped_buttons.keys():
+ var buttons := grouped_buttons[group_value] as Array
+ if buttons.size() < 2:
+ continue
+ var group_y: float = INF
+ for item: Variant in buttons:
+ var button := item as BaseButton
+ group_y = minf(group_y, button.global_position.y)
+ if group_y < selected_group_y:
+ selected_group = group_value
+ selected_group_y = group_y
+ if selected_group == null:
+ return false
+ var tabs := grouped_buttons[selected_group] as Array
+ tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool:
+ return first.global_position.x < second.global_position.x
+ )
+ var current_index: int = 0
+ for index: int in tabs.size():
+ var tab := tabs[index] as BaseButton
+ if tab.button_pressed:
+ current_index = index
+ break
+ var target := tabs[wrapi(
+ current_index + direction, 0, tabs.size()
+ )] as BaseButton
+ target.set_pressed_no_signal(true)
+ target.pressed.emit()
+ return true
+
+
+func _collect_visible_toggle_buttons(
+ root: Node,
+ navigation_cluster: Control,
+ grouped_buttons: Dictionary,
+) -> void:
+ for child: Node in root.get_children():
+ var child_control := child as Control
+ if child_control != null and not child_control.is_visible_in_tree():
+ continue
+ var button := child as BaseButton
+ if (
+ button != null
+ and button.toggle_mode
+ and (
+ navigation_cluster == null
+ or not navigation_cluster.is_ancestor_of(button)
+ )
+ ):
+ var group_key: Variant = button.get_parent()
+ if button.button_group != null:
+ group_key = button.button_group
+ if not grouped_buttons.has(group_key):
+ grouped_buttons[group_key] = []
+ var buttons := grouped_buttons[group_key] as Array
+ buttons.append(button)
+ _collect_visible_toggle_buttons(
+ child, navigation_cluster, grouped_buttons
+ )
+
+
+func _handle_direct_page_shortcut(event: InputEvent) -> bool:
+ var key_event := event as InputEventKey
+ if (
+ key_event == null
+ or not key_event.pressed
+ or _is_text_input_active()
+ or _shortcut_blocked_by_modal_state()
+ ):
+ return false
+ var physical_key: Key = key_event.physical_keycode
+ var inventory_requested: bool = physical_key == KEY_I
+ var logbook_requested: bool = physical_key == KEY_L
+ var tackle_requested: bool = (
+ physical_key == KEY_V
+ or event.is_action_pressed("open_tacklebox")
+ )
+ if not inventory_requested and not tackle_requested and not logbook_requested:
+ return false
+ var requested_section: Section = (
+ Section.LOGBOOK
+ if logbook_requested
+ else (
+ Section.TACKLE_BOX
+ if tackle_requested
+ else _last_inventory_section
+ )
+ )
+ if visible:
+ if (
+ _current_section == requested_section
+ and not _transitioning
+ and not _page_transitioning
+ ):
+ close_menu()
+ elif not _transitioning and not _page_transitioning:
+ _show_section(requested_section)
+ else:
+ if (
+ _fishing_spot == null
+ or not _fishing_spot.can_open_player_menu()
+ ):
+ return false
+ _current_section = requested_section
+ open_menu()
+ return true
+
+
+func _shortcut_blocked_by_modal_state() -> bool:
+ return (
+ _sale_confirmation.visible
+ or (
+ _current_section == Section.MAIL
+ and _mail_page.is_composing_letter()
+ )
+ or (
+ _current_section == Section.PROFILE
+ and _profile_page.has_modal_confirmation()
+ )
+ )
+
+
+func _is_text_input_active() -> bool:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ return (
+ focus_owner != null
+ and focus_owner.is_visible_in_tree()
+ and (focus_owner is LineEdit or focus_owner is TextEdit)
+ )
+
+
func consume_escape() -> bool:
if not visible:
return false
+ if _transitioning or _page_transitioning:
+ return true
if _sale_confirmation.visible:
_close_sale_confirmation()
+ elif _current_section == Section.MAIL and _mail_page.consume_escape():
+ pass
+ elif _current_section == Section.PROFILE and _profile_page.consume_escape():
+ pass
else:
close_menu()
return true
@@ -210,12 +1294,17 @@ func consume_escape() -> bool:
func open_menu() -> void:
if (
visible
+ or _shop_cooler_context_active
or _player == null
or _fishing_spot == null
or not _fishing_spot.can_open_player_menu()
):
return
+ _release_controller_ownership(false, true)
_menu_generation += 1
+ _transition_generation += 1
+ _cancel_presentation_tween()
+ _cancel_page_tween()
_prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_input_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode
@@ -227,9 +1316,125 @@ func open_menu() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
visible = true
_refresh_all()
- _show_section(_current_section)
- _focus_current_section()
+ _show_section_immediate(_current_section)
+ _update_shell_layout()
+ _begin_menu_entry()
menu_visibility_changed.emit(true)
+ inventory_hotbar_context_changed.emit(
+ _current_section in [Section.COOLER, Section.BAG]
+ )
+
+
+func open_section(section: Section) -> bool:
+ if (
+ _shop_cooler_context_active
+ or _player == null
+ or _fishing_spot == null
+ or not _fishing_spot.can_open_player_menu()
+ ):
+ return false
+ if visible:
+ if _transitioning or _page_transitioning or _sale_confirmation.visible:
+ return false
+ if section != _current_section:
+ _show_section(section)
+ return true
+ _current_section = section
+ open_menu()
+ return visible
+
+
+func mount_shop_cooler(
+ host: Control,
+ buyer: FishBuyerProfileType,
+) -> bool:
+ if (
+ visible
+ or _shop_cooler_context_active
+ or host == null
+ or not is_instance_valid(host)
+ or buyer == null
+ or not buyer.is_valid()
+ or buyer.id != MAIN_SHOP_BUYER_ID
+ ):
+ return false
+ _menu_generation += 1
+ _cancel_page_tween()
+ _transitioning = false
+ _page_transitioning = false
+ _sale_buyer_override = buyer
+ _shop_cooler_context_active = true
+ _current_section = Section.COOLER
+ _last_inventory_section = Section.COOLER
+ _cooler_page.reparent(host, false)
+ _sale_confirmation.reparent(host, false)
+ _cooler_page.position = Vector2.ZERO
+ _cooler_page.size = DESKTOP_REFERENCE_SIZE
+ _cooler_page.scale = Vector2.ONE
+ _cooler_page.modulate = Color.WHITE
+ _cooler_page.visible = true
+ _sale_confirmation.visible = false
+ _update_shell_layout()
+ _refresh_inventory()
+ _set_content_interactive(true)
+ _update_cooler_water_mask()
+ set_process(true)
+ _cooler_sort_option.call_deferred("grab_focus")
+ return true
+
+
+func unmount_shop_cooler() -> void:
+ if not _shop_cooler_context_active:
+ return
+ _close_sale_confirmation()
+ _set_content_interactive(false)
+ _cooler_page.visible = false
+ _cooler_page.reparent(_cooler_original_parent, false)
+ _cooler_original_parent.move_child(
+ _cooler_page,
+ mini(_cooler_original_index, _cooler_original_parent.get_child_count() - 1),
+ )
+ _sale_confirmation.reparent(_confirmation_original_parent, false)
+ _confirmation_original_parent.move_child(
+ _sale_confirmation,
+ mini(
+ _confirmation_original_index,
+ _confirmation_original_parent.get_child_count() - 1,
+ ),
+ )
+ _sale_buyer_override = null
+ _shop_cooler_context_active = false
+ _menu_generation += 1
+ if not visible:
+ set_process(false)
+
+
+func is_shop_cooler_mounted() -> bool:
+ return _shop_cooler_context_active
+
+
+func cancel_shop_cooler_confirmation() -> void:
+ if _shop_cooler_context_active and _sale_confirmation.visible:
+ _close_sale_confirmation()
+
+
+func _focus_shop_cooler() -> void:
+ if not _shop_cooler_context_active or not _cooler_page.is_visible_in_tree():
+ return
+ var focused_node := _fish_nodes.get(
+ _fish_selection.get_focused_id()
+ ) as CoolerFishSpriteType
+ if focused_node != null and focused_node.focus_mode != Control.FOCUS_NONE:
+ focused_node.grab_focus()
+ return
+ for fish_catch: FishCatchType in _sorted_catches:
+ var fish_node := _fish_nodes.get(
+ fish_catch.catch_id
+ ) as CoolerFishSpriteType
+ if fish_node != null and fish_node.focus_mode != Control.FOCUS_NONE:
+ fish_node.grab_focus()
+ return
+ _cooler_sort_option.grab_focus()
func close_menu(
@@ -238,6 +1443,17 @@ func close_menu(
) -> void:
if not visible:
return
+ if (
+ reason == CloseReason.USER
+ and _current_section == Section.PROFILE
+ and _profile_page.request_close_confirmation()
+ ):
+ return
+ if _transitioning:
+ if reason != CloseReason.USER:
+ _finish_close(reason, restore_controls, _menu_generation)
+ return
+ _release_controller_ownership(false, true)
get_viewport().gui_cancel_drag()
_close_sale_confirmation()
if reason in [
@@ -247,18 +1463,8 @@ func close_menu(
CloseReason.TEARDOWN,
]:
_fish_selection.clear()
- var closing_generation: int = _menu_generation
- visible = false
- get_viewport().gui_release_focus()
- if _fishing_spot != null and is_instance_valid(_fishing_spot):
- _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
- if restore_controls:
- _restore_player_controls(closing_generation)
- else:
- _control_snapshot_stored = false
- _apply_mouse_close_policy(reason)
- _menu_generation += 1
- menu_visibility_changed.emit(false)
+ menu_exit_started.emit()
+ _begin_menu_exit(reason, restore_controls)
func close_for_water_recovery() -> void:
@@ -276,8 +1482,11 @@ func close_for_session_end() -> void:
func _exit_tree() -> void:
+ _release_controller_ownership(false, true)
+ _cancel_presentation_tween()
+ _cancel_page_tween()
if visible:
- close_menu(CloseReason.TEARDOWN, false)
+ _finish_close(CloseReason.TEARDOWN, false, _menu_generation)
_fish_selection.clear()
_menu_generation += 1
@@ -319,30 +1528,1544 @@ func _on_bite_activated() -> void:
func _show_section(section: Section) -> void:
+ if (
+ section == _current_section
+ or _transitioning
+ or _page_transitioning
+ or _sale_confirmation.visible
+ or get_viewport().gui_is_dragging()
+ ):
+ return
+ if (
+ _current_section == Section.PROFILE
+ and _profile_page.request_close_confirmation()
+ ):
+ return
+ _release_controller_ownership(false, true)
+ _begin_page_transition(section)
+
+
+func _show_section_immediate(section: Section) -> void:
_current_section = section
+ if _is_inventory_section(section):
+ _last_inventory_section = section
if not is_node_ready():
return
- _inventory_section.visible = section == Section.COOLER
- _bag_section.visible = section == Section.BAG
- _logbook_section.visible = section == Section.LOGBOOK
- _inventory_tab.button_pressed = section == Section.COOLER
- _bag_tab.button_pressed = section == Section.BAG
+ _configure_navigation_focus()
+ _cooler_page.visible = section == Section.COOLER
+ _bag_page.visible = section == Section.BAG
+ _tackle_box_page.visible = section == Section.TACKLE_BOX
+ _inventory_sub_tabs.visible = _is_inventory_section(section)
+ _logbook_page.visible = section == Section.LOGBOOK
+ _the_net_page.visible = section == Section.NET
+ _mail_page.visible = section == Section.MAIL
+ _profile_page.visible = section == Section.PROFILE
+ _players_page.visible = section == Section.PLAYERS
+ _content_shell.visible = false
+ _inventory_section.visible = false
+ _bag_section.visible = false
+ _content_shell.set_background_visible(true)
+ _header.visible = section != Section.COOLER
+ _separator.visible = section != Section.COOLER
+ if section == Section.COOLER:
+ _refresh_inventory()
+ else:
+ _fish_selection.clear()
+ _close_detail_constellation()
+ if section != Section.BAG:
+ _close_bag_detail()
+ else:
+ _update_bag_detail()
+ _refresh_tackle_box()
+ if section != Section.LOGBOOK:
+ _cancel_logbook_page_transition(true)
+ _catalog_logbook.deactivate()
+ else:
+ _catalog_logbook.activate()
+ if section == Section.NET:
+ _the_net_page.activate()
+ else:
+ _the_net_page.deactivate()
+ if section == Section.MAIL:
+ _mail_page.activate()
+ else:
+ _mail_page.deactivate()
+ if section == Section.PROFILE:
+ _profile_page.activate()
+ else:
+ _profile_page.deactivate()
+ if section == Section.PLAYERS:
+ _players_page.activate()
+ else:
+ _players_page.deactivate()
+ _inventory_tab.button_pressed = _is_inventory_section(section)
+ _cooler_sub_tab.set_selected(section == Section.COOLER)
+ _bag_sub_tab.set_selected(section == Section.BAG)
+ _tackle_sub_tab.set_selected(section == Section.TACKLE_BOX)
+ _refresh_inventory_organizer_tabs()
_logbook_tab.button_pressed = section == Section.LOGBOOK
- if visible:
- _focus_current_section()
+ _the_net_tab.button_pressed = section == Section.NET
+ _mail_tab.button_pressed = section == Section.MAIL
+ _profile_tab.button_pressed = section == Section.PROFILE
+ _players_tab.button_pressed = section == Section.PLAYERS
+ _update_navigation_selection()
+ _configure_active_page_focus()
+ inventory_hotbar_context_changed.emit(
+ section in [Section.COOLER, Section.BAG]
+ )
+
+
+func _is_inventory_section(section: Section) -> bool:
+ return section in [Section.COOLER, Section.BAG, Section.TACKLE_BOX]
+
+
+func _refresh_inventory_organizer_tabs() -> void:
+ for tab: OrganizerTabType in [
+ _cooler_sub_tab,
+ _tackle_sub_tab,
+ _bag_sub_tab,
+ ]:
+ tab.refresh_state()
+
+
+func _animate_inventory_tab_entry() -> void:
+ if not _is_inventory_section(_current_section):
+ return
+ var tabs: Array[OrganizerTabType] = [
+ _cooler_sub_tab,
+ _tackle_sub_tab,
+ _bag_sub_tab,
+ ]
+ for index: int in tabs.size():
+ tabs[index].animate_entrance(float(index) * 0.025)
+
+
+func _settle_inventory_tabs_for_close() -> void:
+ for tab: OrganizerTabType in [
+ _cooler_sub_tab,
+ _tackle_sub_tab,
+ _bag_sub_tab,
+ ]:
+ tab.settle_for_close()
+
+
+func _show_last_inventory_section() -> void:
+ _show_section(_last_inventory_section)
func _focus_current_section() -> void:
- if _current_section == Section.COOLER:
- _inventory_tab.grab_focus()
- elif _current_section == Section.BAG:
- _bag_tab.grab_focus()
+ if _shop_cooler_context_active:
+ _focus_shop_cooler()
+ return
+ _reserve_main_navigation_for_page_switching()
+ _reserve_visible_secondary_navigation()
+ var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
+ var candidates: Array[Control] = []
+ _collect_focusable_content(self, navigation_cluster, candidates)
+ if candidates.is_empty():
+ return
+ candidates.sort_custom(func(first: Control, second: Control) -> bool:
+ if not is_equal_approx(first.global_position.y, second.global_position.y):
+ return first.global_position.y < second.global_position.y
+ return first.global_position.x < second.global_position.x
+ )
+ candidates[0].grab_focus()
+
+
+func _collect_focusable_content(
+ root: Node,
+ navigation_cluster: Control,
+ output: Array[Control],
+) -> void:
+ for child: Node in root.get_children():
+ if child == navigation_cluster:
+ continue
+ var control := child as Control
+ if control != null and not control.is_visible_in_tree():
+ continue
+ if (
+ control != null
+ and control.focus_mode != Control.FOCUS_NONE
+ and not control is ScrollBar
+ ):
+ output.append(control)
+ _collect_focusable_content(child, navigation_cluster, output)
+
+
+func _process(delta: float) -> void:
+ if visible or _shop_cooler_context_active:
+ _motion_elapsed += delta
+ if visible:
+ _navigation_cluster.advance_motion(delta)
+ _apply_navigation_selection_presentation()
+ for fish_node: CoolerFishSpriteType in _fish_nodes.values():
+ fish_node.advance_presentation(delta, _motion_elapsed)
+ if not visible:
+ return
+ var bag_motion_enabled: bool = (
+ not _bag_drag_active and not get_viewport().gui_is_dragging()
+ )
+ for item_node: BagItemSpriteType in _bag_item_nodes.values():
+ item_node.advance_presentation(
+ _motion_elapsed,
+ bag_motion_enabled,
+ )
+
+
+func _configure_navigation_focus() -> void:
+ _reserve_main_navigation_for_page_switching()
+ var navigation: Array[BubbleButtonType] = [
+ _inventory_tab,
+ _logbook_tab,
+ _the_net_tab,
+ _mail_tab,
+ _profile_tab,
+ _players_tab,
+ _close_button,
+ ]
+ for index: int in navigation.size():
+ var bubble: BubbleButtonType = navigation[index]
+ var previous: BubbleButtonType = navigation[
+ (index - 1 + navigation.size()) % navigation.size()
+ ]
+ var next: BubbleButtonType = navigation[
+ (index + 1) % navigation.size()
+ ]
+ bubble.focus_neighbor_left = bubble.get_path_to(previous)
+ bubble.focus_neighbor_right = bubble.get_path_to(next)
+ bubble.focus_neighbor_top = bubble.focus_neighbor_left
+ bubble.focus_neighbor_bottom = bubble.focus_neighbor_right
+
+
+func _reserve_main_navigation_for_page_switching() -> void:
+ var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
+ if navigation_cluster == null:
+ return
+ _set_descendant_focus_disabled(navigation_cluster)
+
+
+func _set_descendant_focus_disabled(root: Node) -> void:
+ for child: Node in root.get_children():
+ var control := child as Control
+ if control != null:
+ control.focus_mode = Control.FOCUS_NONE
+ _set_descendant_focus_disabled(child)
+
+
+func _reserve_visible_secondary_navigation() -> void:
+ var navigation_cluster := get_node_or_null("%NavigationCluster") as Control
+ var grouped_buttons: Dictionary = {}
+ _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons)
+ var selected_tabs: Array = []
+ var selected_group_y: float = INF
+ for group_value: Variant in grouped_buttons.keys():
+ var buttons := grouped_buttons[group_value] as Array
+ if buttons.size() < 2:
+ continue
+ var group_y: float = INF
+ for item: Variant in buttons:
+ var button := item as BaseButton
+ group_y = minf(group_y, button.global_position.y)
+ if group_y < selected_group_y:
+ selected_tabs = buttons
+ selected_group_y = group_y
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ var displaced_focus: bool = selected_tabs.has(focus_owner)
+ for item: Variant in selected_tabs:
+ var tab := item as Control
+ tab.focus_mode = Control.FOCUS_NONE
+ if displaced_focus:
+ focus_owner.release_focus()
+ call_deferred("_focus_current_section")
+ var inventory_tabs: Array[Button] = [
+ _cooler_sub_tab,
+ _tackle_sub_tab,
+ _bag_sub_tab,
+ ]
+ for index: int in inventory_tabs.size():
+ var tab: Button = inventory_tabs[index]
+ tab.focus_neighbor_left = tab.get_path_to(
+ inventory_tabs[maxi(index - 1, 0)]
+ )
+ tab.focus_neighbor_right = tab.get_path_to(
+ inventory_tabs[mini(index + 1, inventory_tabs.size() - 1)]
+ )
+ tab.focus_neighbor_top = tab.get_path_to(_inventory_tab)
+ _inventory_tab.focus_neighbor_bottom = _inventory_tab.get_path_to(
+ _inventory_tab_for_section(_last_inventory_section)
+ )
+ _tackle_sub_tab.focus_neighbor_bottom = _tackle_sub_tab.get_path_to(
+ _bait_filter
+ )
+
+
+func _inventory_tab_for_section(section: Section) -> Button:
+ match section:
+ Section.COOLER:
+ return _cooler_sub_tab
+ Section.BAG:
+ return _bag_sub_tab
+ _:
+ return _tackle_sub_tab
+
+
+func _configure_sale_confirmation_focus() -> void:
+ _confirm_sale_button.focus_neighbor_left = (
+ _confirm_sale_button.get_path_to(_cancel_sale_button)
+ )
+ _confirm_sale_button.focus_neighbor_right = (
+ _confirm_sale_button.focus_neighbor_left
+ )
+ _confirm_sale_button.focus_neighbor_top = (
+ _confirm_sale_button.focus_neighbor_left
+ )
+ _confirm_sale_button.focus_neighbor_bottom = (
+ _confirm_sale_button.focus_neighbor_left
+ )
+ _cancel_sale_button.focus_neighbor_left = (
+ _cancel_sale_button.get_path_to(_confirm_sale_button)
+ )
+ _cancel_sale_button.focus_neighbor_right = (
+ _cancel_sale_button.focus_neighbor_left
+ )
+ _cancel_sale_button.focus_neighbor_top = (
+ _cancel_sale_button.focus_neighbor_left
+ )
+ _cancel_sale_button.focus_neighbor_bottom = (
+ _cancel_sale_button.focus_neighbor_left
+ )
+ if not _sale_confirmation.visible:
+ for button: Button in [_confirm_sale_button, _cancel_sale_button]:
+ button.focus_mode = Control.FOCUS_NONE
+ button.mouse_filter = Control.MOUSE_FILTER_IGNORE
+
+
+func _configure_active_page_focus() -> void:
+ match _current_section:
+ Section.COOLER:
+ _configure_cooler_fish_focus()
+ Section.BAG:
+ _configure_bag_item_focus()
+ Section.TACKLE_BOX:
+ _bait_filter.grab_focus()
+ Section.LOGBOOK:
+ _catalog_logbook.focus_initial()
+ Section.NET:
+ _the_net_page.focus_initial()
+ Section.MAIL:
+ _mail_page.activate()
+ Section.PROFILE:
+ _profile_page.activate()
+
+
+func _apply_inventory_styles() -> void:
+ for button: Button in [
+ _equipment_filter,
+ _consumables_filter,
+ _bait_filter,
+ _lures_filter,
+ ]:
+ UtilityPageStyle.apply_ocean_button(button)
+ for panel: PanelContainer in [_tackle_main_panel]:
+ var panel_style := UtilityPageStyle.panel_style()
+ panel_style.set_corner_radius_all(INVENTORY_MAIN_CORNER_RADIUS)
+ panel.add_theme_stylebox_override(
+ "panel",
+ panel_style,
+ )
+ for label: Label in [_tackle_empty]:
+ label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ label.add_theme_color_override(
+ "font_color",
+ UtilityPageStyle.OCEAN_TEXT_PRIMARY,
+ )
+ _sale_confirmation.add_theme_stylebox_override(
+ "panel",
+ UtilityPageStyle.panel_style(),
+ )
+ _confirmation_message.add_theme_font_override(
+ "normal_font",
+ UtilityPageStyle.TuffyFont,
+ )
+ _confirmation_message.add_theme_color_override(
+ "default_color",
+ UtilityPageStyle.OCEAN_TEXT_PRIMARY,
+ )
+ UtilityPageStyle.apply_ocean_button(_confirm_sale_button)
+ UtilityPageStyle.apply_ocean_button(_cancel_sale_button)
+
+
+func _set_bag_view(view: BagView) -> void:
+ if _bag_view == view:
+ return
+ _bag_view = view
+ _selected_bag_item_id = StringName()
+ _refresh_bag()
+
+
+func _set_tackle_view(view: TackleView) -> void:
+ _tackle_view = view
+ _selected_tackle_item_id = StringName()
+ _refresh_tackle_box()
+
+
+func _refresh_tackle_box() -> void:
+ if not is_node_ready():
+ return
+ _bait_filter.button_pressed = _tackle_view == TackleView.BAIT
+ _lures_filter.button_pressed = _tackle_view == TackleView.LURES
+ for child: Node in _tackle_item_list.get_children():
+ child.queue_free()
+ var matching_items: Array[OwnedItemType] = []
+ if _bag != null and _item_catalog != null:
+ var available_items: Array[OwnedItemType] = (
+ _bag.get_unlocked_bait_items()
+ if _tackle_view == TackleView.BAIT
+ else _bag.get_all_items()
+ )
+ for owned: OwnedItemType in available_items:
+ var item: ItemDataType = _item_catalog.get_item_by_id(
+ owned.item_id
+ )
+ if item == null:
+ continue
+ if (
+ _tackle_view == TackleView.BAIT
+ and item.category == ItemDataType.Category.BAIT
+ ):
+ matching_items.append(owned)
+ elif (
+ _tackle_view == TackleView.LURES
+ and item.category == ItemDataType.Category.LURE
+ ):
+ matching_items.append(owned)
+ matching_items.sort_custom(_sort_bag_items)
+ for owned: OwnedItemType in matching_items:
+ var item: ItemDataType = _item_catalog.get_item_by_id(owned.item_id)
+ var row := Button.new()
+ row.icon = item.icon
+ row.tooltip_text = (
+ "%s ×%d" % [item.display_name, owned.quantity]
+ if item.is_bait()
+ else item.display_name
+ )
+ var compact_tackle_item: bool = item.is_bait() or item.is_lure()
+ if compact_tackle_item:
+ row.custom_minimum_size = Vector2(72, 72)
+ row.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
+ row.expand_icon = item.icon != null
+ row.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ row.text = "" if item.icon != null else item.display_name
+ if item.icon == null:
+ row.add_theme_font_size_override("font_size", 12)
+ else:
+ row.text = "%s ×%d" % [item.display_name, owned.quantity]
+ row.alignment = HORIZONTAL_ALIGNMENT_LEFT
+ row.toggle_mode = true
+ row.set_meta(&"controller_tackle_item_id", owned.item_id)
+ row.button_pressed = owned.item_id == _selected_tackle_item_id
+ if compact_tackle_item:
+ _apply_tackle_bait_button_style(row)
+ if item.is_bait():
+ _add_tackle_quantity_badge(row, owned.quantity)
+ else:
+ UtilityPageStyle.apply_ocean_button(row)
+ row.pressed.connect(_select_tackle_item.bind(owned.item_id))
+ _tackle_item_list.add_child(row)
+ _tackle_empty.text = (
+ "No bait collected."
+ if _tackle_view == TackleView.BAIT
+ else "No lures collected."
+ )
+ _tackle_empty.visible = matching_items.is_empty()
+ _update_tackle_detail()
+
+
+func _add_tackle_quantity_badge(row: Button, quantity: int) -> void:
+ var badge := Panel.new()
+ badge.name = "QuantityBadge"
+ badge.anchor_left = 1.0
+ badge.anchor_right = 1.0
+ badge.offset_left = -27.0
+ badge.offset_top = 3.0
+ badge.offset_right = -3.0
+ badge.offset_bottom = 27.0
+ badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ badge.z_index = 2
+ var badge_style := StyleBoxFlat.new()
+ badge_style.bg_color = Color("0b5558")
+ badge_style.set_corner_radius_all(12)
+ badge_style.anti_aliasing = false
+ badge.add_theme_stylebox_override("panel", badge_style)
+ row.add_child(badge)
+ var quantity_label := Label.new()
+ quantity_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ quantity_label.offset_left = 1.0
+ quantity_label.offset_right = 1.0
+ quantity_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ quantity_label.text = str(quantity)
+ quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ quantity_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ quantity_label.add_theme_font_size_override(
+ "font_size",
+ 13 if quantity >= 100 else 15,
+ )
+ quantity_label.add_theme_color_override("font_color", Color("e7f5f4"))
+ badge.add_child(quantity_label)
+
+
+func _apply_tackle_bait_button_style(button: Button) -> void:
+ var profile := BubbleMenuProfile.new()
+ var normal_style: StyleBoxFlat = profile.make_normal_style()
+ var hover_style: StyleBoxFlat = profile.make_hover_style()
+ var pressed_style: StyleBoxFlat = profile.make_pressed_style()
+ var disabled_style: StyleBoxFlat = profile.make_disabled_style()
+ for style: StyleBoxFlat in [
+ normal_style,
+ hover_style,
+ pressed_style,
+ disabled_style,
+ ]:
+ style.set_corner_radius_all(36)
+ style.content_margin_left = 13.5
+ style.content_margin_top = 13.5
+ style.content_margin_right = 13.5
+ style.content_margin_bottom = 13.5
+ button.add_theme_stylebox_override("normal", normal_style)
+ button.add_theme_stylebox_override("hover", hover_style)
+ button.add_theme_stylebox_override("focus", hover_style)
+ button.add_theme_stylebox_override("pressed", pressed_style)
+ button.add_theme_stylebox_override("disabled", disabled_style)
+ button.add_theme_color_override("icon_normal_color", Color.WHITE)
+ button.add_theme_color_override("icon_hover_color", Color.WHITE)
+ button.add_theme_color_override("icon_focus_color", Color.WHITE)
+ button.add_theme_color_override("icon_pressed_color", Color.WHITE)
+
+
+func _select_tackle_item(item_id: StringName) -> void:
+ _selected_tackle_item_id = item_id
+ _refresh_tackle_box()
+
+
+func _update_tackle_detail() -> void:
+ var item: ItemDataType = (
+ _item_catalog.get_item_by_id(_selected_tackle_item_id)
+ if _item_catalog != null and not _selected_tackle_item_id.is_empty()
+ else null
+ )
+ if item == null:
+ _tackle_equip_button.visible = false
+ _tackle_equip_button.persistent_mark = false
+ _tackle_detail_text.text = (
+ "Select bait for details."
+ if _tackle_view == TackleView.BAIT
+ else "Select a lure for details."
+ )
+ return
+ _tackle_equip_button.visible = item.is_bait() or item.is_lure()
+ var quantity: int = _bag.get_quantity(item.item_id) if _bag != null else 0
+ var assigned_slot: int = -1
+ if _hotbar != null:
+ for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
+ if _hotbar.get_item_id(slot_index) == item.item_id:
+ assigned_slot = slot_index
+ break
+ var detail_lines: Array[String] = [item.display_name, ""]
+ if item.is_bait():
+ detail_lines.append("quantity: %d" % quantity)
+ if assigned_slot >= 0:
+ detail_lines.append("hotbar slot %d" % (assigned_slot + 1))
+ detail_lines.append("")
+ detail_lines.append(item.description)
+ _tackle_detail_text.text = "\n".join(detail_lines)
+ if item.is_bait() or item.is_lure():
+ var is_equipped: bool = (
+ _player != null
+ and (
+ _player.active_bait_id == item.item_id
+ if item.is_bait()
+ else _player.active_lure_id == item.item_id
+ )
+ )
+ _tackle_equip_button.text = (
+ "dequip %s" % item.display_name
+ if is_equipped
+ else "equip %s" % item.display_name
+ )
+ _tackle_equip_button.persistent_mark = is_equipped
+ _tackle_equip_button.disabled = quantity <= 0
+ _tackle_equip_button.refresh_ink_state()
+
+
+func _toggle_active_tackle() -> void:
+ if _player == null or _item_catalog == null:
+ return
+ var item: ItemDataType = _item_catalog.get_item_by_id(_selected_tackle_item_id)
+ if item == null:
+ return
+ if item.is_bait():
+ if _player.active_bait_id == item.item_id:
+ _player.unequip_bait()
+ else:
+ _player.equip_bait(item)
+ elif item.is_lure():
+ if _player.active_lure_id == item.item_id:
+ _player.unequip_lure()
+ else:
+ _player.equip_lure(item)
else:
- _logbook_tab.grab_focus()
+ return
+ _update_tackle_detail()
-func _on_sort_selected(index: int) -> void:
- _sort_mode = _sort_option.get_item_id(index)
+func _on_active_bait_changed(_item_id: StringName) -> void:
+ _update_tackle_detail()
+
+
+func _on_active_lure_changed(_item_id: StringName) -> void:
+ _update_tackle_detail()
+
+
+func _apply_cooler_control_styles() -> void:
+ var profile: BubbleMenuProfile = _inventory_tab.profile
+ if profile == null:
+ return
+ _sort_option.add_theme_stylebox_override(
+ "normal",
+ profile.make_normal_style(),
+ )
+ _sort_option.add_theme_stylebox_override(
+ "hover",
+ profile.make_hover_style(),
+ )
+ _sort_option.add_theme_stylebox_override(
+ "focus",
+ profile.make_hover_style(),
+ )
+ _sort_option.add_theme_stylebox_override(
+ "pressed",
+ profile.make_pressed_style(),
+ )
+ _sort_option.add_theme_color_override("font_color", profile.text_color)
+ _sort_option.add_theme_color_override(
+ "font_hover_color",
+ profile.text_hover_color,
+ )
+ _sort_option.add_theme_color_override(
+ "font_focus_color",
+ profile.text_hover_color,
+ )
+func _apply_cooler_wall_styles() -> void:
+ var outer := StyleBoxFlat.new()
+ outer.bg_color = Color(0.76, 0.9, 0.96, 1.0)
+ outer.border_color = Color(0.91, 0.98, 1.0, 1.0)
+ outer.set_border_width_all(7)
+ outer.set_corner_radius_all(INVENTORY_MAIN_CORNER_RADIUS)
+ _cooler_outer_wall.add_theme_stylebox_override("panel", outer)
+ var inner := StyleBoxFlat.new()
+ inner.bg_color = Color(0.018, 0.16, 0.25, 1.0)
+ inner.border_color = Color(0.56, 0.78, 0.86, 1.0)
+ inner.set_border_width_all(5)
+ inner.set_corner_radius_all(INVENTORY_INNER_CORNER_RADIUS)
+ _cooler_inner_liner.add_theme_stylebox_override("panel", inner)
+ var scroll_bar := _cooler_scroll.get_v_scroll_bar()
+ var scroll_grabber := StyleBoxFlat.new()
+ scroll_grabber.bg_color = Color(0.56, 0.78, 0.86, 0.58)
+ scroll_grabber.set_corner_radius_all(6)
+ scroll_bar.add_theme_stylebox_override("grabber", scroll_grabber)
+ scroll_bar.add_theme_stylebox_override("grabber_highlight", scroll_grabber)
+ scroll_bar.add_theme_stylebox_override("grabber_pressed", scroll_grabber)
+ var scroll_track := StyleBoxFlat.new()
+ scroll_track.bg_color = Color(0.02, 0.1, 0.14, 0.28)
+ scroll_track.set_corner_radius_all(6)
+ scroll_bar.add_theme_stylebox_override("scroll", scroll_track)
+
+
+func _apply_cooler_notepad_style() -> void:
+ _detail_bubble.add_theme_stylebox_override(
+ "panel",
+ InventoryNotepadType.make_layout_style(false),
+ )
+ InventoryNotepadType.apply_handwritten_to(_detail_constellation)
+
+
+func _apply_navigation_styles() -> void:
+ var profile: BubbleMenuProfile = _inventory_tab.profile
+ if profile == null:
+ return
+ var normal := _make_navigation_style(profile.normal_fill, 2, 0.24)
+ var hover := _make_navigation_style(
+ profile.hover_fill.lightened(0.025),
+ 4,
+ 0.34,
+ )
+ var pressed := _make_navigation_style(
+ profile.pressed_fill.lightened(0.055),
+ 5,
+ 0.40,
+ )
+ var disabled := _make_navigation_style(profile.disabled_fill, 1, 0.14)
+ for bubble: BubbleButtonType in [
+ _inventory_tab,
+ _logbook_tab,
+ _the_net_tab,
+ _mail_tab,
+ _profile_tab,
+ _players_tab,
+ _close_button,
+ ]:
+ bubble.add_theme_stylebox_override("normal", normal)
+ bubble.add_theme_stylebox_override("hover", hover)
+ bubble.add_theme_stylebox_override("focus", hover)
+ bubble.add_theme_stylebox_override("pressed", pressed)
+ bubble.add_theme_stylebox_override("disabled", disabled)
+
+
+func _make_navigation_style(
+ fill: Color,
+ shadow_size: int,
+ shadow_alpha: float,
+) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = fill
+ style.set_corner_radius_all(128)
+ style.set_border_width_all(0)
+ style.shadow_color = Color(0.015, 0.06, 0.09, shadow_alpha)
+ style.shadow_size = shadow_size
+ style.shadow_offset = Vector2(0.0, 2.0)
+ return style
+
+
+func _apply_navigation_selection_presentation() -> void:
+ for bubble: BubbleButtonType in [
+ _inventory_tab,
+ _logbook_tab,
+ _the_net_tab,
+ _mail_tab,
+ _profile_tab,
+ _players_tab,
+ ]:
+ if not bubble.button_pressed:
+ continue
+ bubble.scale *= NAVIGATION_SELECTED_SCALE
+
+
+func _apply_bag_styles() -> void:
+ var outer := StyleBoxFlat.new()
+ outer.bg_color = UtilityPageStyle.OCEAN_PANEL_DEEP
+ outer.set_border_width_all(0)
+ outer.set_corner_radius_all(INVENTORY_MAIN_CORNER_RADIUS)
+ _bag_outer_wall.add_theme_stylebox_override("panel", outer)
+ var inner := StyleBoxFlat.new()
+ inner.bg_color = UtilityPageStyle.OCEAN_PANEL_MID
+ inner.set_border_width_all(0)
+ inner.set_corner_radius_all(INVENTORY_INNER_CORNER_RADIUS)
+ _bag_inner_liner.add_theme_stylebox_override("panel", inner)
+func _apply_logbook_styles() -> void:
+ var backing := StyleBoxFlat.new()
+ backing.bg_color = Color(0.54, 0.42, 0.27, 1.0)
+ backing.border_color = Color(0.72, 0.59, 0.39, 1.0)
+ backing.set_border_width_all(6)
+ backing.corner_radius_top_left = 42
+ backing.corner_radius_top_right = 34
+ backing.corner_radius_bottom_right = 46
+ backing.corner_radius_bottom_left = 36
+ _book_backing.add_theme_stylebox_override("panel", backing)
+ var left_paper := StyleBoxFlat.new()
+ left_paper.bg_color = Color(0.95, 0.91, 0.79, 1.0)
+ left_paper.border_color = Color(0.80, 0.70, 0.52, 1.0)
+ left_paper.set_border_width_all(3)
+ left_paper.corner_radius_top_left = 30
+ left_paper.corner_radius_bottom_left = 38
+ left_paper.corner_radius_top_right = 8
+ left_paper.corner_radius_bottom_right = 6
+ _left_page.add_theme_stylebox_override("panel", left_paper)
+ var right_paper := StyleBoxFlat.new()
+ right_paper.bg_color = Color(0.925, 0.875, 0.74, 1.0)
+ right_paper.border_color = Color(0.77, 0.66, 0.48, 1.0)
+ right_paper.set_border_width_all(3)
+ right_paper.corner_radius_top_left = 8
+ right_paper.corner_radius_bottom_left = 6
+ right_paper.corner_radius_top_right = 34
+ right_paper.corner_radius_bottom_right = 40
+ _right_page.add_theme_stylebox_override("panel", right_paper)
+ for label: Label in [_left_heading, _right_heading, _logbook_empty_state]:
+ label.add_theme_color_override(
+ "font_color",
+ Color(0.22, 0.16, 0.09, 1.0),
+ )
+ var page_profile: BubbleMenuProfile = _logbook_previous.profile
+ if page_profile != null:
+ var disabled_style: StyleBoxFlat = page_profile.make_normal_style()
+ disabled_style.bg_color.a = 0.82
+ disabled_style.border_color.a = 0.72
+ for control: BubbleButtonType in [
+ _logbook_previous,
+ _logbook_next,
+ ]:
+ control.add_theme_stylebox_override(
+ "disabled",
+ disabled_style.duplicate(),
+ )
+ control.add_theme_color_override(
+ "font_disabled_color",
+ Color(0.035, 0.145, 0.22, 0.68),
+ )
+ _logbook_page_status.set_content("pages", "1 / 1")
+
+
+func _update_navigation_selection() -> void:
+ _set_navigation_target(_current_section)
+
+
+func _on_mail_unread_count_changed(count: int) -> void:
+ _mail_unread_badge.visible = count > 0
+ _mail_unread_badge.text = "99+" if count > 99 else str(count)
+
+
+func _set_navigation_target(section: Section) -> void:
+ _inventory_tab.button_pressed = _is_inventory_section(section)
+ _logbook_tab.button_pressed = section == Section.LOGBOOK
+ _the_net_tab.button_pressed = section == Section.NET
+ _mail_tab.button_pressed = section == Section.MAIL
+ _profile_tab.button_pressed = section == Section.PROFILE
+ _players_tab.button_pressed = section == Section.PLAYERS
+
+
+func _update_shell_layout() -> void:
+ if not is_node_ready():
+ return
+ var compact: bool = false
+ _compact_layout = compact
+ var reference_size := DESKTOP_REFERENCE_SIZE
+ _presentation_scale_root.size = reference_size
+ if not _transitioning:
+ _presentation_scale_root.scale = Vector2.ONE
+ _presentation_scale_root.position = Vector2.ZERO
+ _presentation_scale_root.pivot_offset = reference_size * 0.5
+ _content_shell.position = Vector2(14.0, 92.0) if compact else Vector2(42.0, 104.0)
+ _content_shell.size = Vector2(612.0, 286.0) if compact else Vector2(1196.0, 478.0)
+ _presentation_rest_position = _content_shell.position
+ var navigation_size := (
+ Vector2(840.0, 75.0) if compact else Vector2(840.0, 100.0)
+ )
+ _navigation_cluster.position = NAVIGATION_CANONICAL_POSITION
+ _navigation_cluster.size = navigation_size
+ _navigation_cluster.scale = (
+ Vector2.ONE * NAVIGATION_PRESENTATION_SCALE
+ )
+ _navigation_cluster.apply_layout(navigation_size, compact)
+ _cooler_page.size = reference_size
+ _cooler_page.position = Vector2.ZERO
+ _cooler_rest_position = Vector2.ZERO
+ # The Bag shell has the widest 88 px upper-left curve. Eight more pixels
+ # place every tab flange behind a straight section of the shared panel.
+ _inventory_sub_tabs.position = (
+ INVENTORY_MAIN_POSITION
+ + Vector2(INVENTORY_TAB_LEFT_INSET, -30.0)
+ )
+ _layout_cooler_fish(false)
+ _cooler_outer_wall.position = INVENTORY_MAIN_POSITION
+ _cooler_outer_wall.size = INVENTORY_MAIN_SIZE
+ _detail_constellation.position = INVENTORY_NOTEPAD_POSITION
+ _detail_constellation.size = INVENTORY_NOTEPAD_SIZE
+ _notepad_binding.position = Vector2(44.0, 1.0) if compact else Vector2(28.0, 4.0)
+ _notepad_binding.size = Vector2(218.0, 20.0) if compact else Vector2(232.0, 27.0)
+ _notepad_binding.add_theme_font_size_override("font_size", 13 if compact else 20)
+ _notepad_title.position = Vector2(10.0, 14.0) if compact else Vector2(20.0, 31.0)
+ _notepad_title.size = Vector2(286.0, 18.0) if compact else Vector2(248.0, 28.0)
+ _notepad_title.add_theme_font_size_override("font_size", 14 if compact else 20)
+ _notepad_rule.position = Vector2(10.0, 49.0) if compact else Vector2(18.0, 87.0)
+ _notepad_rule.size = Vector2(286.0, 2.0) if compact else Vector2(252.0, 2.0)
+ _cooler_sort_controls.position = (
+ Vector2(10.0, 51.0) if compact else Vector2(18.0, 98.0)
+ )
+ _cooler_sort_controls.size = (
+ Vector2(286.0, 31.0) if compact else Vector2(252.0, 38.0)
+ )
+ _cooler_sort_controls.get_child(0).visible = false
+ _cooler_sort_option.custom_minimum_size = (
+ Vector2(140.0, 31.0) if compact else Vector2(123.0, 38.0)
+ )
+ _cooler_sort_direction.custom_minimum_size = (
+ Vector2(140.0, 31.0) if compact else Vector2(123.0, 38.0)
+ )
+ _cooler_sort_option.set_choice_font_size(14 if compact else 17)
+ _cooler_sort_direction.add_theme_font_size_override(
+ "font_size",
+ 14 if compact else 17,
+ )
+ _notepad_wallet_value.position = (
+ Vector2(10.0, 33.0) if compact else Vector2(18.0, 61.0)
+ )
+ _notepad_wallet_value.size = (
+ Vector2(138.0, 17.0) if compact else Vector2(120.0, 23.0)
+ )
+ _notepad_capacity_value.position = (
+ Vector2(154.0, 33.0) if compact else Vector2(146.0, 61.0)
+ )
+ _notepad_capacity_value.size = (
+ Vector2(142.0, 17.0) if compact else Vector2(124.0, 23.0)
+ )
+ _notepad_wallet_value.icon_size = 10.0 if compact else 14.0
+ _notepad_wallet_value.get_amount_label().add_theme_font_size_override(
+ "font_size", 10 if compact else 14
+ )
+ _notepad_capacity_value.add_theme_font_size_override(
+ "font_size", 10 if compact else 14
+ )
+ _cooler_detail_texture.position = (
+ Vector2(5.0, 84.0) if compact else Vector2(80.0, 137.0)
+ )
+ _cooler_detail_texture.size = (
+ Vector2(74.0, 58.0) if compact else Vector2(128.0, 100.0)
+ )
+ _cooler_detail_name.position = (
+ Vector2(82.0, 82.0) if compact else Vector2(18.0, 241.0)
+ )
+ _cooler_detail_name.size = (
+ Vector2(214.0, 26.0) if compact else Vector2(252.0, 32.0)
+ )
+ _cooler_detail_name.add_theme_font_size_override(
+ "font_size", 18 if compact else 25,
+ )
+ _cooler_detail_stats.position = (
+ Vector2(82.0, 109.0) if compact else Vector2(18.0, 274.0)
+ )
+ _cooler_detail_stats.size = (
+ Vector2(214.0, 39.0) if compact else Vector2(252.0, 65.0)
+ )
+ _layout_cooler_detail_text(compact)
+ _cooler_selection_summary.position = (
+ Vector2(10.0, 151.0) if compact else Vector2(18.0, 346.0)
+ )
+ _cooler_selection_summary.size = (
+ Vector2(130.0, 35.0) if compact else Vector2(252.0, 40.0)
+ )
+ _layout_cooler_selection_text(compact)
+ _favorite_bubble.custom_minimum_size = (
+ Vector2(72.0, 42.0) if compact else Vector2(108.0, 50.0)
+ )
+ _sell_bubble.custom_minimum_size = (
+ Vector2(74.0, 42.0) if compact else Vector2(118.0, 50.0)
+ )
+ _sell_all_bubble.custom_minimum_size = (
+ Vector2(96.0, 38.0) if compact else Vector2(122.0, 44.0)
+ )
+ _favorite_bubble.position = (
+ Vector2(144.0, 148.0) if compact else Vector2(26.0, 378.0)
+ )
+ _favorite_bubble.size = (
+ Vector2(72.0, 42.0) if compact else Vector2(108.0, 50.0)
+ )
+ _sell_bubble.position = (
+ Vector2(222.0, 148.0) if compact else Vector2(144.0, 378.0)
+ )
+ _sell_bubble.size = (
+ Vector2(74.0, 42.0) if compact else Vector2(118.0, 50.0)
+ )
+ _sell_all_bubble.position = (
+ Vector2(102.0, 190.0) if compact else Vector2(78.0, 426.0)
+ )
+ _sell_all_bubble.size = (
+ Vector2(96.0, 38.0) if compact else Vector2(122.0, 44.0)
+ )
+ _favorite_bubble.add_theme_font_size_override(
+ "font_size", 13 if compact else 19,
+ )
+ _sell_bubble.add_theme_font_size_override(
+ "font_size", 13 if compact else 19,
+ )
+ _sell_all_bubble.add_theme_font_size_override(
+ "font_size", 12 if compact else 17,
+ )
+ _update_sort_direction_text()
+ _bag_page.size = reference_size
+ _bag_page.position = Vector2.ZERO
+ _bag_rest_position = Vector2.ZERO
+ _tackle_box_page.size = reference_size
+ _tackle_box_page.position = Vector2.ZERO
+ _tackle_rest_position = Vector2.ZERO
+ _bag_outer_wall.position = INVENTORY_MAIN_POSITION
+ _bag_outer_wall.size = INVENTORY_MAIN_SIZE
+ _bag_filter_tabs.position = (
+ INVENTORY_MAIN_POSITION + INVENTORY_HEADER_INSET
+ )
+ _bag_filter_tabs.size = Vector2(320.0, INVENTORY_HEADER_HEIGHT)
+ _bag_host.custom_minimum_size = (
+ Vector2(520.0, 152.0) if compact else Vector2(788.0, 358.0)
+ )
+ _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ _bag_detail_constellation.position = INVENTORY_NOTEPAD_POSITION
+ _bag_detail_constellation.size = INVENTORY_NOTEPAD_SIZE
+ _bag_sprite_detail_texture.custom_minimum_size = (
+ Vector2(72.0, 48.0) if compact else Vector2(100.0, 72.0)
+ )
+ _bag_sprite_detail_name.add_theme_font_size_override(
+ "font_size",
+ 18 if compact else 22,
+ )
+ _bag_sprite_detail_data.add_theme_font_size_override(
+ "font_size",
+ 12 if compact else 15,
+ )
+ _tackle_main_panel.position = INVENTORY_MAIN_POSITION
+ _tackle_main_panel.size = INVENTORY_MAIN_SIZE
+ _tackle_detail_panel.position = INVENTORY_NOTEPAD_POSITION
+ _tackle_detail_panel.size = INVENTORY_NOTEPAD_SIZE
+ _logbook_page.size = reference_size
+ _logbook_page.position = Vector2.ZERO
+ _logbook_rest_position = Vector2.ZERO
+ _the_net_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ _the_net_page.size = reference_size
+ _the_net_page.position = Vector2.ZERO
+ _the_net_rest_position = Vector2.ZERO
+ _mail_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ _mail_page.size = reference_size
+ _mail_page.position = Vector2.ZERO
+ _mail_rest_position = Vector2.ZERO
+ _profile_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ _profile_page.position = Vector2.ZERO
+ _profile_page.size = reference_size
+ _profile_rest_position = Vector2.ZERO
+ _players_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ _players_page.position = Vector2.ZERO
+ _players_page.size = reference_size
+ _sale_confirmation.size = SALE_CONFIRMATION_SIZE
+ _sale_confirmation.position = (
+ (reference_size - SALE_CONFIRMATION_SIZE) * 0.5
+ )
+ _cooler_host.custom_minimum_size = Vector2(
+ 520.0 if compact else 800.0,
+ 512.0 if compact else 420.0,
+ )
+ _bag_grid.columns = 2 if compact else 3
+ _bag_list.custom_minimum_size.x = 300.0 if compact else 360.0
+ _bag_detail.custom_minimum_size.x = 176.0 if compact else 220.0
+ _content_stage.custom_minimum_size.y = 220.0 if compact else 260.0
+ _content_rest_position = _content_stage.position
+ if not _transitioning:
+ _content_shell.position = _presentation_rest_position
+ _cooler_page.position = _cooler_rest_position
+ _bag_page.position = _bag_rest_position
+ _tackle_box_page.position = _tackle_rest_position
+ _logbook_page.position = _logbook_rest_position
+ _the_net_page.position = _the_net_rest_position
+ _mail_page.position = _mail_rest_position
+ _profile_page.position = _profile_rest_position
+ _players_page.position = Vector2.ZERO
+ _cooler_page.modulate.a = 1.0
+ _bag_page.modulate.a = 1.0
+ _tackle_box_page.modulate.a = 1.0
+ _logbook_page.modulate.a = 1.0
+ _the_net_page.modulate.a = 1.0
+ _mail_page.modulate.a = 1.0
+ _profile_page.modulate.a = 1.0
+ _players_page.modulate.a = 1.0
+ if not _page_transitioning:
+ _content_stage.position = _content_rest_position
+ _layout_cooler_fish(false)
+ _layout_bag_items()
+
+
+func _layout_cooler_detail_text(compact: bool) -> void:
+ var weight_row_height: float = 17.0 if compact else 24.0
+ _cooler_weight_row.position = Vector2.ZERO
+ _cooler_weight_row.size = Vector2(
+ _cooler_detail_stats.size.x,
+ weight_row_height,
+ )
+ _cooler_offer_row.position = Vector2(
+ 0.0,
+ 17.0 if compact else 27.0,
+ )
+ _cooler_offer_row.size = Vector2(
+ _cooler_detail_stats.size.x,
+ 22.0 if compact else 30.0,
+ )
+ _cooler_weight_value.add_theme_font_size_override(
+ "font_size",
+ 9 if compact else 12,
+ )
+ _cooler_weight_unit.add_theme_font_size_override(
+ "font_size",
+ 10 if compact else 14,
+ )
+ _cooler_offer_label.add_theme_font_size_override(
+ "font_size",
+ 13 if compact else 18,
+ )
+ _cooler_offer_value.icon_size = 10.0 if compact else 15.0
+ _cooler_offer_value.get_amount_label().add_theme_font_size_override(
+ "font_size", 10 if compact else 15
+ )
+
+
+func _layout_cooler_selection_text(compact: bool) -> void:
+ var row_height: float = 17.5 if compact else 20.0
+ _cooler_selected_count_value.add_theme_font_size_override(
+ "font_size", 10 if compact else 14
+ )
+ _cooler_combined_offer_value.icon_size = 10.0 if compact else 14.0
+ _cooler_combined_offer_value.get_amount_label().add_theme_font_size_override(
+ "font_size", 10 if compact else 14
+ )
+ _cooler_selection_empty.add_theme_font_size_override(
+ "font_size", 14 if compact else 20
+ )
+ for word_label: Label in [
+ _cooler_selected_count_label,
+ _cooler_combined_offer_label,
+ ]:
+ word_label.add_theme_font_size_override(
+ "font_size",
+ 12 if compact else 18,
+ )
+ _cooler_selected_count_row.position = Vector2.ZERO
+ _cooler_selected_count_row.size = Vector2(
+ _cooler_selection_summary.size.x,
+ row_height,
+ )
+ _cooler_combined_offer_row.position = Vector2(0.0, row_height)
+ _cooler_combined_offer_row.size = Vector2(
+ _cooler_selection_summary.size.x,
+ row_height,
+ )
+
+
+func _begin_menu_entry() -> void:
+ _transitioning = true
+ _set_shell_interactive(false)
+ _reset_page_transition_visuals()
+ _animate_inventory_tab_entry()
+ set_process(true)
+ var generation: int = _transition_generation
+ _presentation_scale_root.pivot_offset = (
+ _presentation_scale_root.size * 0.5
+ )
+ _presentation_scale_root.modulate.a = 0.0
+ _presentation_scale_root.scale = (
+ Vector2.ONE * UIMotion.PLAYER_MENU_ENTER_SCALE
+ )
+ _presentation_tween = create_tween().set_parallel(true)
+ _presentation_tween.tween_property(
+ _presentation_scale_root,
+ "modulate:a",
+ 1.0,
+ UIMotion.PLAYER_MENU_ENTER_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _presentation_tween.tween_property(
+ _presentation_scale_root,
+ "scale",
+ Vector2.ONE,
+ UIMotion.PLAYER_MENU_ENTER_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ _presentation_tween.chain().tween_callback(
+ _finish_menu_entry.bind(generation)
+ )
+
+
+func _finish_menu_entry(generation: int) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _presentation_tween = null
+ _transitioning = false
+ _set_shell_interactive(true)
+ _focus_current_section()
+
+
+func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void:
+ if reason != CloseReason.USER:
+ _finish_close(reason, restore_controls, _menu_generation)
+ return
+ _transition_generation += 1
+ _cancel_presentation_tween()
+ _transitioning = true
+ _set_shell_interactive(false)
+ _settle_inventory_tabs_for_close()
+ if _current_section == Section.LOGBOOK:
+ _catalog_logbook.deactivate()
+ elif _current_section == Section.NET:
+ _the_net_page.deactivate()
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+ var generation: int = _transition_generation
+ var closing_generation: int = _menu_generation
+ _presentation_tween = create_tween().set_parallel(true)
+ _presentation_tween.tween_property(
+ _presentation_scale_root,
+ "modulate:a",
+ 0.0,
+ UIMotion.PLAYER_MENU_EXIT_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ _presentation_tween.tween_property(
+ _presentation_scale_root,
+ "scale",
+ Vector2.ONE * UIMotion.PLAYER_MENU_EXIT_SCALE,
+ UIMotion.PLAYER_MENU_EXIT_DURATION,
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ _presentation_tween.chain().tween_callback(
+ _finish_menu_exit.bind(
+ generation,
+ reason,
+ restore_controls,
+ closing_generation,
+ )
+ )
+
+
+func _finish_menu_exit(
+ generation: int,
+ reason: CloseReason,
+ restore_controls: bool,
+ closing_generation: int,
+) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _presentation_tween = null
+ _finish_close(reason, restore_controls, closing_generation)
+
+
+func _finish_close(
+ reason: CloseReason,
+ restore_controls: bool,
+ closing_generation: int,
+) -> void:
+ _cancel_presentation_tween()
+ _cancel_page_tween()
+ _cancel_logbook_page_transition(true)
+ _the_net_page.deactivate()
+ _transitioning = false
+ _page_transitioning = false
+ _bag_drag_active = false
+ _reset_page_transition_visuals()
+ _presentation_scale_root.modulate.a = 1.0
+ _presentation_scale_root.scale = Vector2.ONE
+ visible = false
+ set_process(false)
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+ if _fishing_spot != null and is_instance_valid(_fishing_spot):
+ _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
+ if restore_controls:
+ _restore_player_controls(closing_generation)
+ else:
+ _control_snapshot_stored = false
+ _apply_mouse_close_policy(reason)
+ _menu_generation += 1
+ _transition_generation += 1
+ menu_visibility_changed.emit(false)
+
+
+func _get_section_root(section: Section) -> Control:
+ match section:
+ Section.COOLER:
+ return _cooler_page
+ Section.BAG:
+ return _bag_page
+ Section.TACKLE_BOX:
+ return _tackle_box_page
+ Section.LOGBOOK:
+ return _logbook_page
+ Section.NET:
+ return _the_net_page
+ Section.MAIL:
+ return _mail_page
+ Section.PROFILE:
+ return _profile_page
+ _:
+ return _players_page
+
+
+func _get_section_rest_position(section: Section) -> Vector2:
+ match section:
+ Section.COOLER:
+ return _cooler_rest_position
+ Section.BAG:
+ return _bag_rest_position
+ Section.TACKLE_BOX:
+ return _tackle_rest_position
+ Section.LOGBOOK:
+ return _logbook_rest_position
+ Section.NET:
+ return _the_net_rest_position
+ Section.MAIL:
+ return _mail_rest_position
+ Section.PROFILE:
+ return _profile_rest_position
+ _:
+ return Vector2.ZERO
+
+
+func _begin_page_transition(section: Section) -> void:
+ _cancel_logbook_page_transition(true)
+ _page_transition_generation += 1
+ _cancel_page_tween()
+ _page_transitioning = true
+ _set_content_interactive(false)
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+ var outgoing_section: Section = _current_section
+ var outgoing_inventory: bool = _is_inventory_section(outgoing_section)
+ var incoming_inventory: bool = _is_inventory_section(section)
+ if outgoing_inventory and not incoming_inventory:
+ _settle_inventory_tabs_for_close()
+ _set_navigation_target(section)
+ var generation: int = _page_transition_generation
+ _page_outgoing_content_root = _get_section_root(outgoing_section)
+ _page_outgoing_root = (
+ _inventory_transition_group
+ if outgoing_inventory and not incoming_inventory
+ else _page_outgoing_content_root
+ )
+ _page_incoming_root = (
+ _inventory_transition_group
+ if incoming_inventory and not outgoing_inventory
+ else _get_section_root(section)
+ )
+ var outgoing_rest: Vector2 = _get_section_rest_position(outgoing_section)
+ var incoming_rest: Vector2 = _get_section_rest_position(section)
+ _show_section_immediate(section)
+ if outgoing_inventory and not incoming_inventory:
+ # Immediate page selection hides Inventory. Restore the outgoing content
+ # beneath its still-opaque masking group for the coordinated fade-out.
+ _page_outgoing_content_root.visible = true
+ _inventory_sub_tabs.visible = true
+ _inventory_transition_group.visible = true
+ _page_outgoing_root.visible = true
+ _page_incoming_root.visible = true
+ _page_outgoing_root.position = outgoing_rest
+ _page_incoming_root.position = incoming_rest
+ _page_outgoing_root.modulate.a = 1.0
+ _page_incoming_root.modulate.a = 0.0
+ if incoming_inventory and not outgoing_inventory:
+ _animate_inventory_tab_entry()
+ var duration: float = (
+ UIMotion.PLAYER_MENU_INVENTORY_DURATION
+ if outgoing_inventory and incoming_inventory
+ else UIMotion.PLAYER_MENU_PAGE_DURATION
+ )
+ _page_tween = create_tween().set_parallel(true)
+ _page_tween.tween_property(
+ _page_outgoing_root,
+ "modulate:a",
+ 0.0,
+ duration,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _page_tween.tween_property(
+ _page_incoming_root,
+ "modulate:a",
+ 1.0,
+ duration,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _page_tween.finished.connect(
+ _finish_page_transition.bind(generation),
+ CONNECT_ONE_SHOT
+ )
+
+
+func _finish_page_transition(generation: int) -> void:
+ if generation != _page_transition_generation or not visible:
+ return
+ _page_tween = null
+ _page_transitioning = false
+ if (
+ _page_outgoing_root != null
+ and _page_outgoing_root != _page_incoming_root
+ and _page_outgoing_root != _inventory_transition_group
+ ):
+ _page_outgoing_root.visible = false
+ if (
+ _page_outgoing_content_root != null
+ and not _is_inventory_section(_current_section)
+ ):
+ _page_outgoing_content_root.visible = false
+ _inventory_sub_tabs.visible = _is_inventory_section(_current_section)
+ _inventory_sub_tabs.modulate.a = 1.0
+ _reset_page_transition_visuals()
+ _set_content_interactive(true)
+ _focus_current_section()
+
+
+func _set_shell_interactive(interactive: bool) -> void:
+ _set_content_interactive(interactive)
+ for bubble: BubbleButtonType in [
+ _inventory_tab,
+ _logbook_tab,
+ _the_net_tab,
+ _mail_tab,
+ _profile_tab,
+ _players_tab,
+ _close_button,
+ ]:
+ bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
+ bubble.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _set_content_interactive(interactive: bool) -> void:
+ _content_stage.mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _cooler_page.mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive and _current_section == Section.COOLER
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _bag_page.mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive and _current_section == Section.BAG
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _tackle_box_page.mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive and _current_section == Section.TACKLE_BOX
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ for tab: Button in [
+ _cooler_sub_tab, _bag_sub_tab, _tackle_sub_tab,
+ ]:
+ var tab_interactive: bool = (
+ interactive and _is_inventory_section(_current_section)
+ )
+ tab.focus_mode = (
+ Control.FOCUS_ALL if tab_interactive else Control.FOCUS_NONE
+ )
+ tab.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if tab_interactive and not tab.button_pressed
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _logbook_page.mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive and _current_section == Section.LOGBOOK
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _catalog_logbook.set_interactive(
+ interactive and _current_section == Section.LOGBOOK
+ )
+ _the_net_page.set_interactive(
+ interactive and _current_section == Section.NET
+ )
+ _mail_page.set_interactive(
+ interactive and _current_section == Section.MAIL
+ )
+ _profile_page.set_interactive(
+ interactive and _current_section == Section.PROFILE
+ )
+ var cooler_interactive: bool = (
+ interactive and _current_section == Section.COOLER
+ )
+ _cooler_sort_option.disabled = not cooler_interactive
+ _cooler_sort_direction.disabled = not cooler_interactive
+ _cooler_sort_option.refresh_ink_state()
+ _cooler_sort_direction.refresh_ink_state()
+ for fish_node: CoolerFishSpriteType in _fish_nodes.values():
+ fish_node.focus_mode = (
+ Control.FOCUS_ALL
+ if cooler_interactive
+ else Control.FOCUS_NONE
+ )
+ fish_node.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if cooler_interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ var detail_interactive: bool = (
+ cooler_interactive and _detail_constellation.visible
+ )
+ for action: NotepadInkActionType in [
+ _favorite_bubble,
+ _sell_bubble,
+ _sell_all_bubble,
+ ]:
+ var action_interactive: bool = detail_interactive and not action.disabled
+ action.focus_mode = (
+ Control.FOCUS_ALL
+ if action_interactive
+ else Control.FOCUS_NONE
+ )
+ action.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if action_interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ action.refresh_ink_state()
+ var bag_interactive: bool = (
+ interactive
+ and _current_section == Section.BAG
+ and not _bag_drag_active
+ )
+ for item_node: BagItemSpriteType in _bag_item_nodes.values():
+ item_node.focus_mode = (
+ Control.FOCUS_ALL if bag_interactive else Control.FOCUS_NONE
+ )
+ item_node.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if bag_interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _cancel_presentation_tween() -> void:
+ if _presentation_tween != null:
+ _presentation_tween.kill()
+ _presentation_tween = null
+
+
+func _cancel_page_tween() -> void:
+ if _page_tween != null:
+ _page_tween.kill()
+ _page_tween = null
+ _reset_page_transition_visuals()
+
+
+func _reset_page_transition_visuals() -> void:
+ for section: Section in [
+ Section.COOLER,
+ Section.BAG,
+ Section.TACKLE_BOX,
+ Section.LOGBOOK,
+ Section.NET,
+ Section.MAIL,
+ Section.PROFILE,
+ Section.PLAYERS,
+ ]:
+ var page: Control = _get_section_root(section)
+ page.position = _get_section_rest_position(section)
+ page.modulate.a = 1.0
+ if _inventory_transition_group != null:
+ _inventory_transition_group.modulate.a = 1.0
+ _inventory_transition_group.visible = true
+ _inventory_sub_tabs.modulate.a = 1.0
+ _page_outgoing_root = null
+ _page_incoming_root = null
+ _page_outgoing_content_root = null
+
+
+func _on_sort_selected(index: int, source: OptionButton) -> void:
+ var selected_id: int = source.get_item_id(index)
+ if selected_id < SortMode.CATCH_ORDER or selected_id > SortMode.RARITY:
+ return
+ _sort_mode = selected_id as SortMode
+ _sort_option.select(_sort_mode)
+ _cooler_sort_option.select(_sort_mode)
+ _refresh_inventory()
+
+
+func _on_cooler_sort_selected(sort_id: int) -> void:
+ _sort_mode = sort_id as SortMode
+ _sort_option.select(_sort_mode)
+ _cooler_sort_option.select(_sort_mode)
_refresh_inventory()
@@ -353,62 +3076,233 @@ func _on_sort_direction_pressed() -> void:
func _update_sort_direction_text() -> void:
+ var direction_text: String
match _sort_mode:
SortMode.CATCH_ORDER:
- _sort_direction.text = "newest first" if _sort_descending else "oldest first"
+ if _compact_layout:
+ direction_text = "newest" if _sort_descending else "oldest"
+ else:
+ direction_text = (
+ "newest first" if _sort_descending else "oldest first"
+ )
SortMode.NAME:
- _sort_direction.text = "z–a" if _sort_descending else "a–z"
+ direction_text = "z–a" if _sort_descending else "a–z"
SortMode.RARITY:
- _sort_direction.text = "high to low" if _sort_descending else "low to high"
+ if _compact_layout:
+ direction_text = "high–low" if _sort_descending else "low–high"
+ else:
+ direction_text = (
+ "high to low" if _sort_descending else "low to high"
+ )
+ _sort_direction.text = direction_text
+ _cooler_sort_direction.text = direction_text
func _refresh_all() -> void:
_refresh_economy_summary()
_refresh_inventory()
_refresh_bag()
- _refresh_logbook()
func _on_bag_changed() -> void:
_refresh_bag()
+ _refresh_tackle_box()
+
+
+func _on_hotbar_changed() -> void:
+ _update_bag_detail()
+ _update_tackle_detail()
func _refresh_bag() -> void:
if not is_node_ready():
return
- _clear_container(_bag_grid)
var owned_items: Array[OwnedItemType] = (
_bag.get_all_items() if _bag != null else []
)
+ var filtered_items: Array[OwnedItemType] = []
+ for owned: OwnedItemType in owned_items:
+ var item: ItemDataType = (
+ _item_catalog.get_item_by_id(owned.item_id)
+ if _item_catalog != null
+ else null
+ )
+ if item == null:
+ continue
+ var is_consumable: bool = (
+ item.category == ItemDataType.Category.CONSUMABLE
+ )
+ if (
+ (_bag_view == BagView.CONSUMABLES and is_consumable)
+ or (
+ _bag_view == BagView.EQUIPMENT
+ and not is_consumable
+ and item.category != ItemDataType.Category.BAIT
+ and item.category != ItemDataType.Category.LURE
+ )
+ ):
+ filtered_items.append(owned)
+ owned_items = filtered_items
owned_items.sort_custom(_sort_bag_items)
+ _sorted_bag_items = owned_items
+ _equipment_filter.button_pressed = _bag_view == BagView.EQUIPMENT
+ _consumables_filter.button_pressed = (
+ _bag_view == BagView.CONSUMABLES
+ )
_bag_empty.visible = owned_items.is_empty()
+ _bag_empty_state.visible = owned_items.is_empty()
+ _bag_empty_state.text = (
+ "No equipment in your Bag."
+ if _bag_view == BagView.EQUIPMENT
+ else "No consumables in your Bag."
+ )
if (
not _selected_bag_item_id.is_empty()
and (_bag == null or not _bag.owns_item(_selected_bag_item_id))
):
_selected_bag_item_id = StringName()
+ _sync_bag_item_nodes(owned_items)
+ _update_bag_detail()
+
+
+func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void:
+ var retained: Dictionary[StringName, bool] = {}
for owned: OwnedItemType in owned_items:
var item: ItemDataType = _item_catalog.get_item_by_id(owned.item_id)
if item == null:
continue
- var card := ItemDragSourceType.new()
- card.custom_minimum_size = Vector2(140, 88)
- card.expand_icon = true
- card.icon = item.icon
- card.text = "%s%s\n%s" % [
+ retained[owned.item_id] = true
+ var item_node := _bag_item_nodes.get(
+ owned.item_id
+ ) as BagItemSpriteType
+ if item_node == null:
+ item_node = BagItemSpriteScene.instantiate()
+ _bag_item_field.add_child(item_node)
+ _bag_item_nodes[owned.item_id] = item_node
+ item_node.pressed.connect(
+ _select_bag_item.bind(owned.item_id)
+ )
+ item_node.focus_entered.connect(
+ _select_bag_item.bind(owned.item_id)
+ )
+ item_node.drag_started.connect(_on_bag_drag_started)
+ item_node.drag_finished.connect(_on_bag_drag_finished)
+ var identity_hash: int = absi(String(owned.item_id).hash())
+ item_node.configure_item(
+ item.item_id,
item.display_name,
- " ×%d" % owned.quantity if owned.quantity > 1 else "",
- item.get_category_name(),
- ]
- card.tooltip_text = (
+ item.icon,
+ owned.quantity,
+ item.hotbar_allowed,
+ float(identity_hash % 628) / 100.0,
+ 0.97 + float(identity_hash % 7) * 0.01,
+ )
+ item_node.tooltip_text = (
"drag to a hotbar slot."
if item.hotbar_allowed
else "this item cannot be assigned to the hotbar."
)
- card.setup(item.item_id, item.display_name, item.icon)
- card.pressed.connect(_select_bag_item.bind(item.item_id))
- _bag_grid.add_child(card)
- _update_bag_detail()
+ item_node.disabled = false
+ item_node.set_selected(item.item_id == _selected_bag_item_id)
+ for item_id: StringName in _bag_item_nodes.keys():
+ if retained.has(item_id):
+ continue
+ var removed := _bag_item_nodes[item_id] as BagItemSpriteType
+ _bag_item_nodes.erase(item_id)
+ _release_focus_from(removed, _bag_sub_tab)
+ removed.queue_free()
+ _layout_bag_items()
+ _configure_bag_item_focus()
+
+
+func _layout_bag_items() -> void:
+ if not is_node_ready() or _sorted_bag_items.is_empty():
+ return
+ _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ var columns: int = 3
+ var cell_size := (
+ Vector2(166.0, 72.0)
+ if _compact_layout
+ else Vector2(246.0, 154.0)
+ )
+ var item_size := (
+ Vector2(86.0, 62.0)
+ if _compact_layout
+ else Vector2(138.0, 102.0)
+ )
+ var origin := (
+ Vector2(16.0, 4.0)
+ if _compact_layout
+ else Vector2(54.0, 44.0)
+ )
+ _bag_item_field.custom_minimum_size = (
+ Vector2(520.0, 140.0)
+ if _compact_layout
+ else Vector2(820.0, 240.0)
+ )
+ for index: int in _sorted_bag_items.size():
+ var owned: OwnedItemType = _sorted_bag_items[index]
+ var item_node := _bag_item_nodes.get(
+ owned.item_id
+ ) as BagItemSpriteType
+ if item_node == null:
+ continue
+ var identity_hash: int = absi(String(owned.item_id).hash())
+ var column: int = index % columns
+ var row: int = floori(float(index) / float(columns))
+ var stable_offset := Vector2(
+ float(identity_hash % 13) - 6.0,
+ float(floori(float(identity_hash) / 19.0) % 11) - 5.0,
+ )
+ var lane_offset: float = 18.0 if row % 2 == 1 else 0.0
+ item_node.custom_minimum_size = item_size
+ item_node.size = item_size
+ item_node.position = (
+ origin
+ + Vector2(
+ float(column) * cell_size.x + lane_offset,
+ float(row) * cell_size.y,
+ )
+ + stable_offset
+ )
+
+
+func _configure_bag_item_focus() -> void:
+ if _current_section != Section.BAG:
+ return
+ var controls: Array[BagItemSpriteType] = []
+ for owned: OwnedItemType in _sorted_bag_items:
+ var item_node := _bag_item_nodes.get(
+ owned.item_id
+ ) as BagItemSpriteType
+ if item_node != null:
+ controls.append(item_node)
+ if controls.is_empty():
+ _bag_sub_tab.focus_neighbor_bottom = NodePath()
+ return
+ _bag_sub_tab.focus_neighbor_bottom = _bag_sub_tab.get_path_to(
+ controls.front()
+ )
+ for index: int in controls.size():
+ var control: BagItemSpriteType = controls[index]
+ var column: int = index % 3
+ var row: int = floori(float(index) / 3.0)
+ var left_index: int = row * 3 + maxi(column - 1, 0)
+ var right_index: int = mini(index + 1, controls.size() - 1)
+ control.focus_neighbor_left = control.get_path_to(
+ controls[left_index]
+ )
+ control.focus_neighbor_right = control.get_path_to(
+ controls[right_index]
+ )
+ control.focus_neighbor_top = (
+ control.get_path_to(_bag_sub_tab)
+ if index < 3
+ else control.get_path_to(controls[index - 3])
+ )
+ control.focus_neighbor_bottom = control.get_path_to(
+ controls[mini(index + 3, controls.size() - 1)]
+ )
func _sort_bag_items(a: OwnedItemType, b: OwnedItemType) -> bool:
@@ -424,7 +3318,12 @@ func _sort_bag_items(a: OwnedItemType, b: OwnedItemType) -> bool:
func _select_bag_item(item_id: StringName) -> void:
+ if _bag_drag_active or get_viewport().gui_is_dragging():
+ return
_selected_bag_item_id = item_id
+ for candidate_id: StringName in _bag_item_nodes:
+ var item_node := _bag_item_nodes[candidate_id] as BagItemSpriteType
+ item_node.set_selected(candidate_id == item_id)
_update_bag_detail()
@@ -439,34 +3338,91 @@ func _update_bag_detail() -> void:
if _bag != null and item != null
else 0
)
- _bag_detail_texture.texture = item.icon if item != null else null
+ var hotbar_assignment: String = "not assigned"
+ if item != null and _hotbar != null:
+ for slot_index: int in range(PlayerHotbarType.SLOT_COUNT):
+ if _hotbar.get_item_id(slot_index) == item.item_id:
+ hotbar_assignment = "hotbar slot %d" % (slot_index + 1)
+ break
+ var item_state: String = ""
+ if item != null:
+ if item.equippable:
+ item_state = "equippable"
+ elif item.usable:
+ item_state = "usable"
+ _bag_detail_texture.texture = null
_bag_detail_name.text = item.display_name if item != null else ""
_bag_detail_data.text = (
- "%s\nquantity: %d\n%s\n%s"
+ "%s\nquantity: %d\n%s\n%s\n%s"
% [
item.get_category_name(),
quantity,
+ item_state,
+ hotbar_assignment,
item.description,
- (
- "can be assigned to the hotbar."
- if item.hotbar_allowed
- else "cannot be assigned to the hotbar."
- ),
]
if item != null
else "select a bag item for details."
)
+ _bag_sprite_detail_texture.texture = null
+ if item != null:
+ _bag_detail_texture.texture = item.icon
+ _bag_sprite_detail_texture.texture = item.icon
+ _bag_sprite_detail_name.text = (
+ item.display_name if item != null else ""
+ )
+ _bag_sprite_detail_data.text = (
+ "%s • quantity: %d\n%s • %s\n%s"
+ % [
+ item.get_category_name(),
+ quantity,
+ item_state,
+ hotbar_assignment,
+ item.description,
+ ]
+ if item != null
+ else "select an item for details."
+ )
+ _bag_detail_constellation.visible = (
+ _current_section == Section.BAG
+ )
+
+
+func _close_bag_detail() -> void:
+ _bag_detail_constellation.visible = false
+
+
+func _on_bag_field_gui_input(event: InputEvent) -> void:
+ if (
+ event is InputEventMouseButton
+ and event.button_index == MOUSE_BUTTON_LEFT
+ and event.pressed
+ and not get_viewport().gui_is_dragging()
+ ):
+ _selected_bag_item_id = StringName()
+ for item_node: BagItemSpriteType in _bag_item_nodes.values():
+ item_node.set_selected(false)
+ _update_bag_detail()
+
+
+func _on_bag_drag_started() -> void:
+ _bag_drag_active = true
+ _close_bag_detail()
+ _set_content_interactive(false)
+
+
+func _on_bag_drag_finished() -> void:
+ _bag_drag_active = false
+ if visible and _current_section == Section.BAG:
+ _set_content_interactive(
+ not _transitioning and not _page_transitioning
+ )
func _on_inventory_changed() -> void:
_refresh_economy_summary()
_revalidate_confirmation()
_refresh_inventory()
- _refresh_logbook()
-
-
-func _on_fish_discovered(_fish_id: StringName) -> void:
- _refresh_logbook()
func _on_wallet_balance_changed(
@@ -485,8 +3441,19 @@ func _refresh_economy_summary() -> void:
if _inventory != null
else 0
)
- _wallet_balance.text = "wallet: $%d" % balance
- _held_value.text = "held fish base value: $%d" % held_total
+ _wallet_balance.set_amount(balance)
+ _wallet_status.set_currency_amount("wallet", balance)
+ _notepad_wallet_value.set_amount(balance)
+ _capacity_status.set_content("cooler", "%d / %d" % [
+ _inventory.get_all_catches().size() if _inventory != null else 0,
+ _cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
+ ])
+ _notepad_capacity_value.text = "%d / %d" % [
+ _inventory.get_all_catches().size() if _inventory != null else 0,
+ _cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
+ ]
+ _held_value_status.set_currency_amount("held value", held_total)
+ _held_value.set_amount(held_total)
_cooler_count.text = "%d / %d" % [
_inventory.get_all_catches().size() if _inventory != null else 0,
_cooler_capacity.get_capacity() if _cooler_capacity != null else 0,
@@ -500,29 +3467,222 @@ func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void:
func _refresh_inventory() -> void:
if not is_node_ready():
return
- _clear_container(_inventory_grid)
var catches: Array[FishCatchType] = []
if _inventory != null:
for fish_catch: FishCatchType in _inventory.get_all_catches():
if fish_catch != null and fish_catch.is_valid():
catches.append(fish_catch)
catches.sort_custom(_compare_catches)
+ _sorted_catches = catches
_inventory_empty.visible = catches.is_empty()
var visible_ids: Array[StringName] = []
for fish_catch: FishCatchType in catches:
visible_ids.append(fish_catch.catch_id)
_fish_selection.set_visible_order(visible_ids)
- var selected: FishCatchType
- if _inventory != null:
- selected = _inventory.get_catch(_fish_selection.get_focused_id())
- for fish_catch: FishCatchType in catches:
- _inventory_grid.add_child(_create_inventory_card(fish_catch))
- _update_inventory_detail(selected)
+ _sync_cooler_fish_nodes(catches)
+ var selected: FishCatchType = (
+ _inventory.get_catch(_fish_selection.get_focused_id())
+ if _inventory != null
+ else null
+ )
_update_sale_summary()
+ _update_inventory_detail(selected)
_update_sort_direction_text()
+func _sync_cooler_fish_nodes(catches: Array[FishCatchType]) -> void:
+ var retained: Dictionary[StringName, bool] = {}
+ for fish_catch: FishCatchType in catches:
+ retained[fish_catch.catch_id] = true
+ var fish_node := _fish_nodes.get(
+ fish_catch.catch_id
+ ) as CoolerFishSpriteType
+ var is_new: bool = fish_node == null
+ if is_new:
+ fish_node = CoolerFishSpriteScene.instantiate()
+ _fish_field.add_child(fish_node)
+ _fish_nodes[fish_catch.catch_id] = fish_node
+ fish_node.set_meta("new_cooler_fish", true)
+ fish_node.pressed.connect(
+ _on_catch_card_pressed.bind(fish_catch.catch_id)
+ )
+ var identity_hash: int = absi(String(fish_catch.catch_id).hash())
+ fish_node.configure(
+ fish_catch.catch_id,
+ FishQualityType.qualified_name(
+ fish_catch.fish.display_name,
+ fish_catch.quality,
+ ),
+ fish_catch.fish.display_texture,
+ float(identity_hash % 628) / 100.0,
+ 0.96 + float(identity_hash % 9) * 0.01,
+ UIPalette.get_quality_color(fish_catch.quality),
+ )
+ fish_node.set_item_state(
+ _fish_selection.is_selected(fish_catch.catch_id),
+ fish_catch.catch_id == _fish_selection.get_focused_id(),
+ fish_catch.is_favorited,
+ )
+ for catch_id: StringName in _fish_nodes.keys():
+ if retained.has(catch_id):
+ continue
+ var removed := _fish_nodes[catch_id] as CoolerFishSpriteType
+ _fish_nodes.erase(catch_id)
+ _release_focus_from(removed, _inventory_tab)
+ removed.queue_free()
+ _layout_cooler_fish(true)
+ _configure_cooler_fish_focus()
+
+
+func _layout_cooler_fish(animate: bool = true) -> void:
+ if not is_node_ready():
+ return
+ var columns: int = 3 if _compact_layout else 9
+ var cell_size := (
+ Vector2(68.0, 58.0) if _compact_layout else Vector2(84.0, 80.0)
+ )
+ var fish_size := (
+ Vector2(52.0, 52.0) if _compact_layout else Vector2(72.0, 72.0)
+ )
+ var visible_field_size := (
+ Vector2(218.0, 124.0)
+ if _compact_layout
+ else Vector2(800.0, 280.0)
+ )
+ var side_margin: float = 2.0 if _compact_layout else 8.0
+ var top_margin: float = 4.0 if _compact_layout else 6.0
+ var required_rows: int = ceili(
+ float(_sorted_catches.size()) / float(columns)
+ ) if not _sorted_catches.is_empty() else 0
+ var required_height: float = (
+ top_margin * 2.0 + float(required_rows) * cell_size.y
+ )
+ var content_size := Vector2(
+ visible_field_size.x,
+ maxf(visible_field_size.y, required_height),
+ )
+ _cooler_host.custom_minimum_size = content_size
+ _fish_field.custom_minimum_size = content_size
+ _cooler_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
+ for index: int in _sorted_catches.size():
+ var fish_catch: FishCatchType = _sorted_catches[index]
+ var fish_node := _fish_nodes.get(
+ fish_catch.catch_id
+ ) as CoolerFishSpriteType
+ if fish_node == null:
+ continue
+ var identity_hash: int = absi(String(fish_catch.catch_id).hash())
+ var column: int = index % columns
+ var row: int = floori(float(index) / float(columns))
+ var offset_span: int = 5 if _compact_layout else 7
+ var offset_center: float = 2.0 if _compact_layout else 3.0
+ var stable_offset := Vector2(
+ float(identity_hash % offset_span) - offset_center,
+ float(floori(float(identity_hash) / 17.0) % offset_span) - offset_center,
+ )
+ var lane_offset: float = 5.0 if row % 2 == 1 else 0.0
+ var target := Vector2(
+ side_margin + float(column) * cell_size.x + lane_offset,
+ top_margin + float(row) * cell_size.y,
+ ) + stable_offset
+ fish_node.custom_minimum_size = fish_size
+ fish_node.size = fish_size
+ var immediate: bool = (
+ not animate or fish_node.has_meta("new_cooler_fish")
+ )
+ fish_node.set_target_position(target, immediate)
+ if fish_node.has_meta("new_cooler_fish"):
+ fish_node.remove_meta("new_cooler_fish")
+
+
+func _configure_cooler_fish_focus() -> void:
+ if _current_section != Section.COOLER:
+ return
+ var controls: Array[CoolerFishSpriteType] = []
+ for fish_catch: FishCatchType in _sorted_catches:
+ var fish_node := _fish_nodes.get(
+ fish_catch.catch_id
+ ) as CoolerFishSpriteType
+ if fish_node != null:
+ controls.append(fish_node)
+ var columns: int = 3 if _compact_layout else 9
+ if controls.is_empty():
+ if not _shop_cooler_context_active:
+ _cooler_sub_tab.focus_neighbor_bottom = NodePath()
+ return
+ var top_control: Control
+ if _shop_cooler_context_active:
+ top_control = _cooler_sort_option
+ else:
+ top_control = _cooler_sub_tab
+ if not _shop_cooler_context_active:
+ _cooler_sub_tab.focus_neighbor_bottom = _cooler_sub_tab.get_path_to(
+ controls.front()
+ )
+ for index: int in controls.size():
+ var control: CoolerFishSpriteType = controls[index]
+ control.focus_neighbor_left = control.get_path_to(
+ controls[maxi(index - 1, 0)]
+ )
+ control.focus_neighbor_right = control.get_path_to(
+ controls[mini(index + 1, controls.size() - 1)]
+ )
+ control.focus_neighbor_top = (
+ control.get_path_to(top_control)
+ if index < columns
+ else control.get_path_to(controls[index - columns])
+ )
+ control.focus_neighbor_bottom = (
+ control.get_path_to(_favorite_bubble)
+ if (
+ not _favorite_bubble.disabled
+ and index + columns >= controls.size()
+ )
+ else control.get_path_to(_sell_all_bubble)
+ if (
+ not _sell_all_bubble.disabled
+ and index + columns >= controls.size()
+ )
+ else control.get_path_to(
+ controls[mini(index + columns, controls.size() - 1)]
+ )
+ )
+ var focused_node := _fish_nodes.get(
+ _fish_selection.get_focused_id()
+ ) as CoolerFishSpriteType
+ if focused_node == null:
+ focused_node = controls.front()
+ _favorite_bubble.focus_neighbor_left = (
+ _favorite_bubble.get_path_to(focused_node)
+ )
+ _favorite_bubble.focus_neighbor_right = (
+ _favorite_bubble.get_path_to(_sell_bubble)
+ )
+ _favorite_bubble.focus_neighbor_top = (
+ _favorite_bubble.get_path_to(focused_node)
+ )
+ _sell_bubble.focus_neighbor_left = (
+ _sell_bubble.get_path_to(_favorite_bubble)
+ )
+ _sell_bubble.focus_neighbor_right = (
+ _sell_bubble.get_path_to(_close_button)
+ )
+ _sell_bubble.focus_neighbor_top = _sell_bubble.get_path_to(focused_node)
+ _sell_bubble.focus_neighbor_bottom = (
+ _sell_bubble.get_path_to(_sell_all_bubble)
+ )
+ _sell_all_bubble.focus_neighbor_left = (
+ _sell_all_bubble.get_path_to(_favorite_bubble)
+ )
+ _sell_all_bubble.focus_neighbor_right = (
+ _sell_all_bubble.get_path_to(_close_button)
+ )
+ _sell_all_bubble.focus_neighbor_top = (
+ _sell_all_bubble.get_path_to(_sell_bubble)
+ )
+
+
func _compare_catches(left: FishCatchType, right: FishCatchType) -> bool:
var comparison: int = 0
match _sort_mode:
@@ -539,164 +3699,6 @@ func _compare_catches(left: FishCatchType, right: FishCatchType) -> bool:
return comparison > 0 if _sort_descending else comparison < 0
-func _create_inventory_card(fish_catch: FishCatchType) -> Button:
- var card := Button.new()
- card.theme_type_variation = &"CardButton"
- card.custom_minimum_size = Vector2(132.0, 118.0)
- card.toggle_mode = true
- var is_selected: bool = _fish_selection.is_selected(fish_catch.catch_id)
- var is_focused: bool = (
- fish_catch.catch_id == _fish_selection.get_focused_id()
- )
- card.button_pressed = is_selected
- card.pressed.connect(_on_catch_card_pressed.bind(fish_catch.catch_id))
- _apply_inventory_card_styles(
- card,
- UIPalette.get_rarity_color(fish_catch.fish.rarity),
- is_selected,
- is_focused
- )
-
- var content := VBoxContainer.new()
- content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT, Control.PRESET_MODE_MINSIZE, 8)
- content.add_theme_constant_override("separation", 3)
- content.mouse_filter = Control.MOUSE_FILTER_IGNORE
- card.add_child(content)
- content.add_child(_create_texture_frame(
- fish_catch.fish.display_texture,
- Vector2(108.0, 62.0)
- ))
- var name_label := Label.new()
- name_label.text = fish_catch.fish.display_name
- name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
- content.add_child(name_label)
- var weight_label := Label.new()
- weight_label.text = "%.2f lb" % fish_catch.weight_lb
- weight_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- weight_label.add_theme_color_override(
- "font_color",
- UIPalette.MUTED_TEXT
- )
- weight_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
- content.add_child(weight_label)
- if fish_catch.is_favorited:
- var favorite_marker := Label.new()
- favorite_marker.text = "★"
- favorite_marker.add_theme_color_override(
- "font_color",
- UIPalette.SECONDARY
- )
- favorite_marker.add_theme_font_size_override("font_size", 18)
- favorite_marker.set_anchors_preset(Control.PRESET_TOP_RIGHT)
- favorite_marker.offset_left = -28.0
- favorite_marker.offset_top = 5.0
- favorite_marker.offset_right = -8.0
- favorite_marker.offset_bottom = 29.0
- favorite_marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- favorite_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
- card.add_child(favorite_marker)
- if is_selected:
- var selected_marker := Label.new()
- selected_marker.text = "✓"
- selected_marker.add_theme_color_override(
- "font_color",
- UIPalette.SUCCESS
- )
- selected_marker.add_theme_font_size_override("font_size", 18)
- selected_marker.set_anchors_preset(Control.PRESET_TOP_LEFT)
- selected_marker.offset_left = 8.0
- selected_marker.offset_top = 5.0
- selected_marker.offset_right = 30.0
- selected_marker.offset_bottom = 29.0
- selected_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
- card.add_child(selected_marker)
- if is_focused:
- var focus_marker := Label.new()
- focus_marker.text = "◆"
- focus_marker.add_theme_color_override(
- "font_color",
- UIPalette.PRIMARY
- )
- focus_marker.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
- focus_marker.offset_left = -26.0
- focus_marker.offset_top = -25.0
- focus_marker.offset_right = -8.0
- focus_marker.offset_bottom = -7.0
- focus_marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- focus_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
- card.add_child(focus_marker)
- return card
-
-
-func _apply_inventory_card_styles(
- card: Button,
- rarity_color: Color,
- is_selected: bool,
- is_focused: bool,
-) -> void:
- card.add_theme_stylebox_override(
- "normal",
- _create_inventory_card_style(
- rarity_color,
- 0,
- is_selected,
- is_focused
- )
- )
- card.add_theme_stylebox_override(
- "hover",
- _create_inventory_card_style(
- rarity_color,
- 1,
- is_selected,
- is_focused
- )
- )
- card.add_theme_stylebox_override(
- "focus",
- _create_inventory_card_style(
- rarity_color,
- 1,
- is_selected,
- true
- )
- )
- card.add_theme_stylebox_override(
- "pressed",
- _create_inventory_card_style(rarity_color, 2, true, is_focused)
- )
-
-
-func _create_inventory_card_style(
- rarity_color: Color,
- state_index: int,
- is_selected: bool,
- is_focused: bool,
-) -> StyleBoxFlat:
- var style := StyleBoxFlat.new()
- match state_index:
- 1:
- style.bg_color = UIPalette.ELEVATED_PANEL.lightened(0.1)
- 2:
- style.bg_color = UIPalette.PRIMARY.darkened(0.48)
- _:
- style.bg_color = (
- UIPalette.PRIMARY.darkened(0.55)
- if is_selected
- else UIPalette.ELEVATED_PANEL
- )
- style.border_color = rarity_color
- var border_width: int = 4 if state_index == 2 or is_focused else 2
- style.set_border_width_all(border_width)
- style.set_corner_radius_all(8)
- style.content_margin_left = 7.0
- style.content_margin_top = 7.0
- style.content_margin_right = 7.0
- style.content_margin_bottom = 7.0
- return style
-
-
func _on_catch_card_pressed(catch_id: StringName) -> void:
if _inventory == null:
return
@@ -708,25 +3710,117 @@ func _on_catch_card_pressed(catch_id: StringName) -> void:
Input.is_key_pressed(KEY_CTRL),
Input.is_key_pressed(KEY_SHIFT)
)
- _transaction_feedback.text = ""
+ _set_transaction_feedback("")
_refresh_inventory()
+func _on_fish_field_gui_input(event: InputEvent) -> void:
+ if (
+ event is InputEventMouseButton
+ and event.button_index == MOUSE_BUTTON_LEFT
+ and event.pressed
+ and not get_viewport().gui_is_dragging()
+ ):
+ _fish_selection.clear()
+ _set_transaction_feedback("")
+ _refresh_inventory()
+
+
+func _close_detail_constellation() -> void:
+ _detail_constellation.visible = _current_section == Section.COOLER
+ _cooler_detail_texture.texture = null
+ _cooler_detail_texture.visible = false
+ _cooler_detail_name.text = "select a fish"
+ _clear_cooler_detail_stats()
+ _set_cooler_selection_summary_empty()
+ _favorite_bubble.disabled = true
+ _favorite_bubble.text = "favorite"
+ _favorite_bubble.persistent_mark = false
+ _sell_bubble.disabled = true
+ _sell_bubble.text = "sell fish"
+ _sell_bubble.persistent_mark = false
+ _favorite_bubble.focus_mode = Control.FOCUS_NONE
+ _sell_bubble.focus_mode = Control.FOCUS_NONE
+ _favorite_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _sell_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _favorite_bubble.refresh_ink_state()
+ _sell_bubble.refresh_ink_state()
+ _refresh_cooler_notepad_action_interactivity()
+ _configure_cooler_fish_focus()
+
+
+func _clear_cooler_detail_stats() -> void:
+ for label: Label in [
+ _cooler_weight_value,
+ _cooler_weight_unit,
+ _cooler_offer_label,
+ ]:
+ label.text = ""
+ _cooler_offer_value.visible = false
+
+
+func _set_cooler_selection_summary_empty() -> void:
+ _cooler_selection_empty.visible = true
+ _cooler_selected_count_row.visible = false
+ _cooler_combined_offer_row.visible = false
+ for label: Label in [
+ _cooler_selected_count_value,
+ _cooler_selected_count_label,
+ _cooler_combined_offer_label,
+ ]:
+ label.visible = false
+ label.text = ""
+ _cooler_combined_offer_value.visible = false
+
+
+func _set_cooler_selection_summary(
+ selected_count: int,
+ offer_value: int,
+) -> void:
+ _cooler_selection_empty.visible = false
+ _cooler_selected_count_row.visible = true
+ _cooler_combined_offer_row.visible = true
+ _cooler_selected_count_value.visible = true
+ _cooler_selected_count_label.visible = true
+ _cooler_combined_offer_label.visible = true
+ _cooler_combined_offer_value.visible = offer_value >= 0
+ _cooler_selected_count_value.text = str(selected_count)
+ _cooler_selected_count_label.text = "fish selected"
+ if offer_value >= 0:
+ _cooler_combined_offer_label.text = "combined offer"
+ _cooler_combined_offer_value.set_amount(offer_value)
+ else:
+ _cooler_combined_offer_label.text = "offer unavailable"
+
+
func _update_inventory_detail(fish_catch: FishCatchType) -> void:
if fish_catch == null:
- _detail_texture.texture = null
- _detail_texture.visible = false
- _detail_name.text = ""
- _detail_data.text = ""
+ _cooler_detail_texture.texture = null
+ _cooler_detail_texture.visible = false
+ _cooler_detail_name.text = "select a fish"
+ _clear_cooler_detail_stats()
_favorite_button.disabled = true
_favorite_button.text = "favorite"
+ _favorite_bubble.disabled = true
+ _favorite_bubble.text = "favorite"
+ _favorite_bubble.persistent_mark = false
+ _detail_constellation.visible = _current_section == Section.COOLER
+ _favorite_bubble.focus_mode = Control.FOCUS_NONE
+ _sell_bubble.focus_mode = Control.FOCUS_NONE
+ _favorite_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _sell_bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _favorite_bubble.refresh_ink_state()
+ _sell_bubble.refresh_ink_state()
+ _refresh_cooler_notepad_action_interactivity()
+ _configure_cooler_fish_focus()
return
- _detail_texture.texture = fish_catch.fish.display_texture
- _detail_texture.visible = fish_catch.fish.display_texture != null
- _detail_name.text = fish_catch.fish.display_name
+ _cooler_detail_texture.texture = fish_catch.fish.display_texture
+ _cooler_detail_texture.visible = fish_catch.fish.display_texture != null
+ _cooler_detail_name.text = fish_catch.fish.display_name
+ var active_buyer: FishBuyerProfileType = _get_active_sale_buyer()
var individual_preview: FishSaleResultType = (
- _sale_service.preview_batch([fish_catch.catch_id], _default_buyer)
- if _sale_service != null
+ _sale_service.preview_batch([fish_catch.catch_id], active_buyer)
+ if _sale_service != null and active_buyer != null
else null
)
var buyer_offer: int = (
@@ -734,25 +3828,68 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void:
if individual_preview != null
else -1
)
- var buyer_offer_text: String = "buyer unavailable"
- if buyer_offer >= 0 and _default_buyer != null:
- buyer_offer_text = "%s offer: $%d" % [
- _default_buyer.display_name,
- buyer_offer,
- ]
- _detail_data.text = "%.2f lb\n%s\nbase value: $%d\n%s" % [
- fish_catch.weight_lb,
+ _cooler_weight_value.text = "%.2f" % fish_catch.weight_lb
+ if (
+ _reservations != null
+ and _reservations.is_fish_reserved(fish_catch.catch_id)
+ ):
+ _cooler_detail_name.text += " • reserved in mail"
+ _cooler_weight_unit.text = "lb • %s • %s" % [
+ FishQualityType.display_name(fish_catch.quality),
fish_catch.fish.get_rarity_name(),
- fish_catch.sale_value,
- buyer_offer_text,
]
+ if buyer_offer >= 0 and active_buyer != null:
+ _cooler_offer_label.text = _get_offer_label(active_buyer)
+ _cooler_offer_value.visible = true
+ _cooler_offer_value.set_amount(buyer_offer)
+ else:
+ _cooler_offer_label.text = "buyer unavailable"
+ _cooler_offer_value.visible = false
_favorite_button.disabled = false
_favorite_button.text = (
"unfavorite" if fish_catch.is_favorited else "favorite"
)
+ _favorite_bubble.disabled = false
+ _favorite_bubble.text = _favorite_button.text
+ _favorite_bubble.persistent_mark = fish_catch.is_favorited
+ _favorite_bubble.refresh_ink_state()
+ _detail_constellation.visible = _current_section == Section.COOLER
+ _cooler_sort_controls.visible = true
+ _refresh_cooler_notepad_action_interactivity()
+ _configure_cooler_fish_focus()
+
+
+func _refresh_cooler_notepad_action_interactivity() -> void:
+ var detail_interactive: bool = (
+ _detail_constellation.visible
+ and _current_section == Section.COOLER
+ and (visible or _shop_cooler_context_active)
+ and not _transitioning
+ and not _page_transitioning
+ and not _sale_confirmation.visible
+ )
+ for action: NotepadInkActionType in [
+ _favorite_bubble,
+ _sell_bubble,
+ _sell_all_bubble,
+ ]:
+ var action_interactive: bool = detail_interactive and not action.disabled
+ if action_interactive:
+ action.focus_mode = Control.FOCUS_ALL
+ action.mouse_filter = Control.MOUSE_FILTER_STOP
+ else:
+ action.focus_mode = Control.FOCUS_NONE
+ action.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ action.refresh_ink_state()
func _update_sale_summary() -> void:
+ var active_buyer: FishBuyerProfileType = _get_active_sale_buyer()
+ var buyer_id: StringName = (
+ active_buyer.id if active_buyer != null else StringName()
+ )
+ _update_sell_all_action(active_buyer, buyer_id)
+ var offer_label: String = _get_offer_label(active_buyer)
var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
var selected_count: int = selected_ids.size()
_sell_button.text = (
@@ -760,16 +3897,64 @@ func _update_sale_summary() -> void:
if selected_count == 1
else "sell %d fish" % selected_count
)
+ _sell_bubble.text = _sell_button.text
if selected_count == 0:
_selection_summary.text = "no fish selected"
+ _selection_status.set_content("selected", "none")
+ _offer_status.set_content(offer_label, "—")
_sell_button.text = "sell fish"
_sell_button.disabled = true
+ _sell_bubble.disabled = true
+ _sell_bubble.text = "sell fish"
+ _sell_bubble.persistent_mark = false
+ _sell_bubble.refresh_ink_state()
+ _set_cooler_selection_summary_empty()
_sale_unavailable.text = ""
_sale_unavailable.visible = false
return
+ if (
+ _sale_in_progress
+ or (
+ _network_sale_service != null
+ and _network_sale_service.is_local_sale_pending()
+ )
+ ):
+ _selection_summary.text = (
+ "1 fish selected"
+ if selected_count == 1
+ else "%d fish selected" % selected_count
+ )
+ _selection_status.set_content("selected", str(selected_count))
+ _offer_status.set_content(offer_label, "pending")
+ _sell_button.disabled = true
+ _sell_bubble.disabled = true
+ _sale_unavailable.text = "Selling…"
+ _sale_unavailable.visible = true
+ return
+ if (
+ _network_sale_service == null
+ or buyer_id.is_empty()
+ or not _network_sale_service.can_request_sale(buyer_id)
+ ):
+ _selection_summary.text = (
+ "1 fish selected"
+ if selected_count == 1
+ else "%d fish selected" % selected_count
+ )
+ _selection_status.set_content("selected", str(selected_count))
+ _offer_status.set_content(offer_label, "unavailable")
+ _sell_button.disabled = true
+ _sell_bubble.disabled = true
+ _sell_bubble.persistent_mark = false
+ _sell_bubble.refresh_ink_state()
+ _sale_unavailable.text = (
+ "Selling is not supported by this server."
+ )
+ _sale_unavailable.visible = true
+ return
var preview: FishSaleResultType = (
- _sale_service.preview_batch(selected_ids, _default_buyer)
- if _sale_service != null
+ _sale_service.preview_batch(selected_ids, active_buyer)
+ if _sale_service != null and active_buyer != null
else null
)
var count_text: String = (
@@ -778,20 +3963,25 @@ func _update_sale_summary() -> void:
else "%d fish selected" % selected_count
)
_selection_summary.text = count_text
+ _selection_status.set_content("selected", str(selected_count))
if (
preview != null
and preview.payout >= 0
- and _default_buyer != null
+ and active_buyer != null
and (
preview.is_success()
or preview.status == FishSaleResultType.Status.FAVORITED
)
):
- _selection_summary.text += "\n%s total offer: $%d" % [
- _default_buyer.display_name,
- preview.payout,
- ]
+ _set_cooler_selection_summary(selected_count, preview.payout)
+ _offer_status.set_currency_amount(offer_label, preview.payout)
+ else:
+ _set_cooler_selection_summary(selected_count, -1)
+ _offer_status.set_content(offer_label, "unavailable")
_sell_button.disabled = preview == null or not preview.is_success()
+ _sell_bubble.disabled = _sell_button.disabled
+ _sell_bubble.persistent_mark = false
+ _sell_bubble.refresh_ink_state()
if preview != null and preview.status == FishSaleResultType.Status.FAVORITED:
_sale_unavailable.text = (
"favorited fish cannot be sold. "
@@ -804,6 +3994,55 @@ func _update_sale_summary() -> void:
_sale_unavailable.visible = not _sale_unavailable.text.is_empty()
+func _update_sell_all_action(
+ active_buyer: FishBuyerProfileType,
+ buyer_id: StringName,
+) -> void:
+ var sell_all_ids: Array[StringName] = _get_sell_all_catch_ids()
+ var sale_available: bool = (
+ not _sale_in_progress
+ and (
+ _network_sale_service == null
+ or not _network_sale_service.is_local_sale_pending()
+ )
+ and _network_sale_service != null
+ and not buyer_id.is_empty()
+ and _network_sale_service.can_request_sale(buyer_id)
+ and _sale_service != null
+ and active_buyer != null
+ and not sell_all_ids.is_empty()
+ )
+ if sale_available:
+ var preview: FishSaleResultType = _sale_service.preview_batch(
+ sell_all_ids,
+ active_buyer,
+ )
+ sale_available = preview.is_success()
+ _sell_all_bubble.text = "sell all fish"
+ _sell_all_bubble.disabled = not sale_available
+ _sell_all_bubble.persistent_mark = false
+ _sell_all_bubble.refresh_ink_state()
+
+
+func _get_sell_all_catch_ids() -> Array[StringName]:
+ var catch_ids: Array[StringName] = []
+ if _inventory == null:
+ return catch_ids
+ for fish_catch: FishCatchType in _inventory.get_all_catches():
+ if (
+ fish_catch == null
+ or not fish_catch.is_valid()
+ or fish_catch.is_favorited
+ or (
+ _reservations != null
+ and _reservations.is_fish_reserved(fish_catch.catch_id)
+ )
+ ):
+ continue
+ catch_ids.append(fish_catch.catch_id)
+ return catch_ids
+
+
func _on_favorite_pressed() -> void:
var focused_id: StringName = _fish_selection.get_focused_id()
if _inventory == null or focused_id.is_empty():
@@ -818,7 +4057,7 @@ func _on_favorite_pressed() -> void:
fish_catch.catch_id,
not fish_catch.is_favorited
):
- _transaction_feedback.text = (
+ _set_transaction_feedback(
"%s %s."
% [
fish_catch.fish.display_name,
@@ -828,20 +4067,53 @@ func _on_favorite_pressed() -> void:
func _on_sell_pressed() -> void:
- var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
+ _begin_sale_confirmation(_fish_selection.get_selected_ids())
+
+
+func _on_sell_all_pressed() -> void:
+ var catch_ids: Array[StringName] = _get_sell_all_catch_ids()
+ if catch_ids.is_empty():
+ _set_transaction_feedback("No sellable fish in the cooler.")
+ return
+ _begin_sale_confirmation(catch_ids)
+
+
+func _begin_sale_confirmation(catch_ids: Array[StringName]) -> void:
+ var active_buyer: FishBuyerProfileType = _get_active_sale_buyer()
+ var buyer_id: StringName = (
+ active_buyer.id if active_buyer != null else StringName()
+ )
+ if (
+ _sale_in_progress
+ or (
+ _network_sale_service != null
+ and _network_sale_service.is_local_sale_pending()
+ )
+ ):
+ _set_transaction_feedback("Selling…")
+ return
+ if (
+ _network_sale_service == null
+ or buyer_id.is_empty()
+ or not _network_sale_service.can_request_sale(buyer_id)
+ ):
+ _set_transaction_feedback(
+ "Selling is not supported by this server."
+ )
+ return
if (
_inventory == null
or _sale_service == null
- or _default_buyer == null
- or selected_ids.is_empty()
+ or active_buyer == null
+ or catch_ids.is_empty()
):
return
var preview: FishSaleResultType = _sale_service.preview_batch(
- selected_ids,
- _default_buyer
+ catch_ids,
+ active_buyer
)
if not preview.is_success():
- _transaction_feedback.text = (
+ _set_transaction_feedback(
"favorited fish cannot be sold. "
+ "remove them from the selection first."
if preview.status == FishSaleResultType.Status.FAVORITED
@@ -849,29 +4121,39 @@ func _on_sell_pressed() -> void:
)
_refresh_inventory()
return
- _confirmation_catch_ids = selected_ids.duplicate()
- _confirmation_buyer = _default_buyer
- _confirmation_buyer_id = _default_buyer.id
+ _confirmation_catch_ids = catch_ids.duplicate()
+ _confirmation_buyer = active_buyer
+ _confirmation_buyer_id = active_buyer.id
_confirmation_generation = _menu_generation
var fish_label: String = "fish"
_confirmation_message.text = (
- "sell %d %s to the %s for $%d?\ncombined base value: $%d"
- % [
- preview.fish_count,
- fish_label,
- _get_buyer_display_group(_default_buyer),
- preview.payout,
- preview.base_value,
- ]
- )
+ "[center]sell %d %s to the %s for %s?\n"
+ + "combined base value: %s[/center]"
+ ) % [
+ preview.fish_count,
+ fish_label,
+ _get_buyer_display_group(active_buyer),
+ CurrencyPresentationType.bbcode_amount(preview.payout, 22),
+ CurrencyPresentationType.bbcode_amount(preview.base_value, 22),
+ ]
_sale_confirmation.visible = true
+ if _shop_cooler_context_active:
+ shop_cooler_modal_changed.emit(true)
+ _set_shell_interactive(false)
+ for button: Button in [_confirm_sale_button, _cancel_sale_button]:
+ button.focus_mode = Control.FOCUS_ALL
+ button.mouse_filter = Control.MOUSE_FILTER_STOP
_confirm_sale_button.disabled = false
- _confirm_sale_button.grab_focus()
+ _cancel_sale_button.grab_focus()
func _on_confirm_sale_pressed() -> void:
if (
_sale_in_progress
+ or _network_sale_service == null
+ or not _network_sale_service.can_request_sale(
+ _confirmation_buyer_id
+ )
or _sale_service == null
or _confirmation_catch_ids.is_empty()
or _confirmation_buyer == null
@@ -883,32 +4165,101 @@ func _on_confirm_sale_pressed() -> void:
var requested_catch_ids: Array[StringName] = (
_confirmation_catch_ids.duplicate()
)
+ var requested_buyer_id: StringName = _confirmation_buyer_id
var transaction_generation: int = _menu_generation
- var transaction_buyer: FishBuyerProfileType = _confirmation_buyer
_confirm_sale_button.disabled = true
- var result: FishSaleResultType = _sale_service.sell_batch(
- requested_catch_ids,
- transaction_buyer
- )
_close_sale_confirmation()
- _sale_in_progress = false
- if transaction_generation != _menu_generation or not visible:
+ var request_id: String = _network_sale_service.request_local_sale(
+ requested_catch_ids,
+ requested_buyer_id,
+ )
+ if request_id.is_empty():
+ _sale_in_progress = false
+ return
+ if (
+ _network_sale_service.is_local_sale_pending()
+ and transaction_generation == _menu_generation
+ and (visible or _shop_cooler_context_active)
+ ):
+ _set_transaction_feedback("Selling…")
+
+
+func _can_use_shared_world_actions() -> bool:
+ return (
+ _network_sale_service != null
+ and _network_sale_service.can_request_sale()
+ )
+
+
+func _on_network_sale_pending(_request_id: String) -> void:
+ _sale_in_progress = true
+ if visible or _shop_cooler_context_active:
+ _set_transaction_feedback("Selling…")
+ _update_sale_summary()
+
+
+func _on_network_sale_finished(
+ _request_id: String,
+ accepted: bool,
+ message: String,
+ catch_ids: Array[StringName],
+ payout: int,
+) -> void:
+ _sale_in_progress = false
+ if accepted:
+ _fish_selection.remove_ids(catch_ids)
+ if not visible and not _shop_cooler_context_active:
return
- _transaction_feedback.text = result.get_message()
- if result.is_success():
- _fish_selection.remove_ids(requested_catch_ids)
_refresh_all()
- _inventory_tab.grab_focus()
+ var feedback_message: String = (
+ "Sale complete • %s received."
+ % CurrencyPresentationType.bbcode_amount(payout, 20)
+ if accepted
+ else message
+ )
+ _set_transaction_feedback(feedback_message)
+ if not accepted:
+ _sale_unavailable.text = feedback_message
+ _sale_unavailable.visible = true
+ if _current_section == Section.COOLER:
+ call_deferred("_restore_inventory_tab_focus")
+
+
+func _restore_inventory_tab_focus() -> void:
+ if _shop_cooler_context_active:
+ _focus_shop_cooler()
+ return
+ if (
+ visible
+ and _current_section == Section.COOLER
+ and _inventory_tab.focus_mode != Control.FOCUS_NONE
+ and _inventory_tab.is_visible_in_tree()
+ ):
+ _inventory_tab.grab_focus()
func _close_sale_confirmation() -> void:
+ var was_visible: bool = _sale_confirmation.visible
_confirmation_catch_ids.clear()
_confirmation_buyer = null
_confirmation_buyer_id = StringName()
_confirmation_generation = -1
_sale_confirmation.visible = false
+ if was_visible and _shop_cooler_context_active:
+ shop_cooler_modal_changed.emit(false)
_confirmation_message.text = ""
_confirm_sale_button.disabled = false
+ for button: Button in [_confirm_sale_button, _cancel_sale_button]:
+ button.focus_mode = Control.FOCUS_NONE
+ button.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ if (
+ was_visible
+ and (visible or _shop_cooler_context_active)
+ and not _transitioning
+ and not _page_transitioning
+ ):
+ _set_shell_interactive(true)
+ _focus_current_section()
func _revalidate_confirmation() -> void:
@@ -922,7 +4273,7 @@ func _revalidate_confirmation() -> void:
or _confirmation_buyer.id != _confirmation_buyer_id
):
_close_sale_confirmation()
- _transaction_feedback.text = "sale selection is no longer available."
+ _set_transaction_feedback("sale selection is no longer available.")
return
var preview: FishSaleResultType = _sale_service.preview_batch(
_confirmation_catch_ids,
@@ -937,7 +4288,7 @@ func _revalidate_confirmation() -> void:
)
):
_close_sale_confirmation()
- _transaction_feedback.text = (
+ _set_transaction_feedback(
"favorited fish cannot be sold. "
+ "remove them from the selection first."
if preview.status == FishSaleResultType.Status.FAVORITED
@@ -957,67 +4308,307 @@ func _get_buyer_display_group(
return "buyer"
+func _set_transaction_feedback(message: String) -> void:
+ _transaction_feedback.text = "[center]%s[/center]" % message
+
+
+func _get_active_sale_buyer() -> FishBuyerProfileType:
+ if _sale_buyer_override != null and _sale_buyer_override.is_valid():
+ return _sale_buyer_override
+ return _default_buyer
+
+
+func _get_offer_label(buyer: FishBuyerProfileType) -> String:
+ if buyer == null:
+ return "buyer offer"
+ if buyer.id == MAIN_SHOP_BUYER_ID:
+ return "shop offer"
+ if buyer.id == NetworkSaleService.PELICAN_BUYER_ID:
+ return "pelican offer"
+ return "buyer offer"
+
+
func _refresh_logbook() -> void:
if not is_node_ready():
return
- _clear_container(_logbook_grid)
var valid_species: Array[FishDataType] = []
if _catalog != null:
for fish: FishDataType in _catalog.candidates:
if fish != null and not fish.id.is_empty():
valid_species.append(fish)
+ _logbook_species = valid_species
_logbook_empty.visible = valid_species.is_empty()
_logbook_empty.text = (
"no fish catalog configured."
if valid_species.is_empty()
else "no species discovered yet."
)
- if not valid_species.is_empty() and _collection_log != null:
- var any_discovered: bool = false
- for fish: FishDataType in valid_species:
- if _collection_log.has_discovered(fish.id):
- any_discovered = true
- break
- _logbook_empty.visible = not any_discovered
- for fish: FishDataType in valid_species:
- _logbook_grid.add_child(_create_logbook_card(fish))
+ _logbook_empty_state.visible = valid_species.is_empty()
+ _logbook_empty_state.text = (
+ "no fish catalog configured"
+ if valid_species.is_empty()
+ else ""
+ )
+ _refresh_logbook_page(false)
-func _create_logbook_card(fish: FishDataType) -> Control:
+func _refresh_logbook_page(keep_transition_state: bool = true) -> void:
+ if not is_node_ready():
+ return
+ var entries_per_spread: int = 2 if _compact_layout else 4
+ _logbook_page_count = maxi(
+ 1,
+ ceili(float(_logbook_species.size()) / float(entries_per_spread)),
+ )
+ _logbook_current_page = clampi(
+ _logbook_current_page,
+ 0,
+ _logbook_page_count - 1,
+ )
+ _clear_logbook_entries()
+ var start_index: int = _logbook_current_page * entries_per_spread
+ var end_index: int = mini(
+ start_index + entries_per_spread,
+ _logbook_species.size(),
+ )
+ var visible_species: Array[FishDataType] = []
+ for index: int in range(start_index, end_index):
+ visible_species.append(_logbook_species[index])
+ if _compact_layout:
+ for fish: FishDataType in visible_species:
+ _left_entry_field.add_child(_create_logbook_entry(fish))
+ else:
+ var left_count: int = mini(2, visible_species.size())
+ for index: int in visible_species.size():
+ var destination: BoxContainer = (
+ _left_entry_field
+ if index < left_count
+ else _right_entry_field
+ )
+ destination.add_child(_create_logbook_entry(
+ visible_species[index]
+ ))
+ _logbook_page_status.set_content(
+ "pages",
+ "%d / %d" % [_logbook_current_page + 1, _logbook_page_count],
+ )
+ _logbook_page_status.visible = _logbook_page_count > 1
+ _update_logbook_page_control_state(
+ keep_transition_state
+ and visible
+ and _current_section == Section.LOGBOOK
+ and not _transitioning
+ and not _page_transitioning
+ and not _logbook_page_transitioning
+ )
+ _configure_logbook_focus()
+
+
+func _create_logbook_entry(fish: FishDataType) -> LogbookEntryType:
var discovered: bool = (
_collection_log != null
and _collection_log.has_discovered(fish.id)
)
- var card := PanelContainer.new()
- card.custom_minimum_size = Vector2(158.0, 154.0)
- var content := VBoxContainer.new()
- content.add_theme_constant_override("separation", 4)
- card.add_child(content)
- var texture_frame: TextureRect = _create_texture_frame(
+ var entry := LogbookEntryScene.instantiate() as LogbookEntryType
+ entry.set_meta("fish_id", fish.id)
+ entry.configure(
fish.display_texture,
- Vector2(142.0, 82.0)
+ fish.display_name,
+ fish.get_rarity_name(),
+ _inventory.get_count(fish.id) if _inventory != null else 0,
+ discovered,
)
- if not discovered:
- texture_frame.modulate = UIPalette.PANEL
- content.add_child(texture_frame)
- var name_label := Label.new()
- name_label.text = fish.display_name if discovered else "???"
- name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- content.add_child(name_label)
- var details := Label.new()
- if discovered:
- var owned_count: int = (
- _inventory.get_count(fish.id) if _inventory != null else 0
+ entry.apply_compact_layout(_compact_layout)
+ return entry
+
+
+func _clear_logbook_entries() -> void:
+ for field: BoxContainer in [_left_entry_field, _right_entry_field]:
+ for child: Node in field.get_children():
+ _release_focus_from(child, _logbook_tab)
+ field.remove_child(child)
+ child.queue_free()
+
+
+func _release_focus_from(node: Node, fallback: Control) -> void:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if (
+ focus_owner == null
+ or (
+ focus_owner != node
+ and not node.is_ancestor_of(focus_owner)
+ )
+ ):
+ return
+ var current_viewport: Viewport = get_viewport()
+ if current_viewport != null:
+ current_viewport.gui_release_focus()
+ if (
+ visible
+ and not _transitioning
+ and not _page_transitioning
+ and fallback != null
+ and fallback.focus_mode != Control.FOCUS_NONE
+ ):
+ fallback.call_deferred("grab_focus")
+
+
+func _request_logbook_page(direction: int) -> void:
+ if (
+ _current_section != Section.LOGBOOK
+ or _logbook_page_transitioning
+ or _transitioning
+ or _page_transitioning
+ ):
+ return
+ var target_page: int = clampi(
+ _logbook_current_page + direction,
+ 0,
+ _logbook_page_count - 1,
+ )
+ if target_page == _logbook_current_page:
+ return
+ _logbook_page_generation += 1
+ _logbook_page_transitioning = true
+ _update_logbook_page_control_state(false)
+ var generation: int = _logbook_page_generation
+ var travel: float = -12.0 if direction > 0 else 12.0
+ _logbook_page_tween = create_tween()
+ _logbook_page_tween.tween_property(
+ _book_spread,
+ "modulate:a",
+ 0.0,
+ LOGBOOK_PAGE_DURATION * 0.5,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _logbook_page_tween.parallel().tween_property(
+ _book_spread,
+ "position:x",
+ travel,
+ LOGBOOK_PAGE_DURATION * 0.5,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _logbook_page_tween.tween_callback(
+ _swap_logbook_page.bind(target_page, travel, generation)
+ )
+ _logbook_page_tween.tween_property(
+ _book_spread,
+ "modulate:a",
+ 1.0,
+ LOGBOOK_PAGE_DURATION * 0.5,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _logbook_page_tween.parallel().tween_property(
+ _book_spread,
+ "position:x",
+ 0.0,
+ LOGBOOK_PAGE_DURATION * 0.5,
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _logbook_page_tween.finished.connect(
+ _finish_logbook_page_transition.bind(generation),
+ CONNECT_ONE_SHOT,
+ )
+
+
+func _swap_logbook_page(
+ target_page: int,
+ travel: float,
+ generation: int,
+) -> void:
+ if (
+ generation != _logbook_page_generation
+ or not visible
+ or _current_section != Section.LOGBOOK
+ ):
+ return
+ _logbook_current_page = target_page
+ _refresh_logbook_page(false)
+ _book_spread.position.x = -travel
+ _book_spread.modulate.a = 0.0
+
+
+func _finish_logbook_page_transition(generation: int) -> void:
+ if generation != _logbook_page_generation:
+ return
+ _logbook_page_tween = null
+ _logbook_page_transitioning = false
+ _book_spread.position.x = 0.0
+ _book_spread.modulate.a = 1.0
+ _update_logbook_page_control_state(
+ visible and _current_section == Section.LOGBOOK
+ )
+ _configure_logbook_focus()
+ if _logbook_next.visible and not _logbook_next.disabled:
+ _logbook_next.grab_focus()
+ elif _logbook_previous.visible and not _logbook_previous.disabled:
+ _logbook_previous.grab_focus()
+
+
+func _cancel_logbook_page_transition(reset_visuals: bool) -> void:
+ _logbook_page_generation += 1
+ if _logbook_page_tween != null:
+ _logbook_page_tween.kill()
+ _logbook_page_tween = null
+ _logbook_page_transitioning = false
+ if reset_visuals and is_instance_valid(_book_spread):
+ _book_spread.position.x = 0.0
+ _book_spread.modulate.a = 1.0
+
+
+func _update_logbook_page_control_state(interactive: bool) -> void:
+ var has_multiple_pages: bool = _logbook_page_count > 1
+ _logbook_previous.visible = has_multiple_pages
+ _logbook_next.visible = has_multiple_pages
+ _logbook_previous.disabled = (
+ not interactive or _logbook_current_page <= 0
+ )
+ _logbook_next.disabled = (
+ not interactive
+ or _logbook_current_page >= _logbook_page_count - 1
+ )
+ for control: BubbleButtonType in [_logbook_previous, _logbook_next]:
+ control.focus_mode = (
+ Control.FOCUS_ALL
+ if control.visible and not control.disabled
+ else Control.FOCUS_NONE
+ )
+ control.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if control.visible and not control.disabled
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _configure_logbook_focus() -> void:
+ if _current_section != Section.LOGBOOK:
+ return
+ _logbook_tab.focus_neighbor_bottom = _logbook_tab.focus_neighbor_right
+ if _logbook_page_count <= 1:
+ return
+ var first_control: BubbleButtonType = (
+ _logbook_previous
+ if not _logbook_previous.disabled
+ else _logbook_next
+ )
+ _logbook_tab.focus_neighbor_bottom = _logbook_tab.get_path_to(
+ first_control
+ )
+ for control: BubbleButtonType in [_logbook_previous, _logbook_next]:
+ control.focus_neighbor_top = control.get_path_to(_logbook_tab)
+ control.focus_neighbor_bottom = control.focus_neighbor_top
+ control.focus_neighbor_left = control.get_path_to(
+ _logbook_previous
+ )
+ control.focus_neighbor_right = control.get_path_to(_logbook_next)
+
+
+func _advance_logbook_page_controls(delta: float) -> void:
+ for control: BubbleButtonType in [_logbook_previous, _logbook_next]:
+ if not control.visible:
+ continue
+ control.advance_emphasis(delta)
+ control.apply_presentation(
+ control.calculate_target(_motion_elapsed, 1.0, 1.0),
+ control.calculate_visual_scale(_motion_elapsed, 1.0),
+ minf(1.0, delta * 10.0),
)
- details.text = "%s\nowned: %d" % [
- fish.get_rarity_name(),
- owned_count,
- ]
- else:
- details.text = "undiscovered"
- details.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- content.add_child(details)
- return card
func _create_texture_frame(
diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn
index 3dccbbe..e20077c 100644
--- a/ui/player_menu.tscn
+++ b/ui/player_menu.tscn
@@ -1,7 +1,47 @@
-[gd_scene load_steps=3 format=3]
+[gd_scene load_steps=34 format=3]
[ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_content_shell.gd" id="3_shell"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="4_profile"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="5_cluster"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_button.gd" id="6_bubble"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_status_bubble.tscn" id="7_status"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_information_panel.gd" id="8_info"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_information_panel.tscn" id="9_info_scene"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_status_bubble.tscn" id="10_status_scene"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_button.gd" id="11_bubble"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/notepad_ink_action.tscn" id="12_ink_action"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/notepad_ink_choice.tscn" id="13_ink_choice"]
+[ext_resource type="Shader" path="res://ui/components/bubble_menu/cooler_water.gdshader" id="14_water"]
+[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble1.png" id="15_bubble1"]
+[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble2.png" id="16_bubble2"]
+[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble3.png" id="17_bubble3"]
+[ext_resource type="Script" path="res://ui/mail_page.gd" id="18_mail_page"]
+[ext_resource type="PackedScene" path="res://ui/profile_page.tscn" id="19_profile_page"]
+[ext_resource type="Script" path="res://ui/players_page.gd" id="20_players_page"]
+[ext_resource type="PackedScene" path="res://ui/logbook_page.tscn" id="21_logbook_page"]
+[ext_resource type="Script" path="res://ui/components/inventory_notepad.gd" id="22_inventory_notepad"]
+[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="23_organizer_tab"]
+[ext_resource type="PackedScene" path="res://ui/the_net_page.tscn" id="24_the_net_page"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/inventory_simple.png" id="25_inventory_simple"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/logbook_icon_simple.png" id="26_logbook_simple"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/fishnet_simple.png" id="27_fishnet_simple"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/mail_simple.png" id="28_mail_simple"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/profile_simple.png" id="29_profile_simple"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/online_simple.png" id="30_online_simple"]
+[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/close.png" id="31_close"]
+[ext_resource type="PackedScene" path="res://ui/components/currency_amount.tscn" id="32_currency"]
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"]
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_cooler_water"]
+shader = ExtResource("14_water")
+shader_parameter/decorative_bubble_1 = ExtResource("15_bubble1")
+shader_parameter/decorative_bubble_2 = ExtResource("16_bubble2")
+shader_parameter/decorative_bubble_3 = ExtResource("17_bubble3")
+shader_parameter/corner_radius = 29.0
+shader_parameter/corner_softness = 0.75
[node name="PlayerMenu" type="Control"]
visible = false
@@ -16,8 +56,7 @@ mouse_filter = 2
theme = ExtResource("2_theme")
script = ExtResource("1_menu")
-[node name="Dimmer" type="ColorRect" parent="."]
-z_index = -20
+[node name="ResponsivePlayerMenuStage" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -25,89 +64,937 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
-color = Color(0.015, 0.02, 0.03, 0.72)
-[node name="MenuPanel" type="PanelContainer" parent="."]
+[node name="PlayerMenuPresentationScaleRoot" type="Control" parent="ResponsivePlayerMenuStage"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 2
+
+[node name="InventorySubTabs" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+visible = false
+z_index = 40
+layout_mode = 0
+offset_left = 150.0
+offset_top = 136.0
+offset_right = 534.0
+offset_bottom = 174.0
+mouse_filter = 1
+theme_override_constants/separation = 6
+
+[node name="CoolerSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(124, 38)
+layout_mode = 2
+toggle_mode = true
+button_group = null
+text = "Cooler"
+script = ExtResource("23_organizer_tab")
+
+[node name="BagSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(124, 38)
+layout_mode = 2
+toggle_mode = true
+button_group = null
+text = "Equipment"
+script = ExtResource("23_organizer_tab")
+palette_index = 2
+
+[node name="TackleSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(124, 38)
+layout_mode = 2
+toggle_mode = true
+button_group = null
+text = "Tackle"
+script = ExtResource("23_organizer_tab")
+palette_index = 1
+
+[node name="CoolerPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+
+[node name="CoolerOuterWall" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage"]
+unique_name_in_owner = true
+z_index = 50
+layout_mode = 0
+offset_left = 54.0
+offset_top = 126.0
+offset_right = 936.0
+offset_bottom = 602.0
+
+[node name="CoolerWallMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall"]
+layout_mode = 2
+theme_override_constants/margin_left = 13
+theme_override_constants/margin_top = 13
+theme_override_constants/margin_right = 13
+theme_override_constants/margin_bottom = 13
+
+[node name="CoolerInnerLiner" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall/CoolerWallMargin"]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="CoolerInnerMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall/CoolerWallMargin/CoolerInnerLiner"]
+layout_mode = 2
+theme_override_constants/margin_left = 16
+theme_override_constants/margin_top = 16
+theme_override_constants/margin_right = 16
+theme_override_constants/margin_bottom = 16
+
+[node name="WaterSurface" type="ColorRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall/CoolerWallMargin/CoolerInnerLiner/CoolerInnerMargin"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+material = SubResource("ShaderMaterial_cooler_water")
+color = Color(1, 1, 1, 1)
+
+[node name="CoolerScroll" type="ScrollContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall/CoolerWallMargin/CoolerInnerLiner/CoolerInnerMargin"]
+unique_name_in_owner = true
+layout_mode = 2
+horizontal_scroll_mode = 0
+
+[node name="CoolerHost" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall/CoolerWallMargin/CoolerInnerLiner/CoolerInnerMargin/CoolerScroll"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(784, 394)
+layout_mode = 2
+mouse_filter = 1
+
+[node name="FishField" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/CoolerOuterWall/CoolerWallMargin/CoolerInnerLiner/CoolerInnerMargin/CoolerScroll/CoolerHost"]
+unique_name_in_owner = true
layout_mode = 1
-anchor_left = 0.07
-anchor_top = 0.055
-anchor_right = 0.93
-anchor_bottom = 0.72
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
+mouse_filter = 1
-[node name="Margin" type="MarginContainer" parent="MenuPanel"]
+[node name="DetailConstellation" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 942.0
+offset_top = 126.0
+offset_right = 1230.0
+offset_bottom = 602.0
+mouse_filter = 1
+
+[node name="DetailBubble" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("22_inventory_notepad")
+title_text = "cooler notes"
+inset_content = false
+
+[node name="BindingDecoration" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 28.0
+offset_top = 4.0
+offset_right = 260.0
+offset_bottom = 31.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.31, 0.27, 0.2, 0.8)
+theme_override_font_sizes/font_size = 20
+text = "● ● ● ●"
+horizontal_alignment = 1
+
+[node name="NotepadTitle" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 20.0
+offset_top = 31.0
+offset_right = 268.0
+offset_bottom = 59.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.16, 0.16, 0.14, 1)
+theme_override_font_sizes/font_size = 19
+text = "cooler notes"
+horizontal_alignment = 1
+
+[node name="NotepadWalletValue" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation" instance=ExtResource("32_currency")]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 18.0
+offset_top = 61.0
+offset_right = 138.0
+offset_bottom = 84.0
+mouse_filter = 2
+icon_size = 14.0
+
+[node name="NotepadCapacityValue" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 146.0
+offset_top = 61.0
+offset_right = 270.0
+offset_bottom = 84.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.16, 0.16, 0.14, 1)
+theme_override_font_sizes/font_size = 14
+text = "0 / 12"
+horizontal_alignment = 2
+
+[node name="NotepadRule" type="ColorRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 18.0
+offset_top = 87.0
+offset_right = 270.0
+offset_bottom = 89.0
+mouse_filter = 2
+color = Color(0.44, 0.36, 0.24, 0.34)
+
+[node name="CoolerSortControls" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 18.0
+offset_top = 98.0
+offset_right = 270.0
+offset_bottom = 136.0
+theme_override_constants/separation = 6
+
+[node name="CoolerSortLabel" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSortControls"]
+visible = false
+layout_mode = 2
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
+text = "sort"
+vertical_alignment = 1
+
+[node name="CoolerSortOption" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSortControls" instance=ExtResource("13_ink_choice")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(123, 38)
+layout_mode = 2
+clip_text = true
+fit_to_longest_item = false
+outline_padding = 1.5
+outline_outsets = Vector4(6, 2, 2, 2)
+
+[node name="CoolerSortDirection" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSortControls" instance=ExtResource("12_ink_action")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(123, 38)
+layout_mode = 2
+clip_text = true
+
+[node name="CoolerDetailTexture" type="TextureRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 80.0
+offset_top = 137.0
+offset_right = 208.0
+offset_bottom = 237.0
+mouse_filter = 2
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="CoolerDetailName" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 18.0
+offset_top = 241.0
+offset_right = 270.0
+offset_bottom = 273.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.16, 0.16, 0.14, 1)
+theme_override_font_sizes/font_size = 25
+text = "select a fish"
+horizontal_alignment = 1
+text_overrun_behavior = 3
+
+[node name="CoolerDetailStats" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 18.0
+offset_top = 274.0
+offset_right = 270.0
+offset_bottom = 339.0
+mouse_filter = 2
+
+[node name="CoolerWeightRow" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerDetailStats"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 252.0
+offset_bottom = 24.0
+mouse_filter = 2
+theme_override_constants/separation = 2
+alignment = 1
+
+[node name="CoolerWeightValue" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerDetailStats/CoolerWeightRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.16, 0.16, 0.14, 1)
+theme_override_font_sizes/font_size = 12
+
+[node name="CoolerWeightUnit" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerDetailStats/CoolerWeightRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.16, 0.16, 0.14, 1)
+theme_override_font_sizes/font_size = 14
+text = "lb"
+
+[node name="CoolerOfferRow" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerDetailStats"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_top = 27.0
+offset_right = 252.0
+offset_bottom = 57.0
+mouse_filter = 2
+theme_override_constants/separation = 4
+alignment = 1
+
+[node name="CoolerOfferLabel" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerDetailStats/CoolerOfferRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.16, 0.16, 0.14, 1)
+theme_override_font_sizes/font_size = 18
+
+[node name="CoolerOfferValue" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerDetailStats/CoolerOfferRow" instance=ExtResource("32_currency")]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+icon_size = 15.0
+
+[node name="CoolerSelectionSummary" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 18.0
+offset_top = 346.0
+offset_right = 270.0
+offset_bottom = 386.0
+mouse_filter = 2
+
+[node name="CoolerSelectionEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.2, 0.18, 0.15, 0.86)
+theme_override_font_sizes/font_size = 20
+text = "no fish selected"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="CoolerSelectedCountRow" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_right = 252.0
+offset_bottom = 20.0
+mouse_filter = 2
+theme_override_constants/separation = 4
+alignment = 1
+
+[node name="CoolerSelectedCountValue" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary/CoolerSelectedCountRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.2, 0.18, 0.15, 0.86)
+theme_override_font_sizes/font_size = 14
+
+[node name="CoolerSelectedCountLabel" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary/CoolerSelectedCountRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.2, 0.18, 0.15, 0.86)
+theme_override_font_sizes/font_size = 18
+text = "fish selected"
+
+[node name="CoolerCombinedOfferRow" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_top = 20.0
+offset_right = 252.0
+offset_bottom = 40.0
+mouse_filter = 2
+theme_override_constants/separation = 4
+alignment = 1
+
+[node name="CoolerCombinedOfferLabel" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary/CoolerCombinedOfferRow"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.2, 0.18, 0.15, 0.86)
+theme_override_font_sizes/font_size = 18
+text = "combined offer"
+
+[node name="CoolerCombinedOfferValue" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation/CoolerSelectionSummary/CoolerCombinedOfferRow" instance=ExtResource("32_currency")]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+icon_size = 14.0
+
+[node name="FavoriteBubble" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation" instance=ExtResource("12_ink_action")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(98, 78)
+layout_mode = 0
+offset_left = 36.0
+offset_top = 386.0
+offset_right = 134.0
+offset_bottom = 464.0
+text = "favorite"
+allow_persistent_mark = true
+
+[node name="SellBubble" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation" instance=ExtResource("12_ink_action")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(108, 82)
+layout_mode = 0
+offset_left = 148.0
+offset_top = 382.0
+offset_right = 256.0
+offset_bottom = 464.0
+text = "sell fish"
+
+[node name="SellAllBubble" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/CoolerPage/DetailConstellation" instance=ExtResource("12_ink_action")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(122, 44)
+layout_mode = 0
+offset_left = 78.0
+offset_top = 426.0
+offset_right = 200.0
+offset_bottom = 470.0
+text = "sell all fish"
+
+[node name="BagPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+
+[node name="BagFilterTabs" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"]
+unique_name_in_owner = true
+z_index = 55
+layout_mode = 0
+offset_left = 122.0
+offset_top = 166.0
+offset_right = 442.0
+offset_bottom = 206.0
+theme_override_constants/separation = 8
+
+[node name="EquipmentFilter" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagFilterTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(150, 40)
+layout_mode = 2
+toggle_mode = true
+text = "Equipment"
+
+[node name="ConsumablesFilter" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagFilterTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(150, 40)
+layout_mode = 2
+toggle_mode = true
+text = "Consumables"
+
+[node name="BagOuterWall" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"]
+unique_name_in_owner = true
+z_index = 50
+layout_mode = 0
+offset_left = 122.0
+offset_top = 132.0
+offset_right = 1018.0
+offset_bottom = 574.0
+
+[node name="BagOuterMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall"]
+layout_mode = 2
+theme_override_constants/margin_left = 14
+theme_override_constants/margin_top = 16
+theme_override_constants/margin_right = 14
+theme_override_constants/margin_bottom = 12
+
+[node name="BagInnerLiner" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin"]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="BagInnerMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner"]
+layout_mode = 2
+theme_override_constants/margin_left = 18
+theme_override_constants/margin_top = 58
+theme_override_constants/margin_right = 18
+theme_override_constants/margin_bottom = 18
+
+[node name="BagScroll" type="ScrollContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner/BagInnerMargin"]
+unique_name_in_owner = true
+layout_mode = 2
+horizontal_scroll_mode = 0
+
+[node name="BagHost" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner/BagInnerMargin/BagScroll"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(788, 358)
+layout_mode = 2
+mouse_filter = 1
+
+[node name="BagItemField" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner/BagInnerMargin/BagScroll/BagHost"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
+
+[node name="BagEmptyState" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner/BagInnerMargin/BagScroll/BagHost"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 24
+text = "your bag is empty"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="BagDetailConstellation" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 984.0
+offset_top = 266.0
+offset_right = 1256.0
+offset_bottom = 570.0
+mouse_filter = 2
+
+[node name="BagDetailBubble" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("22_inventory_notepad")
+title_text = "bag notes"
+
+[node name="BagDetailLayout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="BagSpriteDetailTexture" type="TextureRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 72)
+layout_mode = 2
+texture_filter = 1
+expand_mode = 1
+stretch_mode = 5
+
+[node name="BagSpriteDetailName" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_colors/font_color = Color(0.22, 0.15, 0.08, 1)
+theme_override_font_sizes/font_size = 22
+horizontal_alignment = 1
+
+[node name="BagSpriteDetailData" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_colors/font_color = Color(0.22, 0.15, 0.08, 1)
+theme_override_font_sizes/font_size = 15
+horizontal_alignment = 1
+autowrap_mode = 2
+
+[node name="TackleBoxPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+
+[node name="TackleMainPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"]
+unique_name_in_owner = true
+z_index = 50
+layout_mode = 0
+offset_left = 54.0
+offset_top = 166.0
+offset_right = 936.0
+offset_bottom = 602.0
+
+[node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 24
+theme_override_constants/margin_top = 20
+theme_override_constants/margin_right = 24
+theme_override_constants/margin_bottom = 20
+
+[node name="Layout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 14
+
+[node name="TackleFilterTabs" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="BaitFilter" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleFilterTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(150, 40)
+layout_mode = 2
+toggle_mode = true
+text = "Bait"
+
+[node name="LuresFilter" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleFilterTabs"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(150, 40)
+layout_mode = 2
+toggle_mode = true
+text = "Lures"
+
+[node name="TackleScroll" type="ScrollContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+horizontal_scroll_mode = 0
+
+[node name="TackleItemList" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleScroll"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/separation = 8
+
+[node name="TackleEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+text = "No bait collected."
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="TackleDetailPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 952.0
+offset_top = 166.0
+offset_right = 1230.0
+offset_bottom = 602.0
+script = ExtResource("22_inventory_notepad")
+title_text = "tackle box notes"
+
+[node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleDetailPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 20
+theme_override_constants/margin_top = 20
+theme_override_constants/margin_right = 20
+theme_override_constants/margin_bottom = 20
+
+[node name="Layout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleDetailPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 12
+
+[node name="TackleDetailText" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleDetailPanel/Margin/Layout"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Select bait or a lure for details."
+autowrap_mode = 2
+
+[node name="TackleEquipButton" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleDetailPanel/Margin/Layout" instance=ExtResource("12_ink_action")]
+unique_name_in_owner = true
+layout_mode = 2
+custom_minimum_size = Vector2(150, 68)
+size_flags_horizontal = 4
+text = "equip worms"
+allow_persistent_mark = true
+
+[node name="LogbookPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+
+[node name="CatalogLogbook" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage" instance=ExtResource("21_logbook_page")]
+unique_name_in_owner = true
+
+[node name="TheNetPage" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot" instance=ExtResource("24_the_net_page")]
+unique_name_in_owner = true
+visible = false
+
+[node name="MailPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+script = ExtResource("18_mail_page")
+
+[node name="ProfilePage" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot" instance=ExtResource("19_profile_page")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+
+[node name="PlayersPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+visible = false
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+script = ExtResource("20_players_page")
+
+[node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 58.0
+offset_top = 128.0
+offset_right = 1222.0
+offset_bottom = 588.0
+mouse_filter = 2
+
+[node name="BookOuterMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 12
+theme_override_constants/margin_top = 12
+theme_override_constants/margin_right = 12
+theme_override_constants/margin_bottom = 12
+
+[node name="BookSpread" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/separation = 0
+
+[node name="LeftPage" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+mouse_filter = 2
+
+[node name="LeftPageMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/LeftPage"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 22
+theme_override_constants/margin_top = 18
+theme_override_constants/margin_right = 24
+theme_override_constants/margin_bottom = 18
+
+[node name="LeftPageLayout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/LeftPage/LeftPageMargin"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/separation = 8
+
+[node name="LeftHeading" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/LeftPage/LeftPageMargin/LeftPageLayout"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.22, 0.16, 0.09, 1)
+theme_override_font_sizes/font_size = 20
+text = "field notes"
+horizontal_alignment = 1
+
+[node name="LeftEntryField" type="BoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/LeftPage/LeftPageMargin/LeftPageLayout"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+mouse_filter = 2
+theme_override_constants/separation = 10
+vertical = true
+
+[node name="BookGutter" type="ColorRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(18, 0)
+layout_mode = 2
+mouse_filter = 2
+color = Color(0.44, 0.34, 0.21, 0.52)
+
+[node name="RightPage" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+mouse_filter = 2
+
+[node name="RightPageMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/RightPage"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 24
+theme_override_constants/margin_top = 18
+theme_override_constants/margin_right = 22
+theme_override_constants/margin_bottom = 18
+
+[node name="RightPageLayout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/RightPage/RightPageMargin"]
+layout_mode = 2
+mouse_filter = 2
+theme_override_constants/separation = 8
+
+[node name="RightHeading" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/RightPage/RightPageMargin/RightPageLayout"]
+unique_name_in_owner = true
+layout_mode = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.22, 0.16, 0.09, 1)
+theme_override_font_sizes/font_size = 20
+text = "catch record"
+horizontal_alignment = 1
+
+[node name="RightEntryField" type="BoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage/BookBacking/BookOuterMargin/BookSpread/RightPage/RightPageMargin/RightPageLayout"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 3
+mouse_filter = 2
+theme_override_constants/separation = 10
+vertical = true
+
+[node name="LogbookEmptyState" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 402.0
+offset_top = 314.0
+offset_right = 878.0
+offset_bottom = 366.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.28, 0.21, 0.13, 0.82)
+theme_override_font_sizes/font_size = 20
+text = "no fish catalog configured"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="LogbookPrevious" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(76, 72)
+layout_mode = 0
+offset_left = 78.0
+offset_top = 494.0
+offset_right = 154.0
+offset_bottom = 566.0
+text = "previous"
+script = ExtResource("11_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(76, 72)
+desktop_anchor = Vector2(116, 530)
+compact_anchor = Vector2(58, 332)
+compact_minimum_size = Vector2(66, 62)
+minimum_font_size = 11
+maximum_font_size = 15
+horizontal_amplitude = 0.0
+vertical_amplitude = 0.5
+deformation_amplitude = 0.006
+
+[node name="LogbookNext" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"]
+unique_name_in_owner = true
+visible = false
+custom_minimum_size = Vector2(76, 72)
+layout_mode = 0
+offset_left = 1126.0
+offset_top = 494.0
+offset_right = 1202.0
+offset_bottom = 566.0
+text = "next"
+script = ExtResource("11_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(76, 72)
+desktop_anchor = Vector2(1164, 530)
+compact_anchor = Vector2(582, 332)
+compact_minimum_size = Vector2(66, 62)
+minimum_font_size = 11
+maximum_font_size = 15
+horizontal_amplitude = 0.0
+vertical_amplitude = 0.5
+motion_phase = 2.2
+deformation_amplitude = 0.006
+
+[node name="LogbookPageStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage" instance=ExtResource("10_status_scene")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 0
+offset_left = 555.0
+offset_top = 524.0
+offset_right = 725.0
+offset_bottom = 580.0
+mouse_filter = 2
+
+[node name="MenuPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 42.0
+offset_top = 104.0
+offset_right = 1238.0
+offset_bottom = 582.0
+script = ExtResource("3_shell")
+profile = ExtResource("4_profile")
+content_margin = 22.0
+corner_radius = 74
+
+[node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel"]
layout_mode = 2
theme_override_constants/margin_left = 14
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 14
theme_override_constants/margin_bottom = 12
-[node name="Layout" type="VBoxContainer" parent="MenuPanel/Margin"]
+[node name="Layout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin"]
layout_mode = 2
theme_override_constants/separation = 8
-[node name="Header" type="HBoxContainer" parent="MenuPanel/Margin/Layout"]
+[node name="Header" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout"]
+unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
-[node name="Title" type="Label" parent="MenuPanel/Margin/Layout/Header"]
+[node name="WalletHeading" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Header"]
+layout_mode = 2
+text = "wallet"
+theme_override_colors/font_color = Color(1, 0.82, 0.4, 1)
+
+[node name="WalletBalance" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Header" instance=ExtResource("32_currency")]
+unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
-theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
-theme_override_font_sizes/font_size = 22
-text = "player menu"
+alignment = 0
-[node name="WalletBalance" type="Label" parent="MenuPanel/Margin/Layout/Header"]
+[node name="Separator" type="HSeparator" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout"]
unique_name_in_owner = true
layout_mode = 2
-text = "wallet: $0"
-theme_override_colors/font_color = Color(1, 0.82, 0.4, 1)
-[node name="InventoryTab" type="Button" parent="MenuPanel/Margin/Layout/Header"]
-unique_name_in_owner = true
-layout_mode = 2
-toggle_mode = true
-text = "cooler"
-custom_minimum_size = Vector2(88, 30)
-
-[node name="BagTab" type="Button" parent="MenuPanel/Margin/Layout/Header"]
-unique_name_in_owner = true
-layout_mode = 2
-toggle_mode = true
-text = "bag"
-custom_minimum_size = Vector2(76, 30)
-
-[node name="LogbookTab" type="Button" parent="MenuPanel/Margin/Layout/Header"]
-unique_name_in_owner = true
-layout_mode = 2
-toggle_mode = true
-text = "logbook"
-custom_minimum_size = Vector2(88, 30)
-
-[node name="CloseButton" type="Button" parent="MenuPanel/Margin/Layout/Header"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "close"
-custom_minimum_size = Vector2(68, 30)
-
-[node name="Separator" type="HSeparator" parent="MenuPanel/Margin/Layout"]
-layout_mode = 2
-
-[node name="TransactionFeedback" type="Label" parent="MenuPanel/Margin/Layout"]
+[node name="TransactionFeedback" type="RichTextLabel" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout"]
unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 30)
layout_mode = 2
text = ""
-horizontal_alignment = 1
-theme_override_colors/font_color = Color(1, 0.82, 0.4, 1)
-theme_override_font_sizes/font_size = 14
+bbcode_enabled = true
+fit_content = true
+scroll_active = false
+theme_override_colors/default_color = Color(1, 0.82, 0.4, 1)
+theme_override_font_sizes/normal_font_size = 21
-[node name="Content" type="Control" parent="MenuPanel/Margin/Layout"]
+[node name="Content" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(0, 260)
layout_mode = 2
size_flags_vertical = 3
-[node name="InventorySection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"]
+[node name="InventorySection" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
@@ -117,74 +1004,110 @@ grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 7
-[node name="SortBar" type="HBoxContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"]
+[node name="StatusShoal" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_constants/separation = 8
+alignment = 1
+
+[node name="WalletStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/StatusShoal" instance=ExtResource("7_status")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="CapacityStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/StatusShoal" instance=ExtResource("7_status")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="HeldValueStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/StatusShoal" instance=ExtResource("7_status")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="SortBar" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection"]
+unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 6
-[node name="SortLabel" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
+[node name="SortLabel" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
layout_mode = 2
text = "sort:"
-[node name="SortOption" type="OptionButton" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
+[node name="SortOption" type="OptionButton" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
unique_name_in_owner = true
layout_mode = 2
-custom_minimum_size = Vector2(130, 30)
+custom_minimum_size = Vector2(136, 104)
-[node name="SortDirection" type="Button" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
+[node name="SortDirection" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
unique_name_in_owner = true
layout_mode = 2
-custom_minimum_size = Vector2(112, 30)
+custom_minimum_size = Vector2(136, 104)
text = "newest first"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(136, 104)
+minimum_font_size = 15
+maximum_font_size = 20
+horizontal_amplitude = 0.0
+vertical_amplitude = 0.0
+deformation_amplitude = 0.0
-[node name="SortSpacer" type="Control" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
+[node name="SortSpacer" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
layout_mode = 2
size_flags_horizontal = 3
-[node name="CoolerCount" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
+[node name="CoolerCount" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
unique_name_in_owner = true
+visible = false
layout_mode = 2
text = "0 / 12"
theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
-[node name="HeldValue" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
-unique_name_in_owner = true
+[node name="HeldValueHeading" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar"]
+visible = false
layout_mode = 2
-text = "held fish base value: $0"
+text = "held fish base value"
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
-[node name="InventoryBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"]
+[node name="HeldValue" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/SortBar" instance=ExtResource("32_currency")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+
+[node name="InventoryBody" type="BoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection"]
+unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
-split_offset = 510
theme_override_constants/separation = 8
-[node name="InventoryList" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody"]
+[node name="InventoryList" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(350, 0)
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxEmpty_collection")
-[node name="InventoryListMargin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList"]
+[node name="InventoryListMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList"]
layout_mode = 2
theme_override_constants/margin_left = 8
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 8
theme_override_constants/margin_bottom = 8
-[node name="InventoryStack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin"]
+[node name="InventoryStack" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin"]
layout_mode = 2
theme_override_constants/separation = 6
-[node name="InventoryEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"]
+[node name="InventoryEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"]
unique_name_in_owner = true
layout_mode = 2
text = "your cooler is empty."
horizontal_alignment = 1
-[node name="InventoryScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"]
+[node name="InventoryScroll" type="ScrollContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"]
+unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
-[node name="InventoryGrid" type="GridContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack/InventoryScroll"]
+[node name="InventoryGrid" type="GridContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack/InventoryScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -192,22 +1115,27 @@ theme_override_constants/h_separation = 7
theme_override_constants/v_separation = 7
columns = 3
-[node name="DetailPanel" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody"]
+[node name="DetailPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(210, 0)
layout_mode = 2
+script = ExtResource("8_info")
+profile = ExtResource("4_profile")
+content_margin = 12.0
+corner_radius = 96
-[node name="DetailMargin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel"]
+[node name="DetailMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel"]
layout_mode = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 9
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 9
-[node name="DetailStack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin"]
+[node name="DetailStack" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin"]
layout_mode = 2
theme_override_constants/separation = 6
-[node name="DetailTexture" type="TextureRect" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="DetailTexture" type="TextureRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
unique_name_in_owner = true
custom_minimum_size = Vector2(178, 100)
layout_mode = 2
@@ -215,41 +1143,83 @@ expand_mode = 1
stretch_mode = 5
texture_filter = 1
-[node name="DetailName" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="DetailName" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
-theme_override_font_sizes/font_size = 19
+theme_override_font_sizes/font_size = 29
horizontal_alignment = 1
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
-[node name="DetailData" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="DetailData" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 2
horizontal_alignment = 1
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
-[node name="SelectionSummary" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="SelectionSummary" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
unique_name_in_owner = true
+visible = false
layout_mode = 2
text = "no fish selected"
autowrap_mode = 2
horizontal_alignment = 1
theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1)
-[node name="FavoriteButton" type="Button" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="ActionRow" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+layout_mode = 2
+theme_override_constants/separation = 8
+alignment = 1
+
+[node name="FavoriteButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack/ActionRow"]
unique_name_in_owner = true
layout_mode = 2
disabled = true
text = "favorite"
+custom_minimum_size = Vector2(112, 104)
+size_flags_horizontal = 4
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 104)
+minimum_font_size = 15
+maximum_font_size = 20
+horizontal_amplitude = 0.0
+vertical_amplitude = 0.0
+deformation_amplitude = 0.0
-[node name="SellButton" type="Button" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="SellButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack/ActionRow"]
unique_name_in_owner = true
layout_mode = 2
disabled = true
text = "sell"
-theme_type_variation = &"DangerButton"
+custom_minimum_size = Vector2(112, 104)
+size_flags_horizontal = 4
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 104)
+minimum_font_size = 15
+maximum_font_size = 20
+horizontal_amplitude = 0.0
+vertical_amplitude = 0.0
+deformation_amplitude = 0.0
-[node name="SaleUnavailable" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+[node name="ContextStatus" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
+layout_mode = 2
+theme_override_constants/separation = 6
+alignment = 1
+
+[node name="SelectionStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack/ContextStatus" instance=ExtResource("7_status")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(108, 78)
+layout_mode = 2
+
+[node name="OfferStatus" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack/ContextStatus" instance=ExtResource("7_status")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(128, 78)
+layout_mode = 2
+
+[node name="SaleUnavailable" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"]
unique_name_in_owner = true
visible = false
layout_mode = 2
@@ -257,7 +1227,7 @@ autowrap_mode = 2
horizontal_alignment = 1
theme_override_colors/font_color = Color(1, 0.702, 0.278, 1)
-[node name="BagSection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"]
+[node name="BagSection" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content"]
unique_name_in_owner = true
visible = false
layout_mode = 1
@@ -268,44 +1238,46 @@ grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 7
-[node name="BagHint" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection"]
+[node name="BagHint" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection"]
layout_mode = 2
-text = "drag hotbar-compatible items onto a slot below. right-click a slot to clear it."
+text = "drag fish or hotbar-compatible items onto a slot below. right-click a slot to clear it."
horizontal_alignment = 1
+autowrap_mode = 2
-[node name="BagBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/BagSection"]
+[node name="BagBody" type="HSplitContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection"]
layout_mode = 2
size_flags_vertical = 3
split_offset = 540
theme_override_constants/separation = 8
-[node name="BagList" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody"]
+[node name="BagList" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(360, 0)
layout_mode = 2
-[node name="Margin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList"]
+[node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList"]
layout_mode = 2
theme_override_constants/margin_left = 8
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 8
theme_override_constants/margin_bottom = 8
-[node name="Stack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin"]
+[node name="Stack" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin"]
layout_mode = 2
theme_override_constants/separation = 6
-[node name="BagEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"]
+[node name="BagEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"]
unique_name_in_owner = true
layout_mode = 2
text = "your bag is empty."
horizontal_alignment = 1
-[node name="BagScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"]
+[node name="BagScroll" type="ScrollContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
-[node name="BagGrid" type="GridContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack/BagScroll"]
+[node name="BagGrid" type="GridContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack/BagScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -313,22 +1285,23 @@ theme_override_constants/h_separation = 7
theme_override_constants/v_separation = 7
columns = 3
-[node name="BagDetail" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody"]
+[node name="BagDetail" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody"]
+unique_name_in_owner = true
custom_minimum_size = Vector2(220, 0)
layout_mode = 2
-[node name="Margin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail"]
+[node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail"]
layout_mode = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 9
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 9
-[node name="Stack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin"]
+[node name="Stack" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin"]
layout_mode = 2
theme_override_constants/separation = 6
-[node name="BagDetailTexture" type="TextureRect" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"]
+[node name="BagDetailTexture" type="TextureRect" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 96)
layout_mode = 2
@@ -336,20 +1309,20 @@ expand_mode = 1
stretch_mode = 5
texture_filter = 1
-[node name="BagDetailName" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"]
+[node name="BagDetailName" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"]
unique_name_in_owner = true
layout_mode = 2
-theme_override_font_sizes/font_size = 19
+theme_override_font_sizes/font_size = 29
horizontal_alignment = 1
-[node name="BagDetailData" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"]
+[node name="BagDetailData" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"]
unique_name_in_owner = true
layout_mode = 2
text = "select a bag item for details."
horizontal_alignment = 1
autowrap_mode = 2
-[node name="LogbookSection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"]
+[node name="LogbookSection" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content"]
unique_name_in_owner = true
visible = false
layout_mode = 1
@@ -360,18 +1333,18 @@ grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 7
-[node name="LogbookEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/LogbookSection"]
+[node name="LogbookEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/LogbookSection"]
unique_name_in_owner = true
layout_mode = 2
text = "no species discovered yet."
horizontal_alignment = 1
-[node name="LogbookScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/LogbookSection"]
+[node name="LogbookScroll" type="ScrollContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/LogbookSection"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
-[node name="LogbookGrid" type="GridContainer" parent="MenuPanel/Margin/Layout/Content/LogbookSection/LogbookScroll"]
+[node name="LogbookGrid" type="GridContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/LogbookSection/LogbookScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -379,53 +1352,234 @@ theme_override_constants/h_separation = 7
theme_override_constants/v_separation = 7
columns = 4
-[node name="SaleConfirmation" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content"]
+[node name="SaleConfirmation" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
unique_name_in_owner = true
visible = false
-z_index = 40
-layout_mode = 1
-anchors_preset = 8
-anchor_left = 0.5
-anchor_top = 0.5
-anchor_right = 0.5
-anchor_bottom = 0.5
-offset_left = -230.0
-offset_top = -105.0
-offset_right = 230.0
-offset_bottom = 105.0
-grow_horizontal = 2
-grow_vertical = 2
+z_index = 120
+layout_mode = 0
+offset_left = 380.0
+offset_top = 265.0
+offset_right = 840.0
+offset_bottom = 455.0
-[node name="ConfirmationMargin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation"]
+[node name="ConfirmationMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/SaleConfirmation"]
layout_mode = 2
theme_override_constants/margin_left = 22
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 22
theme_override_constants/margin_bottom = 20
-[node name="ConfirmationLayout" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin"]
+[node name="ConfirmationLayout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/SaleConfirmation/ConfirmationMargin"]
layout_mode = 2
theme_override_constants/separation = 12
-
-[node name="ConfirmationMessage" type="Label" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout"]
-unique_name_in_owner = true
-layout_mode = 2
-autowrap_mode = 2
-horizontal_alignment = 1
-
-[node name="ConfirmationButtons" type="HBoxContainer" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout"]
-layout_mode = 2
alignment = 1
-[node name="ConfirmSaleButton" type="Button" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"]
+[node name="ConfirmationMessage" type="RichTextLabel" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/SaleConfirmation/ConfirmationMargin/ConfirmationLayout"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 64)
+layout_mode = 2
+bbcode_enabled = true
+fit_content = true
+scroll_active = false
+theme_override_font_sizes/normal_font_size = 22
+autowrap_mode = 2
+
+[node name="ConfirmationButtons" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/SaleConfirmation/ConfirmationMargin/ConfirmationLayout"]
+layout_mode = 2
+theme_override_constants/separation = 10
+alignment = 1
+
+[node name="ConfirmSaleButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"]
unique_name_in_owner = true
layout_mode = 2
text = "confirm"
-theme_type_variation = &"DangerButton"
-custom_minimum_size = Vector2(120, 38)
+custom_minimum_size = Vector2(140, 44)
-[node name="CancelSaleButton" type="Button" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"]
+[node name="CancelSaleButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"]
unique_name_in_owner = true
layout_mode = 2
text = "cancel"
-custom_minimum_size = Vector2(120, 38)
+custom_minimum_size = Vector2(140, 44)
+
+[node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 330.0
+offset_top = 44.0
+offset_right = 1170.0
+offset_bottom = 144.0
+mouse_filter = 1
+script = ExtResource("5_cluster")
+profile = ExtResource("4_profile")
+desktop_reference_size = Vector2(840, 100)
+compact_reference_size = Vector2(840, 100)
+
+[node name="InventoryTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+toggle_mode = true
+icon = ExtResource("25_inventory_simple")
+tooltip_text = "stuff"
+accessibility_name = "stuff"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(60, 60)
+compact_anchor = Vector2(60, 73.3333)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 25
+maximum_font_size = 25
+horizontal_amplitude = 1.2
+vertical_amplitude = 2.4
+motion_period = 5.6
+motion_phase = 0.35
+deformation_amplitude = 0.012
+deformation_period = 6.4
+
+[node name="LogbookTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+toggle_mode = true
+icon = ExtResource("26_logbook_simple")
+tooltip_text = "logbook"
+accessibility_name = "logbook"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(180, 60)
+compact_anchor = Vector2(180, 73.3333)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 25
+maximum_font_size = 25
+horizontal_amplitude = 1.3
+vertical_amplitude = 2.5
+motion_period = 5.9
+motion_phase = 2.85
+deformation_amplitude = 0.012
+deformation_period = 6.7
+
+[node name="TheNetTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+toggle_mode = true
+icon = ExtResource("27_fishnet_simple")
+tooltip_text = "fishnet"
+accessibility_name = "net"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(300, 60)
+compact_anchor = Vector2(300, 73.3333)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 25
+maximum_font_size = 25
+horizontal_amplitude = 1.2
+vertical_amplitude = 2.3
+motion_period = 5.75
+motion_phase = 3.2
+deformation_amplitude = 0.011
+deformation_period = 6.45
+
+[node name="MailTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+toggle_mode = true
+icon = ExtResource("28_mail_simple")
+tooltip_text = "mail"
+accessibility_name = "mail"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(420, 60)
+compact_anchor = Vector2(420, 73.3333)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 25
+maximum_font_size = 25
+horizontal_amplitude = 1.1
+vertical_amplitude = 2.2
+motion_period = 5.5
+motion_phase = 3.55
+deformation_amplitude = 0.011
+deformation_period = 6.3
+
+[node name="MailUnreadBadge" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster/MailTab"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 86.0
+offset_top = 2.0
+offset_right = 120.0
+offset_bottom = 34.0
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.12, 0.08, 0.04, 1)
+theme_override_colors/font_outline_color = Color(1, 0.88, 0.34, 1)
+theme_override_constants/outline_size = 7
+theme_override_font_sizes/font_size = 18
+text = "1"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="ProfileTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+toggle_mode = true
+icon = ExtResource("29_profile_simple")
+tooltip_text = "profile"
+accessibility_name = "profile"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(540, 60)
+compact_anchor = Vector2(540, 73.3333)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 23
+maximum_font_size = 25
+horizontal_amplitude = 1.2
+vertical_amplitude = 2.1
+motion_period = 5.7
+motion_phase = 4.05
+deformation_amplitude = 0.011
+deformation_period = 6.5
+
+[node name="PlayersTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+toggle_mode = true
+texture_filter = 1
+icon = ExtResource("30_online_simple")
+tooltip_text = "online"
+accessibility_name = "online"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(660, 60)
+compact_anchor = Vector2(660, 73.3333)
+compact_minimum_size = Vector2(84, 82)
+minimum_font_size = 22
+maximum_font_size = 24
+horizontal_amplitude = 1.1
+vertical_amplitude = 2.2
+motion_period = 5.8
+motion_phase = 4.35
+deformation_amplitude = 0.011
+deformation_period = 6.4
+
+[node name="CloseButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
+unique_name_in_owner = true
+layout_mode = 0
+icon = ExtResource("31_close")
+tooltip_text = "close"
+accessibility_name = "close"
+script = ExtResource("6_bubble")
+profile = ExtResource("4_profile")
+neutral_size = Vector2(96, 94)
+desktop_anchor = Vector2(780, 57)
+compact_anchor = Vector2(780, 73.3333)
+compact_minimum_size = Vector2(76, 74)
+minimum_font_size = 23
+maximum_font_size = 23
+horizontal_amplitude = 1.0
+vertical_amplitude = 1.9
+motion_period = 5.3
+motion_phase = 4.2
+deformation_amplitude = 0.01
+deformation_period = 6.2
diff --git a/ui/player_menu_pattern.gdshader b/ui/player_menu_pattern.gdshader
new file mode 100644
index 0000000..71f8fc6
--- /dev/null
+++ b/ui/player_menu_pattern.gdshader
@@ -0,0 +1,13 @@
+shader_type canvas_item;
+
+uniform sampler2D pattern_texture : source_color, repeat_enable, filter_nearest;
+uniform vec2 source_tile_size = vec2(128.0, 128.0);
+uniform float display_scale : hint_range(0.5, 2.0, 0.05) = 0.85;
+uniform vec2 scroll_velocity_pixels = vec2(-7.0, -5.0);
+
+void fragment() {
+ vec2 viewport_pixels = 1.0 / SCREEN_PIXEL_SIZE;
+ vec2 display_tile_size = max(source_tile_size * display_scale, vec2(1.0));
+ vec2 moving_pixels = UV * viewport_pixels + TIME * scroll_velocity_pixels;
+ COLOR = texture(pattern_texture, moving_pixels / display_tile_size);
+}
diff --git a/ui/player_menu_pattern.gdshader.uid b/ui/player_menu_pattern.gdshader.uid
new file mode 100644
index 0000000..79a35df
--- /dev/null
+++ b/ui/player_menu_pattern.gdshader.uid
@@ -0,0 +1 @@
+uid://byw3iub66qvog
diff --git a/ui/players_page.gd b/ui/players_page.gd
new file mode 100644
index 0000000..380299e
--- /dev/null
+++ b/ui/players_page.gd
@@ -0,0 +1,618 @@
+class_name PlayersPage
+extends Control
+
+const TOGGLE_STATE_COLOR := Color("c3dfe6")
+
+var _service: NetworkPlayerListService
+var _discovery: DiscoveryClient
+var _count_label: Label
+var _tabs: HBoxContainer
+var _list: VBoxContainer
+var _status: Label
+var _host_settings_panel: PanelContainer
+var _room_name_edit: LineEdit
+var _online_toggle: Button
+var _online_state_label: Label
+var _discoverable_toggle: Button
+var _discoverable_state_label: Label
+var _host_discovery_status: Label
+var _current_tab := 0
+
+
+func _ready() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ UtilityPageStyle.apply_page(self)
+ _build()
+
+
+func setup(
+ service: NetworkPlayerListService,
+ discovery: DiscoveryClient,
+) -> void:
+ _service = service
+ _discovery = discovery
+ _service.entries_changed.connect(_refresh)
+ _service.moderation_finished.connect(func(_ok: bool, message: String) -> void:
+ _status.text = message
+ _refresh()
+ )
+ if _discovery != null:
+ _discovery.host_settings_changed.connect(
+ _on_host_settings_changed
+ )
+ _discovery.host_status_changed.connect(_on_host_status_changed)
+ _refresh()
+
+
+func activate() -> void:
+ _refresh()
+ UtilityPageStyle.animate_in(self)
+ _focus_first()
+
+
+func deactivate() -> void:
+ _status.text = ""
+
+
+func _build() -> void:
+ var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self)
+ var root := VBoxContainer.new()
+ root.add_theme_constant_override("separation", 9)
+ margin.add_child(root)
+ var tab_row := HBoxContainer.new()
+ tab_row.add_theme_constant_override("separation", 16)
+ root.add_child(tab_row)
+ _tabs = HBoxContainer.new()
+ _tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _tabs.add_theme_constant_override("separation", 10)
+ tab_row.add_child(_tabs)
+ for index: int in 3:
+ var button := Button.new()
+ button.text = ["players", "relationships", "banned"][index]
+ button.toggle_mode = true
+ button.pressed.connect(_select_tab.bind(index))
+ UtilityPageStyle.apply_ocean_button(button)
+ _tabs.add_child(button)
+ _count_label = Label.new()
+ _count_label.add_theme_font_size_override("font_size", 17)
+ _count_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ tab_row.add_child(_count_label)
+ _build_host_settings(root)
+ var scroll := ScrollContainer.new()
+ scroll.custom_minimum_size = Vector2(0, 310)
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ root.add_child(scroll)
+ _list = VBoxContainer.new()
+ _list.custom_minimum_size = Vector2(1032, 0)
+ _list.add_theme_constant_override("separation", 7)
+ scroll.add_child(_list)
+ _status = Label.new()
+ _status.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ root.add_child(_status)
+
+
+func _build_host_settings(root: VBoxContainer) -> void:
+ _host_settings_panel = PanelContainer.new()
+ _host_settings_panel.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.row_style(false)
+ )
+ root.add_child(_host_settings_panel)
+ var row := HBoxContainer.new()
+ row.custom_minimum_size.y = 58.0
+ row.add_theme_constant_override("separation", 10)
+ _host_settings_panel.add_child(row)
+ var label := Label.new()
+ label.text = "room name"
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ row.add_child(label)
+ _room_name_edit = LineEdit.new()
+ _room_name_edit.custom_minimum_size = Vector2(300.0, 42.0)
+ _room_name_edit.max_length = DiscoveryClient.MAX_ROOM_NAME_LENGTH
+ _room_name_edit.placeholder_text = "Player Name's Server"
+ UtilityPageStyle.apply_ocean_line_edit(_room_name_edit)
+ _room_name_edit.text_submitted.connect(
+ func(_value: String) -> void: _commit_room_name()
+ )
+ _room_name_edit.focus_exited.connect(_commit_room_name)
+ row.add_child(_room_name_edit)
+ _online_toggle = _build_host_toggle("online", row)
+ _online_state_label = _online_toggle.get_node("StateBadge/State") as Label
+ _online_toggle.pressed.connect(_on_online_pressed)
+ _discoverable_toggle = _build_host_toggle("discovery", row)
+ _discoverable_state_label = (
+ _discoverable_toggle.get_node("StateBadge/State") as Label
+ )
+ _discoverable_toggle.pressed.connect(_on_discoverable_pressed)
+ _host_discovery_status = Label.new()
+ _host_discovery_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _host_discovery_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _host_discovery_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _host_discovery_status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _host_discovery_status.add_theme_font_size_override("font_size", 14)
+ _host_discovery_status.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ row.add_child(_host_discovery_status)
+
+
+func _build_host_toggle(label_text: String, row: HBoxContainer) -> Button:
+ var button := Button.new()
+ button.custom_minimum_size = Vector2(150.0, 46.0)
+ button.text = label_text
+ button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ button.clip_contents = false
+ UtilityPageStyle.apply_ocean_button(button)
+ row.add_child(button)
+ var badge := PanelContainer.new()
+ badge.name = "StateBadge"
+ badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ badge.z_index = 2
+ button.add_child(badge)
+ badge.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
+ badge.offset_left = -39.0
+ badge.offset_top = -12.0
+ badge.offset_right = 39.0
+ badge.offset_bottom = 12.0
+ var state_label := Label.new()
+ state_label.name = "State"
+ state_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ state_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ state_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ state_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
+ state_label.add_theme_font_size_override("font_size", 15)
+ state_label.add_theme_color_override("font_color", TOGGLE_STATE_COLOR)
+ badge.add_child(state_label)
+ return button
+
+
+func _set_host_toggle_state(
+ button: Button,
+ state_label: Label,
+ state_text: String,
+ enabled: bool,
+) -> void:
+ state_label.text = state_text
+ var badge := state_label.get_parent() as PanelContainer
+ badge.add_theme_stylebox_override(
+ "panel",
+ UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_SELECTED
+ if enabled
+ else UtilityPageStyle.OCEAN_FIELD,
+ 12,
+ ),
+ )
+ button.queue_redraw()
+
+
+func _select_tab(index: int) -> void:
+ if index == 2 and (_service == null or not _service.is_local_moderator()):
+ return
+ _current_tab = index
+ _refresh()
+ _focus_first()
+
+
+func _refresh() -> void:
+ if _service == null or _list == null:
+ return
+ _count_label.text = "%d / %d connected" % [
+ _service.get_connected_count(), _service.get_max_players(),
+ ]
+ if _current_tab == 2 and not _service.is_local_moderator():
+ _current_tab = 0
+ for index: int in _tabs.get_child_count():
+ var button := _tabs.get_child(index) as Button
+ button.button_pressed = index == _current_tab
+ button.visible = index != 2 or _service.is_local_moderator()
+ _refresh_host_settings()
+ for child: Node in _list.get_children():
+ child.queue_free()
+ match _current_tab:
+ 0:
+ _build_active_rows()
+ 1:
+ _build_relationship_rows()
+ 2:
+ _build_ban_rows()
+
+
+func _refresh_host_settings() -> void:
+ if _host_settings_panel == null:
+ return
+ var host_visible: bool = _service.is_local_host() and _current_tab == 0
+ _host_settings_panel.visible = host_visible
+ if not host_visible or _discovery == null:
+ return
+ if not _room_name_edit.has_focus():
+ _room_name_edit.text = _discovery.get_room_name()
+ var is_open: bool = _service.is_open_host()
+ var is_discoverable: bool = _discovery.is_discoverable()
+ _set_host_toggle_state(
+ _online_toggle,
+ _online_state_label,
+ "OPEN" if is_open else "CLOSED",
+ is_open,
+ )
+ _set_host_toggle_state(
+ _discoverable_toggle,
+ _discoverable_state_label,
+ "ON" if is_discoverable else "OFF",
+ is_discoverable,
+ )
+ _online_toggle.disabled = not _service.is_local_host()
+ _online_toggle.tooltip_text = (
+ "Close this game to new connections."
+ if is_open
+ else "Open this game so other players can connect."
+ )
+ var can_publish: bool = (
+ _service.is_local_host()
+ and _discovery.is_configured()
+ and is_open
+ )
+ _discoverable_toggle.disabled = not can_publish
+ _discoverable_toggle.tooltip_text = (
+ "Stop advertising this game in the public room browser."
+ if is_discoverable
+ else "Advertise this open game in the public room browser."
+ if can_publish
+ else "Open the game before enabling discovery."
+ if _discovery.is_configured()
+ else "Room discovery is not configured in this build."
+ )
+ _on_host_status_changed(
+ _discovery.get_host_status_message(),
+ _discovery.host_status_is_error(),
+ )
+
+
+func _commit_room_name() -> void:
+ if _discovery == null:
+ return
+ if not _discovery.set_room_name(_room_name_edit.text):
+ _room_name_edit.text = _discovery.get_room_name()
+ else:
+ _room_name_edit.text = _discovery.get_room_name()
+
+
+func _on_online_pressed() -> void:
+ if _service == null or not _service.is_local_host():
+ return
+ _service.set_host_open(not _service.is_open_host())
+ _refresh_host_settings()
+
+
+func _on_discoverable_pressed() -> void:
+ if _discovery == null:
+ return
+ _discovery.set_discoverable(not _discovery.is_discoverable())
+ _refresh_host_settings()
+
+
+func _on_host_settings_changed(
+ room_name: String,
+ discoverable: bool,
+) -> void:
+ if _room_name_edit != null and not _room_name_edit.has_focus():
+ _room_name_edit.text = room_name
+ if _discoverable_state_label != null:
+ _set_host_toggle_state(
+ _discoverable_toggle,
+ _discoverable_state_label,
+ "ON" if discoverable else "OFF",
+ discoverable,
+ )
+ _refresh_host_settings()
+
+
+func _on_host_status_changed(message: String, is_error: bool) -> void:
+ if _host_discovery_status == null:
+ return
+ var tooltip_only: bool = message in [
+ "Open the game before listing it publicly.",
+ "Open the game before enabling discovery.",
+ ]
+ _host_discovery_status.text = "" if tooltip_only else message
+ _host_discovery_status.visible = not _host_discovery_status.text.is_empty()
+ _host_discovery_status.add_theme_color_override(
+ "font_color",
+ UtilityPageStyle.OCEAN_DANGER
+ if is_error
+ else UtilityPageStyle.OCEAN_TEXT_SECONDARY,
+ )
+
+
+func _build_active_rows() -> void:
+ if _service.is_local_moderator():
+ _build_session_artwork_controls()
+ var entries := _service.get_entries()
+ if entries.is_empty():
+ _add_empty("No authenticated players.")
+ return
+ for entry: PlayerListEntry in entries:
+ var row := _make_row()
+ var identity := Label.new()
+ identity.custom_minimum_size.x = 430
+ var markers: Array[String] = []
+ if entry.is_host:
+ markers.append("host")
+ if entry.is_operator:
+ markers.append("operator")
+ if entry.is_local_player:
+ markers.append("You")
+ identity.text = "%s%s · %s\n%s" % [
+ entry.display_name,
+ " [%s]" % ", ".join(markers) if not markers.is_empty() else "",
+ entry.compact_fingerprint,
+ entry.continuity_state,
+ ]
+ identity.tooltip_text = "Full identity fingerprint:\n%s" % (
+ entry.full_fingerprint
+ )
+ identity.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ row.add_child(identity)
+ var ping := Label.new()
+ ping.custom_minimum_size.x = 80
+ ping.text = (
+ "Local" if entry.is_host
+ else "%d ms" % entry.ping_to_host_ms
+ if entry.ping_to_host_ms >= 0 else "—"
+ )
+ ping.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ row.add_child(ping)
+ var mute := Button.new()
+ mute.text = "unmute" if entry.muted else "mute"
+ mute.disabled = entry.is_local_player
+ mute.pressed.connect(_toggle_mute.bind(entry))
+ UtilityPageStyle.apply_ocean_button(mute)
+ row.add_child(mute)
+ var block := Button.new()
+ block.text = "block"
+ block.disabled = entry.is_local_player
+ block.pressed.connect(_confirm_block.bind(entry))
+ UtilityPageStyle.apply_ocean_button(block)
+ row.add_child(block)
+ if entry.can_manage_operator:
+ var operator := Button.new()
+ operator.text = "deop" if entry.is_operator else "op"
+ operator.pressed.connect(_confirm_operator.bind(entry))
+ UtilityPageStyle.apply_ocean_button(operator)
+ row.add_child(operator)
+ var kick := Button.new()
+ kick.text = "kick"
+ kick.disabled = not entry.can_kick
+ kick.pressed.connect(_confirm_kick.bind(entry))
+ UtilityPageStyle.apply_ocean_button(kick)
+ row.add_child(kick)
+ var ban := Button.new()
+ ban.text = "ban"
+ ban.disabled = not entry.can_ban
+ ban.pressed.connect(_confirm_ban.bind(entry))
+ UtilityPageStyle.apply_ocean_button(ban)
+ row.add_child(ban)
+
+
+func _build_session_artwork_controls() -> void:
+ var counts: Vector2i = _service.get_session_artwork_counts()
+ var row := _make_row()
+ var label := Label.new()
+ label.custom_minimum_size.x = 830
+ label.text = "session artwork · %d layers · %d painted pixels" % [
+ counts.x, counts.y,
+ ]
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ row.add_child(label)
+ var reset := Button.new()
+ reset.text = "reset paint"
+ reset.disabled = counts.x == 0
+ reset.tooltip_text = "Clears all shared artwork from this session."
+ reset.pressed.connect(func() -> void:
+ _confirm(
+ "Clear all shared paint from this session?\nThis cannot be undone.",
+ _service.reset_session_artwork,
+ )
+ )
+ UtilityPageStyle.apply_ocean_button(reset)
+ row.add_child(reset)
+
+
+func _build_relationship_rows() -> void:
+ var records := _service.get_relationships()
+ if records.is_empty():
+ _add_empty("No muted or blocked identities.")
+ return
+ for record: Dictionary in records:
+ var row := _make_row()
+ var label := Label.new()
+ label.custom_minimum_size.x = 690
+ var fingerprint := str(record["fingerprint"])
+ label.text = "%s · %s %s" % [
+ str(record.get("last_known_display_name", "Player")),
+ NetworkIdentityCrypto.compact_suffix(fingerprint),
+ "Blocked" if bool(record.get("blocked", false)) else "Muted",
+ ]
+ label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint)
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ row.add_child(label)
+ if bool(record.get("blocked", false)):
+ var unblock := Button.new()
+ unblock.text = "unblock"
+ unblock.pressed.connect(func() -> void:
+ _service.set_blocked(fingerprint, str(record["last_known_display_name"]), false)
+ )
+ UtilityPageStyle.apply_ocean_button(unblock)
+ row.add_child(unblock)
+ var unmute := Button.new()
+ unmute.text = "unmute"
+ unmute.disabled = bool(record.get("blocked", false))
+ unmute.pressed.connect(func() -> void:
+ _service.set_muted(fingerprint, str(record["last_known_display_name"]), false)
+ )
+ UtilityPageStyle.apply_ocean_button(unmute)
+ row.add_child(unmute)
+
+
+func _build_ban_rows() -> void:
+ var records := _service.get_bans()
+ if records.is_empty():
+ _add_empty("No banned identities.")
+ return
+ for record: Dictionary in records:
+ var row := _make_row()
+ var fingerprint := str(record["target_fingerprint"])
+ var label := Label.new()
+ label.custom_minimum_size.x = 830
+ label.text = "%s · %s banned %s" % [
+ str(record.get("last_known_display_name", "Player")),
+ NetworkIdentityCrypto.compact_suffix(fingerprint),
+ Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))),
+ ]
+ label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint)
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ row.add_child(label)
+ var unban := Button.new()
+ unban.text = "unban"
+ unban.pressed.connect(_confirm_unban.bind(fingerprint))
+ UtilityPageStyle.apply_ocean_button(unban)
+ row.add_child(unban)
+
+
+func _make_row() -> HBoxContainer:
+ var panel := PanelContainer.new()
+ panel.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.row_style(false)
+ )
+ _list.add_child(panel)
+ var row := HBoxContainer.new()
+ row.custom_minimum_size.y = 58
+ row.add_theme_constant_override("separation", 8)
+ panel.add_child(row)
+ return row
+
+
+func _add_empty(text: String) -> void:
+ var label := Label.new()
+ label.text = text
+ label.custom_minimum_size.y = 100
+ label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _list.add_child(label)
+
+
+func _toggle_mute(entry: PlayerListEntry) -> void:
+ _service.set_muted(entry.full_fingerprint, entry.display_name, not entry.muted)
+
+
+func _confirm_block(entry: PlayerListEntry) -> void:
+ _confirm(
+ "Block %s?\nTheir local social and visual presentation will be hidden."
+ % entry.display_name,
+ func() -> void:
+ _service.set_blocked(entry.full_fingerprint, entry.display_name, true)
+ )
+
+
+func _confirm_kick(entry: PlayerListEntry) -> void:
+ _confirm("Remove %s from this session?" % entry.display_name, func() -> void:
+ _service.kick(entry.peer_id, entry.full_fingerprint, entry.revision)
+ )
+
+
+func _confirm_ban(entry: PlayerListEntry) -> void:
+ _confirm("Ban %s · %s from this server?" % [
+ entry.display_name, entry.compact_fingerprint,
+ ], func() -> void:
+ _service.ban(
+ entry.peer_id, entry.full_fingerprint, entry.display_name, entry.revision
+ )
+ )
+
+
+func _confirm_operator(entry: PlayerListEntry) -> void:
+ var enabled: bool = not entry.is_operator
+ _confirm(
+ (
+ "Grant operator access to %s for this room session?"
+ if enabled
+ else "Remove operator access from %s?"
+ ) % entry.display_name,
+ func() -> void:
+ _service.set_operator(
+ entry.peer_id,
+ entry.full_fingerprint,
+ enabled,
+ entry.revision,
+ )
+ )
+
+
+func _confirm_unban(fingerprint: String) -> void:
+ _confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void:
+ _service.unban(fingerprint)
+ )
+
+
+func _confirm(text: String, action: Callable) -> void:
+ var dialog := ConfirmationDialog.new()
+ dialog.dialog_text = text
+ dialog.ok_button_text = "confirm"
+ dialog.canceled.connect(dialog.queue_free)
+ dialog.confirmed.connect(func() -> void:
+ action.call()
+ dialog.queue_free()
+ )
+ add_child(dialog)
+ dialog.popup_centered(Vector2i(520, 220))
+
+
+func _focus_first() -> void:
+ var candidates: Array[Control] = []
+ _collect_focusable_player_controls(self, candidates)
+ if candidates.is_empty():
+ return
+ candidates.sort_custom(func(first: Control, second: Control) -> bool:
+ if not is_equal_approx(first.global_position.y, second.global_position.y):
+ return first.global_position.y < second.global_position.y
+ return first.global_position.x < second.global_position.x
+ )
+ candidates[0].grab_focus()
+
+
+func _collect_focusable_player_controls(
+ root: Node,
+ output: Array[Control],
+) -> void:
+ for child: Node in root.get_children():
+ if child == _tabs:
+ continue
+ var control := child as Control
+ if control != null and not control.is_visible_in_tree():
+ continue
+ if (
+ control != null
+ and control.focus_mode != Control.FOCUS_NONE
+ and not control is ScrollBar
+ ):
+ output.append(control)
+ _collect_focusable_player_controls(child, output)
diff --git a/ui/players_page.gd.uid b/ui/players_page.gd.uid
new file mode 100644
index 0000000..e93601d
--- /dev/null
+++ b/ui/players_page.gd.uid
@@ -0,0 +1 @@
+uid://c71playerspage0001
diff --git a/ui/profile_page.gd b/ui/profile_page.gd
new file mode 100644
index 0000000..3fc9fb4
--- /dev/null
+++ b/ui/profile_page.gd
@@ -0,0 +1,1278 @@
+class_name ProfilePage
+extends Control
+
+const CHECK_DEBOUNCE_SECONDS: float = 0.4
+const OPTION_GRID_COLUMNS: int = 6
+const FUR_PALETTE_GRID_COLUMNS: int = 10
+const FUR_CHANNEL_ICON_SIZE: int = 20
+const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const AnimaleseVoiceType = preload("res://ui/animalese_voice.gd")
+const TypewriterRevealType = preload("res://ui/typewriter_reveal.gd")
+const VoiceProfilesType = preload(
+ "res://player/animalese_voice_profiles.gd"
+)
+const VOICE_CATEGORY_ID: String = "voice"
+
+var _service: NetworkProfileService
+var _experience: PlayerExperience
+var _draft_name: String = ""
+var _draft_appearance: Dictionary = {}
+var _persisted_name: String = ""
+var _persisted_appearance: Dictionary = {}
+var _draft_voice_id: String = VoiceProfilesType.DEFAULT_ID
+var _persisted_voice_id: String = VoiceProfilesType.DEFAULT_ID
+var _draft_speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
+var _persisted_speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
+var _draft_call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
+var _persisted_call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
+var _category_id: String = "species"
+var _dirty: bool = false
+var _allow_duplicate: bool = false
+
+var _name_edit: LineEdit
+var _name_status: Label
+var _suggestions: HBoxContainer
+var _category_list: VBoxContainer
+var _option_list: VBoxContainer
+var _preview: ProfilePreview
+var _apply_button: Button
+var _revert_button: Button
+var _discard_confirmation: PanelContainer
+var _confirmation_label: Label
+var _confirmation_confirm: Button
+var _keep_editing_button: Button
+var _confirmation_action: String = ""
+var _debounce: Timer
+var _experience_level: Label
+var _experience_progress: ProgressBar
+var _experience_value: Label
+var _feature_preview_cache: Dictionary = {}
+var _fur_swatch_icon_cache: Dictionary = {}
+var _expanded_feature_drawers: Dictionary = {}
+var _feature_drawer_animation_key: String = ""
+var _scale_value_label: Label
+var _voice_preview: AnimaleseVoiceType
+var _voice_preview_tween: Tween
+var _active_fur_color_id: String = "fur_pattern"
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_PASS
+ _build_ui()
+ _debounce = Timer.new()
+ _debounce.one_shot = true
+ _debounce.wait_time = CHECK_DEBOUNCE_SECONDS
+ _debounce.timeout.connect(_request_conflict_check)
+ add_child(_debounce)
+
+
+func setup(
+ service: NetworkProfileService,
+ experience: PlayerExperience,
+ world_environment: WorldEnvironment,
+ world_sun: DirectionalLight3D,
+) -> void:
+ _service = service
+ _experience = experience
+ if not _service.conflict_result.is_connected(_on_conflict_result):
+ _service.conflict_result.connect(_on_conflict_result)
+ _service.apply_finished.connect(_on_apply_finished)
+ if (
+ _experience != null
+ and not _experience.experience_changed.is_connected(
+ _on_experience_changed
+ )
+ ):
+ _experience.experience_changed.connect(_on_experience_changed)
+ _load_persisted()
+ _refresh_experience()
+ _preview.setup_world_lighting(world_environment, world_sun)
+ var identity_value := find_child("IdentityFingerprint", true, false) as Label
+ if identity_value != null:
+ var fingerprint := _service.get_identity_fingerprint()
+ identity_value.text = "Identity • %s • Stored on this device" % (
+ NetworkIdentityCrypto.compact_suffix(fingerprint)
+ )
+ identity_value.tooltip_text = (
+ NetworkIdentityCrypto.format_fingerprint(fingerprint)
+ )
+
+
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ if _preview != null:
+ _preview.setup_controller_mapping(mapping_manager)
+
+
+func set_world_pixel_size(pixel_size: int) -> void:
+ if _preview != null:
+ _preview.set_world_pixel_size(pixel_size)
+
+
+func activate() -> void:
+ visible = true
+ UtilityPageStyle.animate_in(self)
+ if _service != null:
+ _load_persisted()
+ # Keep controller page switches on the Profile navigation bubble. Focusing
+ # the name field here summons the Android keyboard during LB/RB traversal.
+
+
+func deactivate() -> void:
+ visible = false
+ _debounce.stop()
+ if _voice_preview_tween != null and _voice_preview_tween.is_valid():
+ _voice_preview_tween.kill()
+ _preview.reset_view()
+
+
+func set_interactive(interactive: bool) -> void:
+ mouse_filter = (
+ Control.MOUSE_FILTER_PASS if interactive else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func consume_escape() -> bool:
+ if _discard_confirmation.visible:
+ _discard_confirmation.visible = false
+ _confirmation_action = ""
+ _name_edit.grab_focus()
+ return true
+ if _name_edit.has_focus():
+ _name_edit.release_focus()
+ return true
+ if _dirty:
+ _show_confirmation("discard")
+ return true
+ return false
+
+
+func has_unsaved_changes() -> bool:
+ return _dirty
+
+
+func request_close_confirmation() -> bool:
+ if not _dirty:
+ return false
+ _show_confirmation("discard")
+ return true
+
+
+func has_modal_confirmation() -> bool:
+ return (
+ _discard_confirmation != null
+ and _discard_confirmation.visible
+ )
+
+
+func _build_ui() -> void:
+ UtilityPageStyle.apply_page(self)
+ var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self)
+ var layout := VBoxContainer.new()
+ layout.add_theme_constant_override("separation", 8)
+ margin.add_child(layout)
+
+ var account_row := HBoxContainer.new()
+ account_row.add_theme_constant_override("separation", 14)
+ layout.add_child(account_row)
+ var account_stack := VBoxContainer.new()
+ account_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ account_stack.add_theme_constant_override("separation", 2)
+ account_row.add_child(account_stack)
+ var name_line := HBoxContainer.new()
+ name_line.add_theme_constant_override("separation", 10)
+ account_stack.add_child(name_line)
+ var name_label := Label.new()
+ name_label.text = "player name"
+ name_label.custom_minimum_size.x = 98.0
+ name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ name_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ name_line.add_child(name_label)
+ _name_edit = LineEdit.new()
+ _name_edit.max_length = NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
+ _name_edit.placeholder_text = "Player"
+ _name_edit.custom_minimum_size = Vector2(280, 34)
+ UtilityPageStyle.apply_ocean_line_edit(_name_edit)
+ _name_edit.add_theme_stylebox_override(
+ "normal", UtilityPageStyle.ocean_button_style(
+ UtilityPageStyle.OCEAN_PANEL_MID
+ )
+ )
+ _name_edit.text_changed.connect(_on_name_changed)
+ name_line.add_child(_name_edit)
+ var helper := Label.new()
+ helper.text = "Shown to other players in multiplayer."
+ helper.add_theme_font_size_override("font_size", 12)
+ helper.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ account_stack.add_child(helper)
+ _name_status = Label.new()
+ _name_status.add_theme_font_size_override("font_size", 12)
+ _name_status.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ account_stack.add_child(_name_status)
+ _suggestions = HBoxContainer.new()
+ _suggestions.add_theme_constant_override("separation", 6)
+ account_stack.add_child(_suggestions)
+
+ var identity_value := Label.new()
+ identity_value.name = "IdentityFingerprint"
+ identity_value.text = "identity • stored on this device"
+ identity_value.tooltip_text = (
+ "This identity helps other players recognize you between sessions."
+ )
+ identity_value.add_theme_font_size_override("font_size", 12)
+ identity_value.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ account_stack.add_child(identity_value)
+
+ var experience_row := HBoxContainer.new()
+ experience_row.add_theme_constant_override("separation", 9)
+ account_stack.add_child(experience_row)
+ _experience_level = Label.new()
+ _experience_level.custom_minimum_size.x = 64.0
+ _experience_level.add_theme_font_size_override("font_size", 14)
+ _experience_level.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ experience_row.add_child(_experience_level)
+ _experience_progress = ProgressBar.new()
+ _experience_progress.custom_minimum_size = Vector2(210.0, 14.0)
+ _experience_progress.max_value = 1.0
+ _experience_progress.show_percentage = false
+ _experience_progress.add_theme_stylebox_override(
+ "background", UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_PANEL_MID, 7
+ )
+ )
+ _experience_progress.add_theme_stylebox_override(
+ "fill", UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_SELECTED, 7
+ )
+ )
+ experience_row.add_child(_experience_progress)
+ _experience_value = Label.new()
+ _experience_value.add_theme_font_size_override("font_size", 14)
+ _experience_value.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ experience_row.add_child(_experience_value)
+
+ var actions := HBoxContainer.new()
+ actions.alignment = BoxContainer.ALIGNMENT_END
+ actions.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
+ actions.add_theme_constant_override("separation", 7)
+ account_row.add_child(actions)
+ _apply_button = Button.new()
+ _apply_button.text = "apply"
+ _apply_button.custom_minimum_size.x = 72.0
+ UtilityPageStyle.apply_compact_ocean_button(_apply_button)
+ _apply_button.pressed.connect(_apply)
+ actions.add_child(_apply_button)
+ _revert_button = Button.new()
+ _revert_button.text = "revert"
+ _revert_button.custom_minimum_size.x = 76.0
+ UtilityPageStyle.apply_compact_ocean_button(_revert_button)
+ _revert_button.pressed.connect(_revert)
+ actions.add_child(_revert_button)
+ var defaults_button := Button.new()
+ defaults_button.text = "defaults"
+ defaults_button.custom_minimum_size.x = 82.0
+ UtilityPageStyle.apply_compact_ocean_button(defaults_button)
+ defaults_button.pressed.connect(_show_confirmation.bind("defaults"))
+ actions.add_child(defaults_button)
+
+ var body_panel := PanelContainer.new()
+ body_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ body_panel.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.row_style(false)
+ )
+ layout.add_child(body_panel)
+ var body_margin := MarginContainer.new()
+ body_margin.add_theme_constant_override("margin_left", 14)
+ body_margin.add_theme_constant_override("margin_top", 10)
+ body_margin.add_theme_constant_override("margin_right", 14)
+ body_margin.add_theme_constant_override("margin_bottom", 10)
+ body_panel.add_child(body_margin)
+ var body := HBoxContainer.new()
+ body.alignment = BoxContainer.ALIGNMENT_BEGIN
+ body.add_theme_constant_override("separation", 16)
+ body_margin.add_child(body)
+ _category_list = VBoxContainer.new()
+ _category_list.custom_minimum_size = Vector2(120, 0)
+ _category_list.add_theme_constant_override("separation", 5)
+ body.add_child(_category_list)
+ _option_list = VBoxContainer.new()
+ _option_list.custom_minimum_size = Vector2(360, 0)
+ _option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _option_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _option_list.add_theme_constant_override("separation", 5)
+ body.add_child(_option_list)
+ var body_spacer := Control.new()
+ body_spacer.custom_minimum_size.x = 12.0
+ body_spacer.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
+ body.add_child(body_spacer)
+ var preview_stack := VBoxContainer.new()
+ preview_stack.custom_minimum_size.x = 260.0
+ preview_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ preview_stack.alignment = BoxContainer.ALIGNMENT_CENTER
+ preview_stack.add_theme_constant_override("separation", 7)
+ body.add_child(preview_stack)
+ var preview_frame := PanelContainer.new()
+ preview_frame.custom_minimum_size = Vector2(260.0, 0.0)
+ preview_frame.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ preview_frame.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_FIELD, 18
+ )
+ )
+ preview_stack.add_child(preview_frame)
+ var preview_layer := Control.new()
+ preview_layer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ preview_layer.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ preview_layer.mouse_filter = Control.MOUSE_FILTER_PASS
+ preview_frame.add_child(preview_layer)
+ _preview = preload("res://ui/profile_preview.tscn").instantiate()
+ _preview.custom_minimum_size = Vector2(260.0, 0.0)
+ _preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _preview.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ preview_layer.add_child(_preview)
+ _preview.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ var reset_view := Button.new()
+ reset_view.text = "↶"
+ reset_view.tooltip_text = "reset view"
+ reset_view.custom_minimum_size = Vector2(36.0, 36.0)
+ reset_view.position = Vector2(-8.0, -8.0)
+ reset_view.z_index = 2
+ reset_view.pressed.connect(_preview.reset_view)
+ UtilityPageStyle.apply_compact_ocean_button(reset_view)
+ reset_view.add_theme_font_size_override("font_size", 22)
+ preview_layer.add_child(reset_view)
+
+ _discard_confirmation = PanelContainer.new()
+ _discard_confirmation.visible = false
+ _discard_confirmation.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
+ _discard_confirmation.custom_minimum_size = Vector2(430, 190)
+ _discard_confirmation.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_PANEL_MID, 14
+ )
+ )
+ add_child(_discard_confirmation)
+ var confirm_stack := VBoxContainer.new()
+ confirm_stack.alignment = BoxContainer.ALIGNMENT_CENTER
+ confirm_stack.add_theme_constant_override("separation", 16)
+ _discard_confirmation.add_child(confirm_stack)
+ _confirmation_label = Label.new()
+ _confirmation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ confirm_stack.add_child(_confirmation_label)
+ var confirm_buttons := HBoxContainer.new()
+ confirm_buttons.alignment = BoxContainer.ALIGNMENT_CENTER
+ confirm_stack.add_child(confirm_buttons)
+ _confirmation_confirm = Button.new()
+ _confirmation_confirm.pressed.connect(_confirm_pending_action)
+ UtilityPageStyle.apply_ocean_button(_confirmation_confirm)
+ confirm_buttons.add_child(_confirmation_confirm)
+ _keep_editing_button = Button.new()
+ _keep_editing_button.text = "keep editing"
+ _keep_editing_button.pressed.connect(func() -> void:
+ _discard_confirmation.visible = false
+ _name_edit.grab_focus()
+ )
+ UtilityPageStyle.apply_ocean_button(_keep_editing_button)
+ confirm_buttons.add_child(_keep_editing_button)
+
+ _voice_preview = AnimaleseVoiceType.new()
+ _voice_preview.name = "ProfileVoicePreview"
+ add_child(_voice_preview)
+ _build_categories()
+
+
+func _build_categories() -> void:
+ for child: Node in _category_list.get_children():
+ child.queue_free()
+ var category_ids: Array[String] = []
+ for appearance_category: String in CharacterCustomizationCatalog.CATEGORY_IDS:
+ category_ids.append(appearance_category)
+ category_ids.append(VOICE_CATEGORY_ID)
+ for category_id: String in category_ids:
+ var button := Button.new()
+ button.text = _category_label(category_id)
+ button.toggle_mode = true
+ button.custom_minimum_size.x = 120.0
+ button.button_pressed = category_id == _category_id
+ button.pressed.connect(_select_category.bind(category_id))
+ UtilityPageStyle.apply_compact_ocean_button(button)
+ _category_list.add_child(button)
+ _refresh_options()
+
+
+func _select_category(category_id: String) -> void:
+ _category_id = category_id
+ for child: Node in _category_list.get_children():
+ var button := child as Button
+ if button != null:
+ button.button_pressed = button.text == (
+ _category_label(category_id)
+ )
+ _refresh_options()
+
+
+func _refresh_options() -> void:
+ for child: Node in _option_list.get_children():
+ child.queue_free()
+ var title := Label.new()
+ title.text = _category_label(_category_id)
+ title.add_theme_font_size_override("font_size", 18)
+ title.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(title)
+ if _category_id == VOICE_CATEGORY_ID:
+ _build_voice_options()
+ return
+ if _category_id == CharacterCustomizationCatalog.SCALE_CATEGORY_ID:
+ _build_scale_option()
+ return
+ var options: Array = CharacterCustomizationCatalog.options_for(_category_id)
+ if options.is_empty():
+ var empty := Label.new()
+ empty.text = "More options coming later."
+ empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _option_list.add_child(empty)
+ return
+ if _category_id == "fur_pattern":
+ _build_fur_color_options(options)
+ return
+ if _category_id in CharacterCustomizationCatalog.FEATURE_CATEGORIES:
+ _build_feature_preview_options(options)
+ return
+ for option: Dictionary in options:
+ var option_id := str(option["id"])
+ var button := Button.new()
+ button.text = "×" if option_id == "none" else str(option["label"])
+ button.tooltip_text = "none" if option_id == "none" else str(
+ option["label"]
+ )
+ button.toggle_mode = true
+ button.custom_minimum_size.x = 170.0
+ button.button_pressed = _draft_appearance.get(_category_id) == option_id
+ button.pressed.connect(_select_option.bind(_category_id, option_id))
+ UtilityPageStyle.apply_compact_ocean_button(button)
+ if option_id == "none":
+ button.add_theme_font_size_override("font_size", 24)
+ _option_list.add_child(button)
+
+
+func _category_label(category_id: String) -> String:
+ return (
+ "voice"
+ if category_id == VOICE_CATEGORY_ID
+ else CharacterCustomizationCatalog.category_label(category_id)
+ )
+
+
+func _build_voice_options() -> void:
+ var description := Label.new()
+ description.text = "Choose how your character sounds in chat."
+ description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ description.add_theme_font_size_override("font_size", 12)
+ description.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _option_list.add_child(description)
+ var grid := GridContainer.new()
+ grid.columns = 3
+ grid.add_theme_constant_override("h_separation", 6)
+ grid.add_theme_constant_override("v_separation", 4)
+ grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _option_list.add_child(grid)
+ for option: Dictionary in VoiceProfilesType.OPTIONS:
+ var option_id := str(option.get("id", ""))
+ var button := Button.new()
+ button.text = str(option.get("label", option_id))
+ button.toggle_mode = true
+ button.custom_minimum_size = Vector2(108.0, 32.0)
+ button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ button.button_pressed = _draft_voice_id == option_id
+ button.pressed.connect(_select_voice_option.bind(option_id))
+ UtilityPageStyle.apply_compact_ocean_button(button)
+ grid.add_child(button)
+ var divider := HSeparator.new()
+ divider.custom_minimum_size.y = 2.0
+ _option_list.add_child(divider)
+ var speed_title := Label.new()
+ speed_title.text = "speech speed"
+ speed_title.add_theme_font_size_override("font_size", 13)
+ speed_title.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(speed_title)
+ var speed_description := Label.new()
+ speed_description.text = "Controls chat speech on this device only."
+ speed_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ speed_description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ speed_description.add_theme_font_size_override("font_size", 12)
+ speed_description.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _option_list.add_child(speed_description)
+ var speed_grid := GridContainer.new()
+ speed_grid.columns = 3
+ speed_grid.add_theme_constant_override("h_separation", 6)
+ speed_grid.add_theme_constant_override("v_separation", 4)
+ speed_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _option_list.add_child(speed_grid)
+ for option: Dictionary in VoiceProfilesType.SPEED_OPTIONS:
+ var speed_id := str(option.get("id", ""))
+ var speed_button := Button.new()
+ speed_button.text = str(option.get("label", speed_id))
+ speed_button.tooltip_text = "%d characters per second" % roundi(
+ float(option.get("characters_per_second", 28.0))
+ )
+ speed_button.toggle_mode = true
+ speed_button.custom_minimum_size = Vector2(108.0, 32.0)
+ speed_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ speed_button.button_pressed = _draft_speech_speed_id == speed_id
+ speed_button.pressed.connect(
+ _select_speech_speed_option.bind(speed_id)
+ )
+ UtilityPageStyle.apply_compact_ocean_button(speed_button)
+ speed_grid.add_child(speed_button)
+ var call_divider := HSeparator.new()
+ call_divider.custom_minimum_size.y = 2.0
+ _option_list.add_child(call_divider)
+ var call_title := Label.new()
+ call_title.text = "call"
+ call_title.add_theme_font_size_override("font_size", 13)
+ call_title.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(call_title)
+ var call_description := Label.new()
+ call_description.text = "Press G to make this sound."
+ call_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ call_description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ call_description.add_theme_font_size_override("font_size", 12)
+ call_description.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _option_list.add_child(call_description)
+ var call_grid := GridContainer.new()
+ call_grid.columns = 2
+ call_grid.add_theme_constant_override("h_separation", 8)
+ call_grid.add_theme_constant_override("v_separation", 8)
+ _option_list.add_child(call_grid)
+ for option: Dictionary in VoiceProfilesType.CALL_OPTIONS:
+ var call_id := str(option.get("id", ""))
+ var call_button := Button.new()
+ call_button.text = str(option.get("label", call_id))
+ call_button.toggle_mode = true
+ call_button.custom_minimum_size = Vector2(108.0, 32.0)
+ call_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ call_button.button_pressed = _draft_call_id == call_id
+ call_button.pressed.connect(_select_call_option.bind(call_id))
+ UtilityPageStyle.apply_compact_ocean_button(call_button)
+ call_grid.add_child(call_button)
+
+
+func _select_voice_option(voice_id: String) -> void:
+ if not VoiceProfilesType.is_valid(voice_id):
+ return
+ _draft_voice_id = voice_id
+ _dirty = _draft_differs()
+ _refresh_options()
+ _refresh_actions()
+ _play_voice_preview()
+
+
+func _select_speech_speed_option(speed_id: String) -> void:
+ if not VoiceProfilesType.is_valid_speed(speed_id):
+ return
+ _draft_speech_speed_id = speed_id
+ _dirty = _draft_differs()
+ _refresh_options()
+ _refresh_actions()
+ _play_voice_preview()
+
+
+func _select_call_option(call_id: String) -> void:
+ if not VoiceProfilesType.is_valid_call(call_id):
+ return
+ _draft_call_id = call_id
+ _dirty = _draft_differs()
+ _refresh_options()
+ _refresh_actions()
+
+
+func _play_voice_preview() -> void:
+ if _voice_preview_tween != null and _voice_preview_tween.is_valid():
+ _voice_preview_tween.kill()
+ _voice_preview_tween = _voice_preview.speak_text(
+ self,
+ "hello there!",
+ "profile-preview",
+ _draft_voice_id,
+ VoiceProfilesType.speed_for(_draft_speech_speed_id),
+ )
+
+
+func _build_scale_option() -> void:
+ var description := Label.new()
+ description.text = "Adjust your character's visual size."
+ description.add_theme_font_size_override("font_size", 14)
+ description.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _option_list.add_child(description)
+
+ var value_row := HBoxContainer.new()
+ value_row.add_theme_constant_override("separation", 10)
+ value_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _option_list.add_child(value_row)
+
+ var small_label := Label.new()
+ small_label.text = "small"
+ small_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ small_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ value_row.add_child(small_label)
+
+ var slider := HSlider.new()
+ slider.name = "CharacterScaleSlider"
+ slider.min_value = CharacterCustomizationCatalog.MIN_CHARACTER_SCALE
+ slider.max_value = CharacterCustomizationCatalog.MAX_CHARACTER_SCALE
+ slider.step = CharacterCustomizationCatalog.CHARACTER_SCALE_STEP
+ slider.custom_minimum_size = Vector2(220.0, 40.0)
+ slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ slider.tooltip_text = "character size"
+ slider.value = CharacterCustomizationCatalog.character_scale(
+ _draft_appearance.get(
+ CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
+ CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
+ )
+ )
+ slider.value_changed.connect(_select_scale)
+ value_row.add_child(slider)
+
+ var large_label := Label.new()
+ large_label.text = "large"
+ large_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ large_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ value_row.add_child(large_label)
+
+ _scale_value_label = Label.new()
+ _scale_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _scale_value_label.add_theme_font_size_override("font_size", 20)
+ _scale_value_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(_scale_value_label)
+ _update_scale_value_label(float(slider.value))
+
+ var gameplay_note := Label.new()
+ gameplay_note.text = "Movement and collision stay the same."
+ gameplay_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ gameplay_note.add_theme_font_size_override("font_size", 12)
+ gameplay_note.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _option_list.add_child(gameplay_note)
+
+
+func _build_feature_preview_options(_options: Array) -> void:
+ var scroll := ScrollContainer.new()
+ scroll.custom_minimum_size = Vector2(190.0, 0.0)
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
+ _option_list.add_child(scroll)
+ var grid := GridContainer.new()
+ grid.columns = OPTION_GRID_COLUMNS
+ grid.add_theme_constant_override("h_separation", 8)
+ grid.add_theme_constant_override("v_separation", 8)
+ grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ scroll.add_child(grid)
+ var selected_id := CharacterCustomizationCatalog.canonical_option_id(
+ _category_id, str(_draft_appearance.get(_category_id, ""))
+ )
+ var groups: Array = CharacterCustomizationCatalog.feature_option_groups(
+ _category_id
+ )
+ var expanded_drawer_id := str(
+ _expanded_feature_drawers.get(_category_id, "")
+ )
+ if expanded_drawer_id.is_empty():
+ for group: Dictionary in groups:
+ var group_options: Array = group.get("options", []) as Array
+ if group_options.size() <= 1:
+ continue
+ var representative_id := str(
+ (group_options[0] as Dictionary).get("id", "")
+ )
+ for option: Dictionary in group_options:
+ if (
+ str(option.get("id", "")) == selected_id
+ and selected_id != representative_id
+ ):
+ expanded_drawer_id = str(group.get("id", ""))
+ _expanded_feature_drawers[_category_id] = (
+ expanded_drawer_id
+ )
+ break
+ if not expanded_drawer_id.is_empty():
+ break
+
+ var animation_key := _feature_drawer_animation_key
+ for group: Dictionary in groups:
+ var group_id := str(group.get("id", ""))
+ var group_options: Array = group.get("options", []) as Array
+ if group_options.is_empty():
+ continue
+ var has_variants := group_options.size() > 1
+ var is_expanded := has_variants and group_id == expanded_drawer_id
+ for option_index: int in range(group_options.size()):
+ if option_index > 0 and not is_expanded:
+ continue
+ var option: Dictionary = group_options[option_index] as Dictionary
+ var button := _build_feature_option_button(
+ option,
+ selected_id,
+ group_id,
+ option_index == 0,
+ has_variants,
+ is_expanded,
+ )
+ grid.add_child(button)
+ if (
+ option_index > 0
+ and animation_key == "%s:%s" % [_category_id, group_id]
+ ):
+ _animate_feature_drawer_button(button)
+ _feature_drawer_animation_key = ""
+
+
+func _build_feature_option_button(
+ option: Dictionary,
+ selected_id: String,
+ drawer_id: String,
+ is_representative: bool,
+ has_variants: bool,
+ is_expanded: bool,
+) -> Button:
+ var option_id := str(option.get("id", ""))
+ var button := Button.new()
+ var is_none := option_id == "none"
+ button.text = "×" if is_none else ""
+ button.tooltip_text = "none" if is_none else str(
+ option.get("label", option_id)
+ )
+ if is_representative and has_variants:
+ button.tooltip_text += (
+ " (hide variants)" if is_expanded else " (show variants)"
+ )
+ button.toggle_mode = true
+ button.button_pressed = selected_id == option_id
+ button.custom_minimum_size = Vector2(84.0, 64.0)
+ button.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ button.clip_contents = true
+ if is_representative and has_variants:
+ button.pressed.connect(
+ _select_feature_drawer.bind(_category_id, drawer_id, option_id)
+ )
+ else:
+ button.pressed.connect(_select_option.bind(_category_id, option_id))
+ UtilityPageStyle.apply_compact_ocean_button(button)
+ button.custom_minimum_size = Vector2(84.0, 64.0)
+ if is_none:
+ button.add_theme_font_size_override("font_size", 28)
+ else:
+ var preview := TextureRect.new()
+ preview.texture = _feature_preview_texture(_category_id, option_id)
+ preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ var preview_height := 50.0
+ var preview_width := clampf(
+ preview_height * PlayerVisualPresenter.feature_uv_aspect(_category_id),
+ 32.0,
+ 74.0,
+ )
+ preview.custom_minimum_size = Vector2(preview_width, preview_height)
+ preview.size = Vector2(preview_width, preview_height)
+ preview.position = Vector2((84.0 - preview_width) * 0.5, 7.0)
+ button.add_child(preview)
+ if is_representative and has_variants:
+ var indicator := Label.new()
+ indicator.text = "<" if is_expanded else ">"
+ indicator.position = Vector2(66.0, 1.0)
+ indicator.size = Vector2(14.0, 16.0)
+ indicator.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ indicator.add_theme_font_size_override("font_size", 13)
+ indicator.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ button.add_child(indicator)
+ return button
+
+
+func _select_feature_drawer(
+ category_id: String,
+ drawer_id: String,
+ option_id: String,
+) -> void:
+ if str(_expanded_feature_drawers.get(category_id, "")) == drawer_id:
+ _expanded_feature_drawers.erase(category_id)
+ else:
+ _expanded_feature_drawers[category_id] = drawer_id
+ _feature_drawer_animation_key = "%s:%s" % [category_id, drawer_id]
+ _select_option(category_id, option_id)
+
+
+func _animate_feature_drawer_button(button: Button) -> void:
+ button.scale = Vector2(0.05, 1.0)
+ var faded := button.modulate
+ faded.a = 0.0
+ button.modulate = faded
+ var tween := button.create_tween()
+ tween.set_parallel(true)
+ tween.tween_property(
+ button, "scale", Vector2.ONE, FEATURE_DRAWER_ANIMATION_SECONDS
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ tween.tween_property(
+ button, "modulate:a", 1.0, FEATURE_DRAWER_ANIMATION_SECONDS
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+
+
+func _feature_preview_texture(
+ category_id: String,
+ option_id: String,
+) -> Texture2D:
+ var cache_key := "%s:%s" % [category_id, option_id]
+ if _feature_preview_cache.has(cache_key):
+ return _feature_preview_cache[cache_key] as Texture2D
+ var source := CharacterCustomizationCatalog.texture_for(
+ category_id, option_id
+ )
+ if source == null:
+ return null
+ var image := source.get_image()
+ var used := image.get_used_rect()
+ if used.size.x <= 0 or used.size.y <= 0:
+ _feature_preview_cache[cache_key] = source
+ return source
+ var padding := 12
+ used.position -= Vector2i(padding, padding)
+ used.size += Vector2i(padding * 2, padding * 2)
+ used = used.intersection(Rect2i(Vector2i.ZERO, image.get_size()))
+ var cropped := image.get_region(used)
+ var preview := ImageTexture.create_from_image(cropped)
+ _feature_preview_cache[cache_key] = preview
+ return preview
+
+
+func _build_fur_color_options(options: Array) -> void:
+ var pattern_label := Label.new()
+ pattern_label.text = "pattern"
+ pattern_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(pattern_label)
+ var pattern_row := HBoxContainer.new()
+ pattern_row.add_theme_constant_override("separation", 8)
+ _option_list.add_child(pattern_row)
+ var selected_style_id := str(_draft_appearance.get(
+ CharacterCustomizationCatalog.FUR_STYLE_ID,
+ CharacterCustomizationCatalog.DEFAULT_FUR_STYLE,
+ ))
+ for pattern_option: Dictionary in CharacterCustomizationCatalog.options_for(
+ CharacterCustomizationCatalog.FUR_STYLE_ID
+ ):
+ var pattern_id := str(pattern_option.get("id", ""))
+ var pattern_button := Button.new()
+ pattern_button.text = str(pattern_option.get("label", pattern_id))
+ pattern_button.toggle_mode = true
+ pattern_button.button_pressed = selected_style_id == pattern_id
+ pattern_button.custom_minimum_size = Vector2(118.0, 32.0)
+ pattern_button.pressed.connect(_select_option.bind(
+ CharacterCustomizationCatalog.FUR_STYLE_ID,
+ pattern_id,
+ ))
+ UtilityPageStyle.apply_compact_ocean_button(pattern_button)
+ pattern_row.add_child(pattern_button)
+
+ var channel_label := Label.new()
+ channel_label.text = "color channel"
+ channel_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(channel_label)
+ var channel_grid := GridContainer.new()
+ channel_grid.columns = 2
+ channel_grid.add_theme_constant_override("h_separation", 8)
+ channel_grid.add_theme_constant_override("v_separation", 8)
+ _option_list.add_child(channel_grid)
+ for color_category_id: String in CharacterCustomizationCatalog.FUR_COLOR_IDS:
+ var selected_color_id := str(_draft_appearance.get(
+ color_category_id,
+ "white",
+ ))
+ var selected_color := CharacterCustomizationCatalog.option_color(
+ color_category_id,
+ selected_color_id,
+ )
+ var channel_button := Button.new()
+ channel_button.text = CharacterCustomizationCatalog.fur_color_label(
+ color_category_id
+ )
+ channel_button.icon = _fur_swatch_icon(selected_color)
+ channel_button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT
+ channel_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
+ channel_button.add_theme_constant_override(
+ "icon_max_width", FUR_CHANNEL_ICON_SIZE
+ )
+ channel_button.add_theme_constant_override("h_separation", 8)
+ channel_button.tooltip_text = "Edit %s: %s" % [
+ CharacterCustomizationCatalog.fur_color_label(color_category_id),
+ _fur_option_label(options, selected_color_id),
+ ]
+ channel_button.toggle_mode = true
+ channel_button.custom_minimum_size = Vector2(140.0, 34.0)
+ channel_button.button_pressed = (
+ _active_fur_color_id == color_category_id
+ )
+ channel_button.pressed.connect(
+ _select_fur_color_slot.bind(color_category_id)
+ )
+ UtilityPageStyle.apply_compact_ocean_button(channel_button)
+ channel_grid.add_child(channel_button)
+
+ _build_fur_palette(_active_fur_color_id, options)
+
+
+func _build_fur_palette(category_id: String, options: Array) -> void:
+ var selected_id: String = CharacterCustomizationCatalog.canonical_option_id(
+ category_id,
+ str(_draft_appearance.get(category_id, "white")),
+ )
+ var label := Label.new()
+ label.text = "%s palette" % CharacterCustomizationCatalog.fur_color_label(
+ category_id
+ )
+ label.add_theme_font_size_override("font_size", 14)
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ _option_list.add_child(label)
+
+ var grid := GridContainer.new()
+ grid.columns = FUR_PALETTE_GRID_COLUMNS
+ grid.add_theme_constant_override("h_separation", 6)
+ grid.add_theme_constant_override("v_separation", 6)
+ _option_list.add_child(grid)
+ for option: Dictionary in options:
+ var option_id: String = str(option.get("id", ""))
+ var color: Color = CharacterCustomizationCatalog.option_color(
+ category_id, option_id
+ )
+ var button := Button.new()
+ button.text = ""
+ button.tooltip_text = "%s: %s" % [
+ CharacterCustomizationCatalog.fur_color_label(category_id),
+ str(option.get("label", option_id)),
+ ]
+ button.toggle_mode = true
+ button.custom_minimum_size = Vector2(30.0, 30.0)
+ button.button_pressed = selected_id == option_id
+ button.pressed.connect(_select_option.bind(category_id, option_id))
+ _apply_fur_swatch_style(button, color, button.button_pressed)
+ grid.add_child(button)
+
+
+func _fur_option_label(options: Array, option_id: String) -> String:
+ for option: Dictionary in options:
+ if str(option.get("id", "")) == option_id:
+ return str(option.get("label", option_id))
+ return option_id
+
+
+func _fur_swatch_icon(color: Color) -> Texture2D:
+ var cache_id := color.to_html(true)
+ if _fur_swatch_icon_cache.has(cache_id):
+ return _fur_swatch_icon_cache[cache_id] as Texture2D
+ var image := Image.create(
+ FUR_CHANNEL_ICON_SIZE,
+ FUR_CHANNEL_ICON_SIZE,
+ false,
+ Image.FORMAT_RGBA8,
+ )
+ var center := Vector2.ONE * (float(FUR_CHANNEL_ICON_SIZE) * 0.5)
+ var radius := float(FUR_CHANNEL_ICON_SIZE) * 0.39
+ for y: int in range(FUR_CHANNEL_ICON_SIZE):
+ for x: int in range(FUR_CHANNEL_ICON_SIZE):
+ var sample_position := Vector2(float(x) + 0.5, float(y) + 0.5)
+ var alpha := clampf(
+ radius + 0.8 - sample_position.distance_to(center),
+ 0.0,
+ 1.0,
+ )
+ image.set_pixel(
+ x,
+ y,
+ Color(color.r, color.g, color.b, color.a * alpha),
+ )
+ var texture := ImageTexture.create_from_image(image)
+ _fur_swatch_icon_cache[cache_id] = texture
+ return texture
+
+
+func _select_fur_color_slot(category_id: String) -> void:
+ if category_id not in CharacterCustomizationCatalog.FUR_COLOR_IDS:
+ return
+ _active_fur_color_id = category_id
+ _refresh_options()
+
+
+func _apply_fur_swatch_style(
+ button: Button,
+ color: Color,
+ selected: bool,
+) -> void:
+ var normal: StyleBoxFlat = UtilityPageStyle.rounded_style(color, 999)
+ var hover: StyleBoxFlat = UtilityPageStyle.rounded_style(
+ color.lightened(0.12), 999
+ )
+ var selected_style: StyleBoxFlat = UtilityPageStyle.rounded_style(color, 999)
+ selected_style.border_color = UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ selected_style.set_border_width_all(3 if selected else 2)
+ button.add_theme_stylebox_override("normal", normal)
+ button.add_theme_stylebox_override("hover", hover)
+ button.add_theme_stylebox_override("pressed", selected_style)
+ button.add_theme_stylebox_override("focus", selected_style)
+
+
+func _select_option(category_id: String, option_id: String) -> void:
+ _draft_appearance[category_id] = option_id
+ _preview.apply_appearance_profile(_draft_appearance)
+ _dirty = _draft_differs()
+ _refresh_options()
+ _refresh_actions()
+
+
+func _select_scale(value: float) -> void:
+ var resolved_scale: float = CharacterCustomizationCatalog.character_scale(
+ value
+ )
+ _draft_appearance[CharacterCustomizationCatalog.SCALE_CATEGORY_ID] = (
+ resolved_scale
+ )
+ _update_scale_value_label(resolved_scale)
+ _preview.apply_appearance_profile(_draft_appearance)
+ _dirty = _draft_differs()
+ _refresh_actions()
+
+
+func _update_scale_value_label(value: float) -> void:
+ if _scale_value_label != null:
+ _scale_value_label.text = "%d%%" % (
+ CharacterCustomizationCatalog.character_scale_percent(value)
+ )
+
+
+func _on_name_changed(value: String) -> void:
+ _draft_name = value
+ _allow_duplicate = false
+ _dirty = _draft_differs()
+ _refresh_actions()
+ _clear_suggestions()
+ if NetworkProfilePreferences.is_valid_display_name(value):
+ _name_status.text = "Checking this game…"
+ _debounce.start()
+ else:
+ _name_status.text = "Use 1–24 plain-text characters."
+
+
+func _request_conflict_check() -> void:
+ if _service != null:
+ _service.request_name_check(_draft_name)
+
+
+func _on_conflict_result(
+ _request_id: String,
+ has_conflict: bool,
+ suggestion_names: PackedStringArray,
+) -> void:
+ _clear_suggestions()
+ if not has_conflict:
+ _name_status.text = "Name is available in this game."
+ return
+ _name_status.text = "That name is already in use in this game."
+ for suggestion: String in suggestion_names:
+ var button := Button.new()
+ button.text = suggestion
+ button.pressed.connect(_use_suggestion.bind(suggestion))
+ UtilityPageStyle.apply_compact_ocean_button(button)
+ _suggestions.add_child(button)
+ var anyway := Button.new()
+ anyway.text = "use anyway"
+ anyway.pressed.connect(func() -> void:
+ _allow_duplicate = true
+ _name_status.text = "Duplicate name allowed for this apply."
+ )
+ UtilityPageStyle.apply_compact_ocean_button(anyway)
+ _suggestions.add_child(anyway)
+
+
+func _on_experience_changed(_total_experience: int, _level: int) -> void:
+ _refresh_experience()
+
+
+func _refresh_experience() -> void:
+ if _experience_level == null:
+ return
+ if _experience == null:
+ _experience_level.text = "level 1"
+ _experience_progress.value = 0.0
+ _experience_value.text = "0 / 100 xp"
+ return
+ var level: int = _experience.get_level()
+ var current: int = _experience.get_experience_in_level()
+ var required: int = _experience.get_experience_for_next_level()
+ _experience_level.text = "level %d" % level
+ _experience_progress.value = _experience.get_level_progress()
+ _experience_value.text = "%d / %d xp" % [current, required]
+ _experience_value.tooltip_text = "%d total xp" % (
+ _experience.get_total_experience()
+ )
+
+
+func _use_suggestion(value: String) -> void:
+ _name_edit.text = value
+ _draft_name = value
+ _allow_duplicate = false
+ _dirty = _draft_differs()
+ _clear_suggestions()
+ _request_conflict_check()
+
+
+func _apply() -> void:
+ if _service == null:
+ return
+ _apply_button.disabled = true
+ _service.apply_profile(
+ _draft_name,
+ _draft_appearance,
+ _allow_duplicate,
+ _draft_voice_id,
+ _draft_speech_speed_id,
+ _draft_call_id,
+ )
+
+
+func _on_apply_finished(accepted: bool, message: String) -> void:
+ _apply_button.disabled = false
+ _name_status.text = message
+ if accepted:
+ _load_persisted()
+
+
+func _load_persisted() -> void:
+ if _service == null:
+ return
+ _persisted_name = _service.get_persisted_name()
+ _persisted_appearance = _service.get_persisted_appearance()
+ _persisted_voice_id = _service.get_persisted_voice_id()
+ _persisted_speech_speed_id = (
+ _service.get_persisted_speech_speed_id()
+ )
+ _persisted_call_id = _service.get_persisted_call_id()
+ _draft_name = _persisted_name
+ _draft_appearance = _persisted_appearance.duplicate(true)
+ _draft_voice_id = _persisted_voice_id
+ _draft_speech_speed_id = _persisted_speech_speed_id
+ _draft_call_id = _persisted_call_id
+ TypewriterRevealType.set_characters_per_second(
+ VoiceProfilesType.speed_for(_persisted_speech_speed_id)
+ )
+ _name_edit.text = _draft_name
+ _preview.apply_appearance_profile(_draft_appearance)
+ _dirty = false
+ _allow_duplicate = false
+ _name_status.text = ""
+ _clear_suggestions()
+ _refresh_options()
+ _refresh_actions()
+
+
+func _revert() -> void:
+ _load_persisted()
+
+
+func _confirm_discard() -> void:
+ _discard_confirmation.visible = false
+ _load_persisted()
+
+
+func _show_confirmation(action: String) -> void:
+ _confirmation_action = action
+ _discard_confirmation.visible = true
+ if action == "defaults":
+ _confirmation_label.text = "Reset the profile draft to defaults?"
+ _confirmation_confirm.text = "reset draft"
+ else:
+ _confirmation_label.text = "Discard unsaved profile changes?"
+ _confirmation_confirm.text = "discard changes"
+ _keep_editing_button.grab_focus()
+
+
+func _confirm_pending_action() -> void:
+ _discard_confirmation.visible = false
+ if _confirmation_action == "defaults":
+ _draft_appearance = CharacterCustomizationCatalog.default_snapshot()
+ _draft_voice_id = VoiceProfilesType.DEFAULT_ID
+ _draft_speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
+ _draft_call_id = VoiceProfilesType.DEFAULT_CALL_ID
+ _preview.apply_appearance_profile(_draft_appearance)
+ _dirty = _draft_differs()
+ _refresh_options()
+ _refresh_actions()
+ else:
+ _confirm_discard()
+ _confirmation_action = ""
+
+
+func _draft_differs() -> bool:
+ return (
+ _draft_name.strip_edges() != _persisted_name
+ or _draft_appearance != _persisted_appearance
+ or _draft_voice_id != _persisted_voice_id
+ or _draft_speech_speed_id != _persisted_speech_speed_id
+ or _draft_call_id != _persisted_call_id
+ )
+
+
+func _refresh_actions() -> void:
+ _apply_button.disabled = (
+ not _dirty
+ or not NetworkProfilePreferences.is_valid_display_name(_draft_name)
+ )
+ _revert_button.disabled = not _dirty
+
+
+func _clear_suggestions() -> void:
+ for child: Node in _suggestions.get_children():
+ child.queue_free()
diff --git a/ui/profile_page.gd.uid b/ui/profile_page.gd.uid
new file mode 100644
index 0000000..752af13
--- /dev/null
+++ b/ui/profile_page.gd.uid
@@ -0,0 +1 @@
+uid://b6c2de8orrwfj
diff --git a/ui/profile_page.tscn b/ui/profile_page.tscn
new file mode 100644
index 0000000..da22b8b
--- /dev/null
+++ b/ui/profile_page.tscn
@@ -0,0 +1,8 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/profile_page.gd" id="1"]
+
+[node name="ProfilePage" type="Control"]
+custom_minimum_size = Vector2(320, 280)
+layout_mode = 0
+script = ExtResource("1")
diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd
new file mode 100644
index 0000000..4239ea0
--- /dev/null
+++ b/ui/profile_preview.gd
@@ -0,0 +1,238 @@
+class_name ProfilePreview
+extends SubViewportContainer
+
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+const FRONT_FACING_YAW: float = PI
+const DEFAULT_CAMERA_DISTANCE: float = 1.9
+const DEFAULT_CAMERA_PITCH: float = 0.12
+const MIN_CAMERA_DISTANCE: float = 1.05
+const MAX_CAMERA_DISTANCE: float = 2.8
+const CAMERA_ZOOM_STEP: float = 0.14
+const CAMERA_TARGET_HEIGHT: float = 0.95
+
+@export_range(0.1, 2.0, 0.05) var drag_sensitivity: float = 0.012
+@export_range(0.1, 4.0, 0.1) var keyboard_speed: float = 1.8
+
+@onready var _preview_root: Node3D = %PreviewRoot
+@onready var _preview_environment: WorldEnvironment = %WorldEnvironment
+@onready var _preview_sun: DirectionalLight3D = %KeyLight
+@onready var _preview_camera: Camera3D = $Viewport/Camera3D
+@onready var _pixelation_material: ShaderMaterial = material as ShaderMaterial
+
+var _dragging: bool = false
+var _camera_yaw: float = 0.0
+var _camera_pitch: float = DEFAULT_CAMERA_PITCH
+var _camera_distance: float = DEFAULT_CAMERA_DISTANCE
+var _visuals: Node3D
+var _controller_mapping_manager: ControllerMappingManagerType
+var _source_environment: WorldEnvironment
+var _source_sun: DirectionalLight3D
+var _world_pixel_size: int = PlayerSettings.DEFAULT_WORLD_PIXEL_SIZE
+
+
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+
+
+func setup_world_lighting(
+ world_environment: WorldEnvironment,
+ world_sun: DirectionalLight3D,
+) -> void:
+ _source_environment = world_environment
+ _source_sun = world_sun
+ _sync_world_lighting()
+
+
+func set_world_pixel_size(pixel_size: int) -> void:
+ _world_pixel_size = clampi(
+ pixel_size,
+ PlayerSettings.MIN_WORLD_PIXEL_SIZE,
+ PlayerSettings.MAX_WORLD_PIXEL_SIZE,
+ )
+ _refresh_pixelation_filter()
+
+
+func _ready() -> void:
+ focus_mode = Control.FOCUS_ALL
+ texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ gui_input.connect(_on_gui_input)
+ resized.connect(_refresh_pixelation_filter)
+ get_window().size_changed.connect(_refresh_pixelation_filter)
+ _configure_preview_lighting()
+ reset_view()
+ _visuals = PlayerVisualPresenter.instantiate_visuals()
+ _preview_root.add_child(_visuals)
+ var rod := _visuals.find_child("FishingRod", true, false) as Node3D
+ if rod != null:
+ rod.visible = false
+ var catch_display := _visuals.find_child("CatchDisplay", true, false) as Node3D
+ if catch_display != null:
+ catch_display.visible = false
+ call_deferred("_refresh_pixelation_filter")
+
+
+func apply_appearance_profile(profile: Dictionary) -> void:
+ if _visuals != null:
+ PlayerVisualPresenter.apply_appearance(_visuals, profile)
+
+
+func reset_view() -> void:
+ _preview_root.rotation.y = FRONT_FACING_YAW
+ _camera_yaw = 0.0
+ _camera_pitch = DEFAULT_CAMERA_PITCH
+ _camera_distance = DEFAULT_CAMERA_DISTANCE
+ _apply_camera_orbit()
+
+
+func _process(delta: float) -> void:
+ _sync_world_lighting()
+ if not has_focus():
+ return
+ var axis := Input.get_axis("ui_left", "ui_right")
+ var right_stick: float = (
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X
+ )
+ if _controller_mapping_manager != null
+ else Input.get_joy_axis(0, JOY_AXIS_RIGHT_X)
+ )
+ if absf(right_stick) > 0.2:
+ axis = right_stick
+ if absf(axis) > 0.1:
+ _rotate(axis * keyboard_speed * delta)
+
+
+func _on_gui_input(event: InputEvent) -> void:
+ if event is InputEventMouseButton and event.shift_pressed:
+ if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
+ _zoom_preview(-CAMERA_ZOOM_STEP)
+ grab_focus()
+ accept_event()
+ return
+ if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
+ _zoom_preview(CAMERA_ZOOM_STEP)
+ grab_focus()
+ accept_event()
+ return
+ if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
+ _dragging = event.pressed
+ if event.pressed:
+ grab_focus()
+ accept_event()
+ elif event is InputEventMouseMotion and _dragging:
+ _rotate(event.relative.x * drag_sensitivity)
+ _camera_pitch = clampf(
+ _camera_pitch + event.relative.y * drag_sensitivity,
+ -0.55,
+ 0.55,
+ )
+ _apply_camera_orbit()
+ accept_event()
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_VISIBILITY_CHANGED and not is_visible_in_tree():
+ _dragging = false
+
+
+func _rotate(amount: float) -> void:
+ _camera_yaw = fposmod(_camera_yaw - amount, TAU)
+ _apply_camera_orbit()
+
+
+func _zoom_preview(amount: float) -> void:
+ if _preview_camera == null:
+ return
+ _camera_distance = clampf(
+ _camera_distance + amount,
+ MIN_CAMERA_DISTANCE,
+ MAX_CAMERA_DISTANCE,
+ )
+ _apply_camera_orbit()
+
+
+func _apply_camera_orbit() -> void:
+ if _preview_camera == null:
+ return
+ var horizontal_distance := cos(_camera_pitch) * _camera_distance
+ _preview_camera.position = Vector3(
+ sin(_camera_yaw) * horizontal_distance,
+ CAMERA_TARGET_HEIGHT + sin(_camera_pitch) * _camera_distance,
+ cos(_camera_yaw) * horizontal_distance,
+ )
+ _preview_camera.look_at(
+ Vector3(0.0, CAMERA_TARGET_HEIGHT, 0.0),
+ Vector3.UP,
+ )
+
+
+func _sync_world_lighting() -> void:
+ # The customization viewport deliberately does not follow gameplay time of
+ # day. Keep this method as a compatibility seam for existing setup callers.
+ pass
+
+
+func _configure_preview_lighting() -> void:
+ if _preview_environment == null or _preview_sun == null:
+ return
+ var environment := Environment.new()
+ environment.background_mode = Environment.BG_COLOR
+ environment.background_color = Color("082431")
+ environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
+ environment.ambient_light_color = Color("b9d8d4")
+ environment.ambient_light_energy = 1.15
+ environment.reflected_light_source = Environment.REFLECTION_SOURCE_DISABLED
+ _preview_environment.environment = environment
+ _preview_sun.rotation_degrees = Vector3(-35.0, -25.0, 0.0)
+ _preview_sun.light_color = Color("fff1d0")
+ _preview_sun.light_energy = 1.1
+ _preview_sun.light_indirect_energy = 0.0
+ _preview_sun.shadow_enabled = false
+
+
+func _refresh_pixelation_filter() -> void:
+ if not is_node_ready() or _pixelation_material == null:
+ return
+ _pixelation_material.set_shader_parameter(
+ "logical_grid_size",
+ Vector2(calculate_pixelated_grid_size(
+ size,
+ Vector2(get_window().size),
+ _world_pixel_size,
+ )),
+ )
+
+
+static func calculate_pixelated_grid_size(
+ control_size: Vector2,
+ window_size: Vector2,
+ pixel_size: int,
+) -> Vector2i:
+ var safe_window_size := Vector2(
+ maxf(window_size.x, 1.0),
+ maxf(window_size.y, 1.0),
+ )
+ var world_grid_size: Vector2i = PlayerSettings.get_world_grid_size(
+ pixel_size,
+ Vector2i(roundi(safe_window_size.x), roundi(safe_window_size.y)),
+ )
+ var world_pixel_extent: float = (
+ safe_window_size.y / float(maxi(world_grid_size.y, 1))
+ )
+ var presentation_scale: float = UIReferencePresentationType.get_scale(
+ safe_window_size
+ )
+ var grid_size: Vector2 = (
+ control_size * presentation_scale / maxf(world_pixel_extent, 0.001)
+ )
+ return Vector2i(
+ maxi(roundi(grid_size.x), 1),
+ maxi(roundi(grid_size.y), 1),
+ )
diff --git a/ui/profile_preview.gd.uid b/ui/profile_preview.gd.uid
new file mode 100644
index 0000000..5377ed7
--- /dev/null
+++ b/ui/profile_preview.gd.uid
@@ -0,0 +1 @@
+uid://3iu5mxqgsjrx
diff --git a/ui/profile_preview.tscn b/ui/profile_preview.tscn
new file mode 100644
index 0000000..27313ec
--- /dev/null
+++ b/ui/profile_preview.tscn
@@ -0,0 +1,38 @@
+[gd_scene load_steps=4 format=3]
+
+[ext_resource type="Script" path="res://ui/profile_preview.gd" id="1"]
+[ext_resource type="Shader" path="res://ui/profile_preview_pixelation.gdshader" id="2_pixelation"]
+
+[sub_resource type="World3D" id="PreviewWorld"]
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_pixelation"]
+shader = ExtResource("2_pixelation")
+
+[node name="ProfilePreview" type="SubViewportContainer"]
+material = SubResource("ShaderMaterial_pixelation")
+custom_minimum_size = Vector2(300, 220)
+texture_filter = 1
+stretch = true
+script = ExtResource("1")
+
+[node name="Viewport" type="SubViewport" parent="."]
+transparent_bg = true
+size = Vector2i(320, 330)
+render_target_update_mode = 4
+world_3d = SubResource("PreviewWorld")
+
+[node name="WorldEnvironment" type="WorldEnvironment" parent="Viewport"]
+unique_name_in_owner = true
+
+[node name="KeyLight" type="DirectionalLight3D" parent="Viewport"]
+unique_name_in_owner = true
+light_energy = 0.1
+shadow_enabled = false
+
+[node name="PreviewRoot" type="Node3D" parent="Viewport"]
+unique_name_in_owner = true
+
+[node name="Camera3D" type="Camera3D" parent="Viewport"]
+unique_name_in_owner = true
+position = Vector3(0, 0.95, 1.9)
+current = true
diff --git a/ui/profile_preview_pixelation.gdshader b/ui/profile_preview_pixelation.gdshader
new file mode 100644
index 0000000..27a4f07
--- /dev/null
+++ b/ui/profile_preview_pixelation.gdshader
@@ -0,0 +1,11 @@
+shader_type canvas_item;
+render_mode unshaded;
+
+uniform vec2 logical_grid_size = vec2(160.0, 165.0);
+
+
+void fragment() {
+ vec2 safe_grid = max(logical_grid_size, vec2(1.0));
+ vec2 snapped_uv = (floor(UV * safe_grid) + vec2(0.5)) / safe_grid;
+ COLOR = texture(TEXTURE, snapped_uv);
+}
diff --git a/ui/profile_preview_pixelation.gdshader.uid b/ui/profile_preview_pixelation.gdshader.uid
new file mode 100644
index 0000000..dbf8e18
--- /dev/null
+++ b/ui/profile_preview_pixelation.gdshader.uid
@@ -0,0 +1 @@
+uid://dlbc0pxkqo028
diff --git a/ui/quick_radial_menu.gd b/ui/quick_radial_menu.gd
new file mode 100644
index 0000000..8ae1720
--- /dev/null
+++ b/ui/quick_radial_menu.gd
@@ -0,0 +1,198 @@
+class_name QuickRadialMenu
+extends Control
+
+signal action_selected(action_id: StringName)
+
+const BubbleButtonScene: PackedScene = preload(
+ "res://ui/components/bubble_menu/bubble_button.tscn"
+)
+const BubbleProfile: BubbleMenuProfile = preload(
+ "res://ui/components/bubble_menu/bubble_menu_profile.tres"
+)
+const RING_RADIUS: float = 172.0
+const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0)
+const CONTROLLER_SELECTION_DEADZONE: float = 0.35
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const ACTIONS: Array[StringName] = [
+ &"stuff",
+ &"logbook",
+ &"fishnet",
+ &"mail",
+ &"profile",
+ &"online",
+ &"paint",
+ &"chat",
+ &"freecam",
+ &"hud",
+]
+const LABELS: Array[String] = [
+ "stuff",
+ "logbook",
+ "fishnet",
+ "mail",
+ "profile",
+ "online",
+ "paint",
+ "chat",
+ "freecam",
+ "hide hud",
+]
+const SECTOR_COUNT: int = 10
+
+var _buttons: Array[BubbleButton] = []
+var _is_open: bool = false
+var _selected_sector: int = 0
+var _controller_selection_mode: bool = false
+var _controller_mapping_manager: ControllerMappingManagerType
+var _hud_hidden: bool = false
+
+
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+
+
+func _ready() -> void:
+ visible = false
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+ for sector: int in SECTOR_COUNT:
+ var bubble: BubbleButton = BubbleButtonScene.instantiate() as BubbleButton
+ bubble.profile = BubbleProfile
+ bubble.focus_mode = Control.FOCUS_NONE
+ bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bubble.text = _label_for_sector(sector)
+ add_child(bubble)
+ _buttons.append(bubble)
+ _apply_selection_styles()
+
+
+func handle_input(event: InputEvent, can_open: bool) -> bool:
+ if not event.is_action("open_quick_actions"):
+ return false
+ var key_event: InputEventKey = event as InputEventKey
+ if key_event != null and key_event.echo:
+ return false
+ if event.is_pressed():
+ if _is_open or not can_open:
+ return _is_open
+ open_menu(event is InputEventJoypadButton)
+ return true
+ if not _is_open:
+ return false
+ var selected: int = _selected_sector
+ close_menu()
+ call_deferred("_emit_selected_action", ACTIONS[selected])
+ return true
+
+
+func open_menu(controller_selection: bool = false) -> void:
+ _is_open = true
+ _selected_sector = 0
+ _controller_selection_mode = controller_selection
+ visible = true
+ _refresh_labels()
+ _layout_bubbles()
+ _apply_selection_styles()
+
+
+func close_menu() -> void:
+ _is_open = false
+ visible = false
+
+
+func is_open() -> bool:
+ return _is_open
+
+
+func set_hud_hidden(hidden: bool) -> void:
+ _hud_hidden = hidden
+ _refresh_labels()
+
+
+func _refresh_labels() -> void:
+ for sector: int in _buttons.size():
+ _buttons[sector].text = _label_for_sector(sector)
+
+
+func _label_for_sector(sector: int) -> String:
+ if ACTIONS[sector] == &"hud":
+ return "show hud" if _hud_hidden else "hide hud"
+ return LABELS[sector]
+
+
+func _emit_selected_action(action_id: StringName) -> void:
+ action_selected.emit(action_id)
+
+
+func _process(_delta: float) -> void:
+ if not _is_open:
+ return
+ _layout_bubbles()
+ _update_selection()
+
+
+func _layout_bubbles() -> void:
+ var center: Vector2 = size * 0.5
+ for sector: int in SECTOR_COUNT:
+ var angle: float = -PI * 0.5 + TAU * float(sector) / float(SECTOR_COUNT)
+ var bubble_center: Vector2 = center + Vector2.from_angle(angle) * RING_RADIUS
+ var bubble: BubbleButton = _buttons[sector]
+ bubble.position = bubble_center - BUBBLE_SIZE * 0.5
+ bubble.size = BUBBLE_SIZE
+ bubble.pivot_offset = BUBBLE_SIZE * 0.5
+
+
+func _update_selection() -> void:
+ var stick: Vector2 = _get_selection_stick()
+ if stick.length() >= CONTROLLER_SELECTION_DEADZONE:
+ _select_sector_from_offset(stick)
+ return
+ if _controller_selection_mode:
+ return
+ _update_mouse_selection()
+
+
+func _get_selection_stick() -> Vector2:
+ if _controller_mapping_manager != null:
+ return Vector2(
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_X
+ ),
+ _controller_mapping_manager.get_role_axis(
+ ControllerMappingManagerType.ROLE_RIGHT_STICK_Y
+ ),
+ )
+ return Vector2(
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_X),
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y),
+ )
+
+
+func _update_mouse_selection() -> void:
+ var offset: Vector2 = get_local_mouse_position() - size * 0.5
+ if offset.length() < 24.0:
+ return
+ _select_sector_from_offset(offset)
+
+
+func _select_sector_from_offset(offset: Vector2) -> void:
+ var half_sector: float = TAU / (float(SECTOR_COUNT) * 2.0)
+ var angle: float = fposmod(offset.angle() + PI * 0.5 + half_sector, TAU)
+ var sector: int = int(floor(angle / (TAU / float(SECTOR_COUNT))))
+ if sector == _selected_sector:
+ return
+ _selected_sector = sector
+ _apply_selection_styles()
+
+
+func _apply_selection_styles() -> void:
+ for sector: int in _buttons.size():
+ var bubble: BubbleButton = _buttons[sector]
+ bubble.apply_profile()
+ if sector == _selected_sector:
+ bubble.add_theme_stylebox_override(
+ "normal", BubbleProfile.make_hover_style()
+ )
diff --git a/ui/quick_radial_menu.gd.uid b/ui/quick_radial_menu.gd.uid
new file mode 100644
index 0000000..ee4327f
--- /dev/null
+++ b/ui/quick_radial_menu.gd.uid
@@ -0,0 +1 @@
+uid://5nr1c8x881sw
diff --git a/ui/quick_radial_menu.tscn b/ui/quick_radial_menu.tscn
new file mode 100644
index 0000000..66a0f45
--- /dev/null
+++ b/ui/quick_radial_menu.tscn
@@ -0,0 +1,14 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/quick_radial_menu.gd" id="1_quick"]
+
+[node name="QuickRadialMenu" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+z_index = 90
+script = ExtResource("1_quick")
diff --git a/ui/settings/settings_bubble_page.gd b/ui/settings/settings_bubble_page.gd
new file mode 100644
index 0000000..dd01309
--- /dev/null
+++ b/ui/settings/settings_bubble_page.gd
@@ -0,0 +1,233 @@
+class_name SettingsBubblePage
+extends Control
+
+const ControllerFocusNavigationType = preload(
+ "res://ui/controller_focus_navigation.gd"
+)
+
+@export var page_id: StringName
+@export var cluster_path: NodePath = ^"BubbleCluster"
+@export var bubble_paths: Array[NodePath] = []
+@export var focus_paths: Array[NodePath] = []
+@export var initial_focus_path: NodePath
+@export var back_focus_path: NodePath
+@export var maximum_layout_size: Vector2 = Vector2(720.0, 520.0)
+@export var compact_maximum_layout_size: Vector2 = Vector2.ZERO
+@export var compact_width_threshold: float = 680.0
+@export var compact_height_threshold: float = 500.0
+@export_range(0.0, 128.0, 1.0) var transition_safe_margin: float = 24.0
+
+var _cluster: BubbleCluster
+var _bubbles: Array[BubbleButton] = []
+var _focus_controls: Array[Control] = []
+var _transition: Tween
+var _transition_generation: int = 0
+var _resting_cluster_position: Vector2 = Vector2.ZERO
+var _is_transitioning: bool = false
+
+
+func _ready() -> void:
+ _cluster = get_node(cluster_path) as BubbleCluster
+ for path: NodePath in bubble_paths:
+ var bubble := get_node(path) as BubbleButton
+ if bubble != null:
+ _bubbles.append(bubble)
+ for path: NodePath in focus_paths:
+ var focus_control := get_node(path) as Control
+ if focus_control != null:
+ _focus_controls.append(focus_control)
+ _cluster.configure(_bubbles)
+ _configure_focus_order()
+ resized.connect(_update_layout)
+ call_deferred("_update_layout")
+ set_process(false)
+
+
+func show_page(should_focus: bool = true) -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ show()
+ _is_transitioning = false
+ _cluster.position = _resting_cluster_position
+ set_process(true)
+ _set_interactive(true)
+ var paper := get_node_or_null("Paper") as Control
+ if paper != null:
+ UtilityPageStyle.animate_in(paper)
+ if should_focus:
+ focus_initial()
+
+
+func transition_out(
+ completed: Callable,
+ _duration_override: float = -1.0,
+) -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ _is_transitioning = true
+ _set_interactive(false)
+ var paper := get_node_or_null("Paper") as Control
+ if paper != null:
+ UtilityPageStyle.animate_out(paper, Callable())
+ set_process(true)
+ var target_y: float = -_cluster.size.y - transition_safe_margin
+ var duration := UIMotion.bubble_duration(
+ target_y - _cluster.position.y
+ )
+ var generation: int = _transition_generation
+ _transition = create_tween()
+ _transition.tween_property(
+ _cluster,
+ "position:y",
+ target_y,
+ duration
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _transition.finished.connect(
+ _finish_transition_out.bind(generation, completed),
+ CONNECT_ONE_SHOT
+ )
+
+
+func transition_in(
+ should_focus: bool,
+ completed: Callable,
+ _duration_override: float = -1.0,
+) -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ show()
+ _is_transitioning = true
+ _set_interactive(false)
+ set_process(true)
+ _cluster.position = Vector2(
+ _resting_cluster_position.x,
+ size.y + transition_safe_margin
+ )
+ var paper := get_node_or_null("Paper") as Control
+ if paper != null:
+ UtilityPageStyle.animate_in(paper)
+ var duration := UIMotion.bubble_duration(
+ _cluster.position.y - _resting_cluster_position.y
+ )
+ var generation: int = _transition_generation
+ _transition = create_tween()
+ _transition.tween_property(
+ _cluster,
+ "position",
+ _resting_cluster_position,
+ duration
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _transition.finished.connect(
+ _finish_transition_in.bind(generation, should_focus, completed),
+ CONNECT_ONE_SHOT
+ )
+
+
+func hide_page() -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ _is_transitioning = false
+ _set_interactive(false)
+ set_process(false)
+ hide()
+ if _cluster != null:
+ _cluster.position = _resting_cluster_position
+
+
+func focus_initial() -> void:
+ var initial := get_node_or_null(initial_focus_path) as Control
+ if initial != null and initial.visible and initial.focus_mode != Control.FOCUS_NONE:
+ initial.grab_focus()
+
+
+func focus_back() -> void:
+ var back := get_node_or_null(back_focus_path) as Control
+ if back != null and back.visible and back.focus_mode != Control.FOCUS_NONE:
+ back.grab_focus()
+
+
+func get_page_id() -> StringName:
+ return page_id
+
+
+func _process(delta: float) -> void:
+ if visible:
+ _cluster.advance_motion(delta)
+
+
+func _update_layout() -> void:
+ if not is_node_ready() or _cluster == null:
+ return
+ var compact: bool = false
+ var layout_maximum: Vector2 = maximum_layout_size
+ if compact and compact_maximum_layout_size != Vector2.ZERO:
+ layout_maximum = compact_maximum_layout_size
+ var field_size := Vector2(
+ minf(size.x, layout_maximum.x),
+ minf(size.y, layout_maximum.y)
+ )
+ field_size.x = maxf(1.0, field_size.x)
+ field_size.y = maxf(1.0, field_size.y)
+ _resting_cluster_position = (size - field_size) * 0.5
+ if not _is_transitioning:
+ _cluster.position = _resting_cluster_position
+ _cluster.size = field_size
+ _cluster.apply_layout(field_size, compact)
+ ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
+
+
+func _configure_focus_order() -> void:
+ for bubble: BubbleButton in _bubbles:
+ bubble.focus_mode = Control.FOCUS_NONE
+ ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
+
+
+func _set_interactive(interactive: bool) -> void:
+ mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ for focus_control: Control in _focus_controls:
+ focus_control.focus_mode = (
+ Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
+ )
+ focus_control.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _finish_transition_out(generation: int, completed: Callable) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _transition = null
+ _is_transitioning = false
+ hide()
+ _cluster.position = _resting_cluster_position
+ if completed.is_valid():
+ completed.call()
+
+
+func _finish_transition_in(
+ generation: int,
+ should_focus: bool,
+ completed: Callable,
+) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _transition = null
+ _is_transitioning = false
+ _cluster.position = _resting_cluster_position
+ set_process(true)
+ _set_interactive(true)
+ if should_focus:
+ focus_initial()
+ completed.call()
+
+
+func _cancel_transition() -> void:
+ if _transition != null:
+ _transition.kill()
+ _transition = null
diff --git a/ui/settings/settings_bubble_page.gd.uid b/ui/settings/settings_bubble_page.gd.uid
new file mode 100644
index 0000000..77b26e5
--- /dev/null
+++ b/ui/settings/settings_bubble_page.gd.uid
@@ -0,0 +1 @@
+uid://cwgitrlhjkepy
diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd
index d31cb19..4a7ec9b 100644
--- a/ui/settings_panel.gd
+++ b/ui/settings_panel.gd
@@ -1,63 +1,780 @@
class_name SettingsPanel
-extends PanelContainer
+extends Control
+const MAX_PANEL_SIZE: Vector2 = Vector2(960.0, 700.0)
+const PANEL_EDGE_MARGIN: float = 16.0
+const PAGE_ROOT: StringName = &"root"
+const PAGE_DISPLAY: StringName = &"display"
+const PAGE_SOUND: StringName = &"sound"
+const PAGE_CONTROLS: StringName = &"controls"
+const PAGE_ACCESSIBILITY: StringName = &"accessibility"
+const PAGE_DATA: StringName = &"data"
+const PIXELATION_NAMES: PackedStringArray = [
+ "legible",
+ "cute",
+ "retro",
+ "hardcore",
+ "wtf",
+]
const SettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
+const ControllerMappingManagerType = preload(
+ "res://settings/controller_mapping_manager.gd"
+)
+const ControllerMappingPanelType = preload(
+ "res://ui/controller_mapping_panel.gd"
+)
signal applied
signal closed
+signal closing
+signal opened
+signal crisp_reset_focus_requested
+signal panel_visibility_changed(is_visible: bool)
+signal navigation_transition_started
-@onready var _auto_click_toggle: CheckButton = %AutoClickToggle
-@onready var _auto_click_interval: HSlider = %AutoClickInterval
-@onready var _auto_click_interval_value: Label = %AutoClickIntervalValue
-@onready var _mouse_sensitivity: HSlider = %MouseSensitivity
-@onready var _mouse_sensitivity_value: Label = %MouseSensitivityValue
-@onready var _controller_sensitivity: HSlider = %ControllerSensitivity
-@onready var _controller_sensitivity_value: Label = %ControllerSensitivityValue
-@onready var _invert_y_toggle: CheckButton = %InvertYToggle
+enum PresentationMode {
+ TITLE_EMBEDDED,
+ GAMEPLAY_MODAL,
+}
+
+@onready var _root_page: SettingsBubblePage = %RootPage
+@onready var _display_page: SettingsBubblePage = %DisplayPage
+@onready var _sound_page: SettingsBubblePage = %SoundPage
+@onready var _controls_page: SettingsBubblePage = %ControlsPage
+@onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage
+@onready var _data_page: SettingsBubblePage = %DataPage
@onready var _feedback: Label = %SettingsFeedback
+@onready var _world_value: BubbleButton = %WorldValue
+@onready var _ui_value: BubbleButton = %UIValue
+@onready var _chat_dock: BubbleButton = %ChatDock
+@onready var _chat_mode: BubbleButton = %ChatMode
+@onready var _paint_dock: BubbleButton = %PaintDock
+@onready var _fullscreen_toggle: BubbleButton = %FullscreenToggle
+@onready var _mouse_value: BubbleButton = %MouseValue
+@onready var _controller_value: BubbleButton = %ControllerValue
+@onready var _invert_y_toggle: BubbleButton = %InvertYToggle
+@onready var _on_screen_keyboard_toggle: BubbleButton = %OnScreenKeyboardToggle
+@onready var _auto_click_toggle: BubbleButton = %AutoClickToggle
+@onready var _auto_click_interval: BubbleButton = %AutoClickIntervalValue
+@onready var _master_volume_slider: HSlider = %MasterVolumeSlider
+@onready var _music_volume_slider: HSlider = %MusicVolumeSlider
+@onready var _effects_volume_slider: HSlider = %EffectsVolumeSlider
+@onready var _environment_volume_slider: HSlider = %EnvironmentVolumeSlider
+@onready var _master_volume_value: Label = %MasterVolumeValue
+@onready var _music_volume_value: Label = %MusicVolumeValue
+@onready var _effects_volume_value: Label = %EffectsVolumeValue
+@onready var _environment_volume_value: Label = %EnvironmentVolumeValue
+
+@onready var _world_options: Array[BubbleButton] = [
+ %WorldLegible,
+ %WorldCute,
+ %WorldRetro,
+ %WorldHardcore,
+ %WorldWtf,
+]
+@onready var _ui_options: Array[BubbleButton] = [
+ %UILegible,
+ %UICute,
+ %UIRetro,
+ %UIHardcore,
+ %UIWtf,
+]
+
var _settings_manager: SettingsManagerType
+var _effective_ui_pixel_size: int = PlayerSettings.DEFAULT_UI_PIXEL_SIZE
+var _page_stack: Array[StringName] = []
+var _pages: Dictionary[StringName, SettingsBubblePage] = {}
+var _page_transition_generation: int = 0
+var _page_transition_active: bool = false
+var _presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL
+var _animate_gameplay_host_transitions: bool = false
+var _auto_click_enabled: bool = false
+var _auto_click_interval_value: float = 0.20
+var _mouse_sensitivity: float = 0.005
+var _controller_sensitivity: float = 2.5
+var _invert_camera_y: bool = false
+var _on_screen_keyboard_enabled: bool = false
+var _master_volume: float = 1.0
+var _music_volume: float = 1.0
+var _effects_volume: float = 1.0
+var _environment_volume: float = 1.0
+var _network_profile: NetworkProfilePreferences
+var _network_session: NetworkSession
+var _data_root: PlayerDataRoot
+var _identity_backups: IdentityBackupService
+var _player_identity: PlayerIdentityStore
+var _host_identity: HostIdentityStore
+var _interface_fonts: InterfaceFontController
+var _controller_mapping_manager: ControllerMappingManagerType
+var _controller_mapping_panel: ControllerMappingPanelType
+var _data_folder_dialog: FileDialog
+var _backup_file_dialog: FileDialog
+var _export_file_dialog: FileDialog
+var _passphrase_dialog: ConfirmationDialog
+var _passphrase_entry: LineEdit
+var _passphrase_confirm: LineEdit
+var _pending_identity_operation := ""
+var _pending_identity_type := ""
+var _pending_identity_path := ""
+var _pending_import_data: Dictionary = {}
func _ready() -> void:
+ _pages = {
+ PAGE_ROOT: _root_page,
+ PAGE_DISPLAY: _display_page,
+ PAGE_SOUND: _sound_page,
+ PAGE_CONTROLS: _controls_page,
+ PAGE_ACCESSIBILITY: _accessibility_page,
+ PAGE_DATA: _data_page,
+ }
+ %DisplayCategory.pressed.connect(_push_page.bind(PAGE_DISPLAY))
+ %SoundCategory.pressed.connect(_push_page.bind(PAGE_SOUND))
+ %ControlsCategory.pressed.connect(_push_page.bind(PAGE_CONTROLS))
+ %AccessibilityCategory.pressed.connect(
+ _push_page.bind(PAGE_ACCESSIBILITY)
+ )
+ %DataCategory.pressed.connect(_push_page.bind(PAGE_DATA))
%ApplySettingsButton.pressed.connect(_apply_settings)
- %CancelSettingsButton.pressed.connect(close_panel)
- _auto_click_interval.value_changed.connect(_refresh_value_labels)
- _mouse_sensitivity.value_changed.connect(_refresh_value_labels)
- _controller_sensitivity.value_changed.connect(_refresh_value_labels)
+ %RootBackButton.pressed.connect(close_panel)
+ %DisplayBackButton.pressed.connect(handle_back)
+ %SoundBackButton.pressed.connect(handle_back)
+ %ControlsBackButton.pressed.connect(handle_back)
+ %ControllerMapping.pressed.connect(_open_controller_mapping)
+ %AccessibilityBackButton.pressed.connect(handle_back)
+ %DataBackButton.pressed.connect(handle_back)
+ %RootBackButton.gui_input.connect(_on_back_bubble_gui_input)
+ %DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input)
+ %SoundBackButton.gui_input.connect(_on_back_bubble_gui_input)
+ %ControlsBackButton.gui_input.connect(_on_back_bubble_gui_input)
+ %AccessibilityBackButton.gui_input.connect(_on_back_bubble_gui_input)
+ %DataBackButton.gui_input.connect(_on_back_bubble_gui_input)
+ %OpenDataFolder.pressed.connect(_open_data_folder)
+ %ChangeDataFolder.pressed.connect(_choose_data_folder)
+ %ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player"))
+ %ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player"))
+ %CopyPlayerFingerprint.pressed.connect(_copy_player_fingerprint)
+ %ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host"))
+ %ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host"))
+ for index: int in _world_options.size():
+ _world_options[index].pressed.connect(
+ _set_world_pixelation.bind(index + 1)
+ )
+ for index: int in _ui_options.size():
+ _ui_options[index].pressed.connect(
+ _set_ui_pixelation.bind(index + 1)
+ )
+ _chat_dock.pressed.connect(_toggle_chat_dock)
+ _chat_mode.pressed.connect(_toggle_chat_mode)
+ _paint_dock.pressed.connect(_toggle_paint_dock)
+ _fullscreen_toggle.pressed.connect(_toggle_fullscreen)
+ %MouseDecrease.pressed.connect(_adjust_mouse_sensitivity.bind(-1))
+ %MouseIncrease.pressed.connect(_adjust_mouse_sensitivity.bind(1))
+ _mouse_value.pressed.connect(_adjust_mouse_sensitivity.bind(1))
+ %ControllerDecrease.pressed.connect(
+ _adjust_controller_sensitivity.bind(-1)
+ )
+ %ControllerIncrease.pressed.connect(
+ _adjust_controller_sensitivity.bind(1)
+ )
+ _controller_value.pressed.connect(
+ _adjust_controller_sensitivity.bind(1)
+ )
+ _invert_y_toggle.pressed.connect(_toggle_invert_y)
+ _on_screen_keyboard_toggle.pressed.connect(_toggle_on_screen_keyboard)
+ _auto_click_toggle.pressed.connect(_toggle_auto_click)
+ for control: Control in [_auto_click_toggle, _auto_click_interval]:
+ control.mouse_entered.connect(
+ _show_accessibility_helper.bind(&"auto_click")
+ )
+ control.focus_entered.connect(
+ _show_accessibility_helper.bind(&"auto_click")
+ )
+ %IntervalDecrease.pressed.connect(_adjust_auto_click_interval.bind(-1))
+ %IntervalIncrease.pressed.connect(_adjust_auto_click_interval.bind(1))
+ _auto_click_interval.pressed.connect(
+ _adjust_auto_click_interval.bind(1)
+ )
+ _master_volume_slider.value_changed.connect(
+ _on_audio_volume_changed.bind(&"master")
+ )
+ _music_volume_slider.value_changed.connect(
+ _on_audio_volume_changed.bind(&"music")
+ )
+ _effects_volume_slider.value_changed.connect(
+ _on_audio_volume_changed.bind(&"effects")
+ )
+ _environment_volume_slider.value_changed.connect(
+ _on_audio_volume_changed.bind(&"environment")
+ )
+ _mouse_value.gui_input.connect(
+ _on_continuous_value_input.bind(&"mouse")
+ )
+ _controller_value.gui_input.connect(
+ _on_continuous_value_input.bind(&"controller")
+ )
+ _auto_click_interval.gui_input.connect(
+ _on_continuous_value_input.bind(&"interval")
+ )
+ resized.connect(_refresh_panel_size)
+ var parent_control := get_parent() as Control
+ if parent_control != null:
+ parent_control.resized.connect(_refresh_panel_size)
+ for page: SettingsBubblePage in _pages.values():
+ page.hide_page()
+ _controller_mapping_panel = ControllerMappingPanelType.new()
+ add_child(_controller_mapping_panel)
+ _controller_mapping_panel.closed.connect(
+ _on_controller_mapping_panel_closed
+ )
+ _style_data_page()
+ _style_sound_page()
+ call_deferred("_refresh_panel_size")
-func open_panel(settings_manager: SettingsManagerType) -> void:
+func setup_network_profile(
+ profile: NetworkProfilePreferences,
+ session: NetworkSession,
+) -> void:
+ _network_profile = profile
+ _network_session = session
+
+
+func setup_controller_mapping(
+ mapping_manager: ControllerMappingManagerType,
+) -> void:
+ _controller_mapping_manager = mapping_manager
+ _controller_mapping_panel.setup(_controller_mapping_manager)
+
+
+func setup_data_and_identity(
+ data_root: PlayerDataRoot,
+ identity_backups: IdentityBackupService,
+ player_identity: PlayerIdentityStore,
+ host_identity: HostIdentityStore,
+ session: NetworkSession,
+ interface_fonts: InterfaceFontController,
+) -> void:
+ _data_root = data_root
+ _identity_backups = identity_backups
+ _player_identity = player_identity
+ _host_identity = host_identity
+ _network_session = session
+ _interface_fonts = interface_fonts
+ _identity_backups.operation_finished.connect(_on_identity_operation_finished)
+ _interface_fonts.apply_utility_theme(_data_page)
+ _refresh_data_page()
+
+
+func open_panel(
+ settings_manager: SettingsManagerType,
+ presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL,
+ animate_gameplay_host_transitions: bool = false,
+) -> void:
+ _cancel_page_transition()
+ _presentation_mode = presentation_mode
+ _animate_gameplay_host_transitions = animate_gameplay_host_transitions
_settings_manager = settings_manager
_load_controls()
_feedback.text = ""
+ _refresh_data_page()
show()
- _auto_click_toggle.grab_focus()
+ _page_stack.clear()
+ _page_stack.append(PAGE_ROOT)
+ panel_visibility_changed.emit(true)
+ if (
+ presentation_mode == PresentationMode.TITLE_EMBEDDED
+ or _animate_gameplay_host_transitions
+ ):
+ for page: SettingsBubblePage in _pages.values():
+ page.hide_page()
+ _page_transition_generation += 1
+ _page_transition_active = true
+ var generation: int = _page_transition_generation
+ _root_page.transition_in(
+ true,
+ _finish_animated_open.bind(generation),
+ 0.0
+ )
+ else:
+ _show_active_page(true)
+ opened.emit()
-func close_panel() -> void:
+func close_panel(immediate: bool = false) -> void:
if not visible:
return
+ if immediate:
+ _finish_panel_close(false)
+ return
+ if (
+ _presentation_mode == PresentationMode.TITLE_EMBEDDED
+ or _animate_gameplay_host_transitions
+ ):
+ if _page_transition_active:
+ return
+ _begin_animated_close(false)
+ return
+ _finish_panel_close(false)
+
+
+func _finish_panel_close(applied_result: bool) -> void:
+ _cancel_page_transition()
+ if (
+ _controller_mapping_panel != null
+ and _controller_mapping_panel.is_open()
+ ):
+ _controller_mapping_panel.close_panel()
+ for page: SettingsBubblePage in _pages.values():
+ page.hide_page()
+ _page_stack.clear()
hide()
- _load_controls()
- closed.emit()
+ panel_visibility_changed.emit(false)
+ if applied_result:
+ applied.emit()
+ else:
+ _load_controls()
+ closed.emit()
+
+
+func handle_back() -> void:
+ if (
+ _controller_mapping_panel != null
+ and _controller_mapping_panel.is_open()
+ ):
+ _controller_mapping_panel.request_back()
+ return
+ if _page_transition_active:
+ return
+ if _page_stack.size() > 1:
+ _start_page_transition(_page_stack[-2], false)
+ return
+ close_panel()
+
+
+func _open_controller_mapping() -> void:
+ if _controller_mapping_manager == null:
+ _feedback.text = "no controller mapping service is available"
+ return
+ _controller_mapping_panel.open_panel()
+
+
+func _on_controller_mapping_panel_closed() -> void:
+ %ControllerMapping.grab_focus()
+
+
+func get_active_page_id() -> StringName:
+ return _page_stack.back() if not _page_stack.is_empty() else StringName()
+
+
+func is_controller_mapping_capturing() -> bool:
+ return (
+ _controller_mapping_panel != null
+ and _controller_mapping_panel.is_capturing()
+ )
+
+
+func _push_page(page_id: StringName) -> void:
+ if (
+ _page_transition_active
+ or not _pages.has(page_id)
+ or get_active_page_id() == page_id
+ ):
+ return
+ _start_page_transition(page_id, true)
+
+
+func _start_page_transition(page_id: StringName, push_page: bool) -> void:
+ var outgoing_page: SettingsBubblePage = _get_active_page()
+ var incoming_page: SettingsBubblePage = _pages.get(page_id)
+ if outgoing_page == null or incoming_page == null:
+ return
+ _page_transition_generation += 1
+ _page_transition_active = true
+ navigation_transition_started.emit()
+ var generation: int = _page_transition_generation
+ if push_page:
+ _page_stack.append(page_id)
+ else:
+ _page_stack.pop_back()
+ for page: SettingsBubblePage in _pages.values():
+ if page != outgoing_page and page != incoming_page:
+ page.hide_page()
+ outgoing_page.transition_out(
+ Callable()
+ )
+ get_tree().create_timer(
+ UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
+ ).timeout.connect(
+ func() -> void:
+ if (
+ generation == _page_transition_generation
+ and _page_transition_active
+ ):
+ incoming_page.transition_in(
+ true,
+ _finish_incoming_page.bind(generation)
+ ),
+ CONNECT_ONE_SHOT
+ )
+
+
+func _finish_incoming_page(generation: int) -> void:
+ if generation != _page_transition_generation or not _page_transition_active:
+ return
+ _page_transition_active = false
+
+
+func _finish_animated_open(generation: int) -> void:
+ if generation != _page_transition_generation or not _page_transition_active:
+ return
+ _page_transition_active = false
+ opened.emit()
+
+
+func _begin_animated_close(applied_result: bool) -> void:
+ var active_page: SettingsBubblePage = _get_active_page()
+ if active_page == null:
+ return
+ _page_transition_generation += 1
+ _page_transition_active = true
+ navigation_transition_started.emit()
+ closing.emit()
+ var generation: int = _page_transition_generation
+ active_page.transition_out(
+ _finish_animated_close.bind(generation, applied_result),
+ 0.0
+ )
+
+
+func _finish_animated_close(
+ generation: int,
+ applied_result: bool,
+) -> void:
+ if generation != _page_transition_generation or not _page_transition_active:
+ return
+ _finish_panel_close(applied_result)
+
+
+func _cancel_page_transition() -> void:
+ _page_transition_generation += 1
+ _page_transition_active = false
+
+
+func _show_active_page(should_focus: bool) -> void:
+ for page_id: StringName in _pages:
+ var page: SettingsBubblePage = _pages[page_id]
+ if page_id == get_active_page_id():
+ page.show_page(should_focus)
+ else:
+ page.hide_page()
+ if get_active_page_id() == PAGE_DATA:
+ _refresh_data_page()
+ if should_focus:
+ %OpenDataFolder.call_deferred("grab_focus")
+
+
+func _refresh_data_page() -> void:
+ if not is_node_ready() or _data_root == null:
+ return
+ %DataFolderPath.text = _data_root.root_path
+ %DataStorageMode.text = "Storage Mode: " + _data_root.storage_mode_text()
+ %ChangeDataFolder.disabled = (
+ _data_root.override_active
+ or (_network_session != null and _network_session.is_session_active())
+ )
+ var fingerprint: String = (
+ _player_identity.fingerprint if _player_identity != null else ""
+ )
+ %CopyPlayerFingerprint.disabled = not NetworkIdentityCrypto.valid_fingerprint(
+ fingerprint
+ )
+ %CopyPlayerFingerprint.text = (
+ "Copy Player Fingerprint · %s"
+ % NetworkIdentityCrypto.compact_suffix(fingerprint)
+ )
+
+
+func _open_data_folder() -> void:
+ if _data_root == null or not _data_root.open_folder():
+ _feedback.text = "Could not open the data folder."
+
+
+func _copy_player_fingerprint() -> void:
+ var fingerprint: String = (
+ _player_identity.fingerprint if _player_identity != null else ""
+ )
+ if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
+ _feedback.text = "Player identity is unavailable."
+ return
+ DisplayServer.clipboard_set(fingerprint)
+ _feedback.text = "Full player fingerprint copied for server operator setup."
+
+
+func _choose_data_folder() -> void:
+ if _data_root == null or _data_root.override_active:
+ _feedback.text = "The data folder is externally managed."
+ return
+ if _network_session != null and _network_session.is_session_active():
+ _feedback.text = "Return to title to change the data folder."
+ return
+ if _data_folder_dialog == null:
+ _data_folder_dialog = FileDialog.new()
+ _data_folder_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
+ _data_folder_dialog.access = FileDialog.ACCESS_FILESYSTEM
+ _data_folder_dialog.use_native_dialog = false
+ _data_folder_dialog.dir_selected.connect(_change_data_folder)
+ _interface_fonts.apply_utility_theme(_data_folder_dialog)
+ add_child(_data_folder_dialog)
+ _data_folder_dialog.current_dir = _data_root.root_path.get_base_dir()
+ _interface_fonts.popup_file_dialog(_data_folder_dialog)
+
+
+func _change_data_folder(path: String) -> void:
+ var result: Dictionary = PortableDataMigration.migrate_active_to(
+ _data_root, path
+ )
+ if bool(result.get("requires_existing_root_decision", false)):
+ _show_existing_data_folder_choice(path)
+ return
+ if bool(result.get("ok", false)):
+ _feedback.text = "Data folder changed. NETfishing will close safely."
+ _refresh_data_page()
+ get_tree().call_deferred("quit")
+ else:
+ _feedback.text = str(result.get("message", "Could not change the data folder."))
+
+
+func _show_existing_data_folder_choice(path: String) -> void:
+ var dialog: ConfirmationDialog = ConfirmationDialog.new()
+ dialog.title = "Existing NETfishing data"
+ dialog.ok_button_text = "Use Selected Data"
+ dialog.dialog_text = (
+ "The selected folder contains different NETfishing data.\n\n"
+ + "Choose one complete data set. Data will not be merged."
+ )
+ dialog.add_button("Replace Selected Data", false, "replace")
+ dialog.confirmed.connect(func() -> void:
+ if _data_root.use_existing_root(path):
+ _feedback.text = "Data folder changed. NETfishing will close safely."
+ get_tree().call_deferred("quit")
+ else:
+ _feedback.text = _data_root.error_message
+ dialog.queue_free()
+ )
+ dialog.custom_action.connect(func(action: StringName) -> void:
+ if action != &"replace":
+ return
+ var result: Dictionary = PortableDataMigration.replace_existing_with_active(
+ _data_root, path
+ )
+ _feedback.text = str(result.get("message", "Could not replace selected data."))
+ if bool(result.get("ok", false)):
+ get_tree().call_deferred("quit")
+ dialog.queue_free()
+ )
+ _interface_fonts.apply_utility_theme(dialog)
+ add_child(dialog)
+ dialog.popup_centered(Vector2i(620, 340))
+
+
+func _choose_identity_export(identity_type: String) -> void:
+ if not _identity_operation_allowed():
+ return
+ _pending_identity_operation = "export"
+ _pending_identity_type = identity_type
+ var suggested: String = _identity_backups.default_export_path(identity_type)
+ if _export_file_dialog == null:
+ _export_file_dialog = FileDialog.new()
+ _export_file_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
+ _export_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
+ _export_file_dialog.use_native_dialog = false
+ _export_file_dialog.filters = PackedStringArray(["*.nfidentity ; NETfishing identity backup"])
+ _export_file_dialog.file_selected.connect(_identity_export_file_selected)
+ _interface_fonts.apply_utility_theme(_export_file_dialog)
+ add_child(_export_file_dialog)
+ _export_file_dialog.current_dir = suggested.get_base_dir()
+ _export_file_dialog.current_file = suggested.get_file()
+ _interface_fonts.popup_file_dialog(_export_file_dialog)
+
+
+func _identity_export_file_selected(path: String) -> void:
+ _pending_identity_path = (
+ path if path.ends_with(".nfidentity") else path + ".nfidentity"
+ )
+ _show_passphrase_dialog(true)
+
+
+func _choose_identity_import(identity_type: String) -> void:
+ if not _identity_operation_allowed():
+ return
+ _pending_identity_operation = "import"
+ _pending_identity_type = identity_type
+ if _backup_file_dialog == null:
+ _backup_file_dialog = FileDialog.new()
+ _backup_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
+ _backup_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
+ _backup_file_dialog.use_native_dialog = false
+ _backup_file_dialog.filters = PackedStringArray(["*.nfidentity ; NETfishing identity backup"])
+ _backup_file_dialog.file_selected.connect(_identity_import_file_selected)
+ _interface_fonts.apply_utility_theme(_backup_file_dialog)
+ add_child(_backup_file_dialog)
+ _backup_file_dialog.current_dir = _data_root.identity_backup_directory()
+ _interface_fonts.popup_file_dialog(_backup_file_dialog)
+
+
+func _identity_import_file_selected(path: String) -> void:
+ _pending_identity_path = path
+ _show_passphrase_dialog(false)
+
+
+func _show_passphrase_dialog(exporting: bool) -> void:
+ if _passphrase_dialog == null:
+ _passphrase_dialog = ConfirmationDialog.new()
+ _passphrase_dialog.title = "Encrypted identity backup"
+ _passphrase_dialog.confirmed.connect(_submit_identity_passphrase)
+ var fields: VBoxContainer = VBoxContainer.new()
+ var warning: Label = Label.new()
+ warning.text = (
+ "Anyone with this backup and passphrase can use your identity."
+ )
+ warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ fields.add_child(warning)
+ _passphrase_entry = LineEdit.new()
+ _passphrase_entry.placeholder_text = "Passphrase (12 characters minimum)"
+ _passphrase_entry.secret = true
+ fields.add_child(_passphrase_entry)
+ _passphrase_confirm = LineEdit.new()
+ _passphrase_confirm.placeholder_text = "Confirm passphrase"
+ _passphrase_confirm.secret = true
+ fields.add_child(_passphrase_confirm)
+ _passphrase_dialog.add_child(fields)
+ _interface_fonts.apply_utility_theme(_passphrase_dialog)
+ add_child(_passphrase_dialog)
+ _passphrase_confirm.visible = exporting
+ _passphrase_entry.clear()
+ _passphrase_confirm.clear()
+ _passphrase_dialog.dialog_text = (
+ "Create a passphrase-encrypted backup."
+ if exporting else "Enter the backup passphrase."
+ )
+ _passphrase_dialog.popup_centered(Vector2i(560, 300))
+ _passphrase_entry.grab_focus()
+
+
+func _submit_identity_passphrase() -> void:
+ var passphrase: String = _passphrase_entry.text
+ if _pending_identity_operation == "export":
+ _identity_backups.export_backup(
+ _pending_identity_type,
+ _pending_identity_path,
+ passphrase,
+ _passphrase_confirm.text,
+ )
+ return
+ var inspected: Dictionary = _identity_backups.import_backup(
+ _pending_identity_type,
+ _pending_identity_path,
+ passphrase,
+ false,
+ )
+ if bool(inspected.get("requires_confirmation", false)):
+ _pending_import_data = {
+ "passphrase": passphrase,
+ "current": inspected["current_fingerprint"],
+ "incoming": inspected["incoming_fingerprint"],
+ }
+ _show_identity_replacement_confirmation()
+
+
+func _show_identity_replacement_confirmation() -> void:
+ var dialog: ConfirmationDialog = ConfirmationDialog.new()
+ dialog.title = "Replace active identity?"
+ dialog.ok_button_text = "Review Replacement"
+ dialog.dialog_text = (
+ "Current:\n%s\n\nIncoming:\n%s\n\n"
+ + "Other players will recognize this device as the imported identity."
+ ) % [
+ NetworkIdentityCrypto.format_fingerprint(_pending_import_data["current"]),
+ NetworkIdentityCrypto.format_fingerprint(_pending_import_data["incoming"]),
+ ]
+ dialog.set_meta("confirmation_step", 1)
+ dialog.confirmed.connect(_advance_identity_replacement.bind(dialog))
+ dialog.canceled.connect(dialog.queue_free)
+ _interface_fonts.apply_utility_theme(dialog)
+ add_child(dialog)
+ dialog.popup_centered(Vector2i(620, 360))
+
+
+func _advance_identity_replacement(dialog: ConfirmationDialog) -> void:
+ if int(dialog.get_meta("confirmation_step", 1)) == 1:
+ dialog.set_meta("confirmation_step", 2)
+ dialog.ok_button_text = "Replace Identity"
+ dialog.dialog_text = (
+ "Replace the active identity and archive the current key locally?"
+ )
+ dialog.call_deferred("popup_centered", Vector2i(560, 280))
+ return
+ _confirm_identity_replacement(dialog)
+
+
+func _confirm_identity_replacement(dialog: ConfirmationDialog) -> void:
+ _identity_backups.import_backup(
+ _pending_identity_type,
+ _pending_identity_path,
+ str(_pending_import_data["passphrase"]),
+ true,
+ )
+ _pending_import_data.clear()
+ dialog.queue_free()
+
+
+func _identity_operation_allowed() -> bool:
+ if _identity_backups == null:
+ _feedback.text = "Identity backup is unavailable."
+ return false
+ if _network_session != null and _network_session.is_session_active():
+ _feedback.text = "Return to title to change an identity."
+ return false
+ return true
+
+
+func _on_identity_operation_finished(_success: bool, message: String) -> void:
+ _feedback.text = message
+
+
+func _get_active_page() -> SettingsBubblePage:
+ return _pages.get(get_active_page_id()) as SettingsBubblePage
func _apply_settings() -> void:
+ if _page_transition_active:
+ return
if _settings_manager == null:
_feedback.text = "settings are unavailable."
return
- var edited := PlayerSettings.new()
- edited.auto_click_enabled = _auto_click_toggle.button_pressed
- edited.auto_click_interval = float(_auto_click_interval.value)
- edited.mouse_camera_sensitivity = float(_mouse_sensitivity.value)
- edited.controller_camera_sensitivity = float(_controller_sensitivity.value)
- edited.invert_camera_y = _invert_y_toggle.button_pressed
+ var edited: PlayerSettings = _settings_manager.current_settings.copy()
+ edited.auto_click_enabled = _auto_click_enabled
+ edited.auto_click_interval = _auto_click_interval_value
+ edited.mouse_camera_sensitivity = _mouse_sensitivity
+ edited.controller_camera_sensitivity = _controller_sensitivity
+ edited.invert_camera_y = _invert_camera_y
+ edited.on_screen_keyboard_enabled = _on_screen_keyboard_enabled
+ edited.master_volume = _master_volume
+ edited.music_volume = _music_volume
+ edited.effects_volume = _effects_volume
+ edited.environment_volume = _environment_volume
if _settings_manager.apply_settings(edited):
- hide()
- applied.emit()
+ if (
+ _presentation_mode == PresentationMode.TITLE_EMBEDDED
+ or _animate_gameplay_host_transitions
+ ):
+ _begin_animated_close(true)
+ else:
+ _finish_panel_close(true)
else:
_feedback.text = "failed to save settings."
@@ -66,15 +783,326 @@ func _load_controls() -> void:
if _settings_manager == null or not is_node_ready():
return
var settings: PlayerSettings = _settings_manager.current_settings
- _auto_click_toggle.button_pressed = settings.auto_click_enabled
- _auto_click_interval.value = settings.auto_click_interval
- _mouse_sensitivity.value = settings.mouse_camera_sensitivity
- _controller_sensitivity.value = settings.controller_camera_sensitivity
- _invert_y_toggle.button_pressed = settings.invert_camera_y
- _refresh_value_labels(0.0)
+ _auto_click_enabled = settings.auto_click_enabled
+ _auto_click_interval_value = settings.auto_click_interval
+ _mouse_sensitivity = settings.mouse_camera_sensitivity
+ _controller_sensitivity = settings.controller_camera_sensitivity
+ _invert_camera_y = settings.invert_camera_y
+ _on_screen_keyboard_enabled = settings.on_screen_keyboard_enabled
+ _master_volume = settings.master_volume
+ _music_volume = settings.music_volume
+ _effects_volume = settings.effects_volume
+ _environment_volume = settings.environment_volume
+ _master_volume_slider.set_value_no_signal(_master_volume * 100.0)
+ _music_volume_slider.set_value_no_signal(_music_volume * 100.0)
+ _effects_volume_slider.set_value_no_signal(_effects_volume * 100.0)
+ _environment_volume_slider.set_value_no_signal(
+ _environment_volume * 100.0
+ )
+ _settings_manager.restore_audio_levels()
+ _refresh_value_labels()
-func _refresh_value_labels(_unused: float) -> void:
- _auto_click_interval_value.text = "%.2f s" % _auto_click_interval.value
- _mouse_sensitivity_value.text = "%.4f" % _mouse_sensitivity.value
- _controller_sensitivity_value.text = "%.1f" % _controller_sensitivity.value
+func _refresh_value_labels() -> void:
+ if _settings_manager == null:
+ return
+ var settings: PlayerSettings = _settings_manager.current_settings
+ _world_value.text = (
+ "world\npixelation\n"
+ + _pixel_size_label(settings.world_pixel_size)
+ )
+ _ui_value.text = (
+ "ui\npixelation\n"
+ + _pixel_size_label(settings.ui_pixel_size)
+ )
+ _chat_dock.text = "chat dock\n" + ("right" if settings.chat_dock_right else "left")
+ _chat_mode.text = (
+ "chat mode\n" + ("mobile" if settings.chat_mobile_mode else "desktop")
+ )
+ _paint_dock.text = (
+ "paint dock\n" + ("right" if settings.paint_dock_right else "left")
+ )
+ _fullscreen_toggle.text = (
+ "fullscreen\n" + ("on" if settings.fullscreen_enabled else "off")
+ )
+ _mouse_value.text = "mouse\nsensitivity\n%.4f" % _mouse_sensitivity
+ _controller_value.text = (
+ "controller\nsensitivity\n%.1f" % _controller_sensitivity
+ )
+ _invert_y_toggle.text = (
+ "invert\nvertical\ncamera\n"
+ + ("on" if _invert_camera_y else "off")
+ )
+ _on_screen_keyboard_toggle.text = (
+ "on-screen\nkeyboard\n"
+ + ("on" if _on_screen_keyboard_enabled else "off")
+ )
+ _auto_click_toggle.text = (
+ "accessibility\nauto-click\n"
+ + ("on" if _auto_click_enabled else "off")
+ )
+ _auto_click_interval.text = (
+ "auto-click\ninterval\n%.2f s" % _auto_click_interval_value
+ )
+ _refresh_audio_value_labels()
+ for index: int in _world_options.size():
+ _world_options[index].button_pressed = (
+ index + 1 == settings.world_pixel_size
+ )
+ for index: int in _ui_options.size():
+ _ui_options[index].button_pressed = index + 1 == settings.ui_pixel_size
+
+
+func _show_accessibility_helper(_kind: StringName) -> void:
+ var helper := %IntervalHelp as BubbleButton
+ helper.text = (
+ "Lower intervals click\nbarriers faster\nwhile held."
+ )
+
+
+func _style_data_page() -> void:
+ UtilityPageStyle.apply_page(_data_page)
+ var paper := _data_page.get_node("Paper") as PanelContainer
+ paper.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.panel_style()
+ )
+ var content := _data_page.get_node("Paper/Content") as VBoxContainer
+ content.add_theme_constant_override("separation", 12)
+ for node: Node in content.find_children("*", "", true, false):
+ if node is BaseButton:
+ UtilityPageStyle.apply_ocean_button(node)
+ elif node is Label:
+ node.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+
+
+func _style_sound_page() -> void:
+ UtilityPageStyle.apply_page(_sound_page)
+ var paper := _sound_page.get_node("Paper") as PanelContainer
+ paper.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.panel_style()
+ )
+ var content := _sound_page.get_node("Paper/Content") as VBoxContainer
+ for node: Node in content.find_children("*", "Label", true, false):
+ (node as Label).add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+
+
+func _on_audio_volume_changed(value: float, channel: StringName) -> void:
+ var linear_volume: float = clampf(value / 100.0, 0.0, 1.0)
+ match channel:
+ &"master":
+ _master_volume = linear_volume
+ &"music":
+ _music_volume = linear_volume
+ &"effects":
+ _effects_volume = linear_volume
+ &"environment":
+ _environment_volume = linear_volume
+ _refresh_audio_value_labels()
+ if _settings_manager != null:
+ _settings_manager.preview_audio_levels(
+ _master_volume,
+ _music_volume,
+ _effects_volume,
+ _environment_volume,
+ )
+
+
+func _refresh_audio_value_labels() -> void:
+ _master_volume_value.text = "%d%%" % roundi(_master_volume * 100.0)
+ _music_volume_value.text = "%d%%" % roundi(_music_volume * 100.0)
+ _effects_volume_value.text = "%d%%" % roundi(_effects_volume * 100.0)
+ _environment_volume_value.text = (
+ "%d%%" % roundi(_environment_volume * 100.0)
+ )
+
+
+func _set_world_pixelation(pixel_size: int) -> void:
+ if _settings_manager == null:
+ return
+ if not _settings_manager.apply_pixelation(
+ pixel_size,
+ _settings_manager.current_settings.ui_pixel_size
+ ):
+ _feedback.text = "failed to save pixelation settings."
+ return
+ _refresh_value_labels()
+
+
+func _set_ui_pixelation(pixel_size: int) -> void:
+ if _settings_manager == null:
+ return
+ if not _settings_manager.apply_pixelation(
+ _settings_manager.current_settings.world_pixel_size,
+ pixel_size
+ ):
+ _feedback.text = "failed to save pixelation settings."
+ return
+ _refresh_value_labels()
+
+
+func _toggle_chat_dock() -> void:
+ if _settings_manager == null:
+ return
+ var edited: PlayerSettings = _settings_manager.current_settings.copy()
+ edited.chat_dock_right = not edited.chat_dock_right
+ if not _settings_manager.apply_settings(edited):
+ _feedback.text = "failed to save chat dock setting."
+ return
+ _refresh_value_labels()
+
+
+func _toggle_chat_mode() -> void:
+ if _settings_manager == null:
+ return
+ var edited: PlayerSettings = _settings_manager.current_settings.copy()
+ edited.chat_mobile_mode = not edited.chat_mobile_mode
+ if not _settings_manager.apply_settings(edited):
+ _feedback.text = "failed to save chat mode setting."
+ return
+ _refresh_value_labels()
+
+
+func _toggle_fullscreen() -> void:
+ if _settings_manager == null:
+ return
+ var edited: PlayerSettings = _settings_manager.current_settings.copy()
+ edited.fullscreen_enabled = not edited.fullscreen_enabled
+ if not _settings_manager.apply_settings(edited):
+ _feedback.text = "failed to save fullscreen setting."
+ return
+ _refresh_value_labels()
+
+
+func _toggle_paint_dock() -> void:
+ if _settings_manager == null:
+ return
+ var edited: PlayerSettings = _settings_manager.current_settings.copy()
+ edited.paint_dock_right = not edited.paint_dock_right
+ if not _settings_manager.apply_settings(edited):
+ _feedback.text = "failed to save paint dock setting."
+ return
+ _refresh_value_labels()
+
+
+func _adjust_mouse_sensitivity(direction: int) -> void:
+ _mouse_sensitivity = clampf(
+ _mouse_sensitivity + float(direction) * 0.0005,
+ PlayerSettings.MIN_MOUSE_SENSITIVITY,
+ PlayerSettings.MAX_MOUSE_SENSITIVITY
+ )
+ _refresh_value_labels()
+
+
+func _adjust_controller_sensitivity(direction: int) -> void:
+ _controller_sensitivity = clampf(
+ _controller_sensitivity + float(direction) * 0.1,
+ PlayerSettings.MIN_CONTROLLER_SENSITIVITY,
+ PlayerSettings.MAX_CONTROLLER_SENSITIVITY
+ )
+ _refresh_value_labels()
+
+
+func _adjust_auto_click_interval(direction: int) -> void:
+ _auto_click_interval_value = clampf(
+ _auto_click_interval_value + float(direction) * 0.01,
+ PlayerSettings.MIN_AUTO_CLICK_INTERVAL,
+ PlayerSettings.MAX_AUTO_CLICK_INTERVAL
+ )
+ _refresh_value_labels()
+
+
+func _toggle_invert_y() -> void:
+ _invert_camera_y = not _invert_camera_y
+ _refresh_value_labels()
+
+
+func _toggle_on_screen_keyboard() -> void:
+ _on_screen_keyboard_enabled = not _on_screen_keyboard_enabled
+ _refresh_value_labels()
+
+
+func _toggle_auto_click() -> void:
+ _auto_click_enabled = not _auto_click_enabled
+ _refresh_value_labels()
+
+
+func _on_continuous_value_input(
+ event: InputEvent,
+ control_id: StringName,
+) -> void:
+ var direction: int = 0
+ if event.is_action_pressed("ui_left"):
+ direction = -1
+ elif event.is_action_pressed("ui_right"):
+ direction = 1
+ if direction == 0:
+ return
+ match control_id:
+ &"mouse":
+ _adjust_mouse_sensitivity(direction)
+ &"controller":
+ _adjust_controller_sensitivity(direction)
+ &"interval":
+ _adjust_auto_click_interval(direction)
+ accept_event()
+
+
+func _pixel_size_label(pixel_size: int) -> String:
+ var index: int = clampi(
+ pixel_size - 1,
+ 0,
+ PIXELATION_NAMES.size() - 1
+ )
+ return PIXELATION_NAMES[index]
+
+
+func set_effective_ui_pixel_size(pixel_size: int) -> void:
+ _effective_ui_pixel_size = clampi(
+ pixel_size,
+ PlayerSettings.MIN_UI_PIXEL_SIZE,
+ PlayerSettings.MAX_UI_PIXEL_SIZE
+ )
+ if is_node_ready():
+ _refresh_value_labels()
+
+
+func focus_back_button() -> void:
+ var active_page: SettingsBubblePage = _get_active_page()
+ if active_page != null:
+ active_page.focus_back()
+
+
+func _on_back_bubble_gui_input(event: InputEvent) -> void:
+ if event.is_action_pressed("ui_down") or event.is_action_pressed("ui_focus_next"):
+ crisp_reset_focus_requested.emit()
+ accept_event()
+
+
+func refresh_open_panel() -> void:
+ if visible:
+ _load_controls()
+
+
+func _refresh_panel_size() -> void:
+ if not is_node_ready():
+ return
+ var parent_control := get_parent() as Control
+ if parent_control == null:
+ return
+ var available_size: Vector2 = parent_control.size - Vector2.ONE * (
+ PANEL_EDGE_MARGIN * 2.0
+ )
+ var target_size := Vector2(
+ minf(MAX_PANEL_SIZE.x, maxf(320.0, available_size.x)),
+ minf(MAX_PANEL_SIZE.y, maxf(240.0, available_size.y))
+ )
+ custom_minimum_size = target_size
+ if parent_control is CenterContainer:
+ return
+ set_anchors_preset(Control.PRESET_CENTER)
+ position = (parent_control.size - target_size) * 0.5
+ size = target_size
diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn
index cfdef8e..df2e261 100644
--- a/ui/settings_panel.tscn
+++ b/ui/settings_panel.tscn
@@ -1,136 +1,956 @@
-[gd_scene load_steps=3 format=3]
+[gd_scene load_steps=9 format=3]
-[ext_resource type="Script" path="res://ui/settings_panel.gd" id="1_script"]
+[ext_resource type="Script" path="res://ui/settings_panel.gd" id="1_panel"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+[ext_resource type="Script" path="res://ui/settings/settings_bubble_page.gd" id="3_page"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_button.tscn" id="4_bubble"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="5_cluster"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="6_profile"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/undo_dark.png" id="7_undo_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/check_mark_dark.png" id="8_check_mark_dark"]
-[node name="SettingsPanel" type="PanelContainer"]
+[node name="SettingsPanel" type="Control"]
visible = false
-custom_minimum_size = Vector2(600, 490)
+custom_minimum_size = Vector2(960, 700)
+layout_mode = 3
+anchors_preset = 0
theme = ExtResource("2_theme")
-script = ExtResource("1_script")
+script = ExtResource("1_panel")
-[node name="Margin" type="MarginContainer" parent="."]
-layout_mode = 2
-theme_override_constants/margin_left = 28
-theme_override_constants/margin_top = 24
-theme_override_constants/margin_right = 28
-theme_override_constants/margin_bottom = 24
-
-[node name="Content" type="VBoxContainer" parent="Margin"]
-layout_mode = 2
-theme_override_constants/separation = 12
-
-[node name="Heading" type="Label" parent="Margin/Content"]
-layout_mode = 2
-theme_override_font_sizes/font_size = 26
-text = "settings"
+[node name="SettingsFeedback" type="Label" parent="."]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 12
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -42.0
+offset_bottom = -8.0
+grow_horizontal = 2
+grow_vertical = 0
+mouse_filter = 2
horizontal_alignment = 1
+vertical_alignment = 1
-[node name="AutoClickToggle" type="CheckButton" parent="Margin/Content"]
+[node name="RootPage" type="Control" parent="."]
unique_name_in_owner = true
-layout_mode = 2
-text = "accessibility auto-click"
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("3_page")
+page_id = &"root"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DisplayCategory"), NodePath("BubbleCluster/SoundCategory"), NodePath("BubbleCluster/ControlsCategory"), NodePath("BubbleCluster/AccessibilityCategory"), NodePath("BubbleCluster/DataCategory"), NodePath("BubbleCluster/ApplySettingsButton"), NodePath("BubbleCluster/RootBackButton")])
+focus_paths = Array[NodePath]([NodePath("BubbleCluster/DisplayCategory"), NodePath("BubbleCluster/SoundCategory"), NodePath("BubbleCluster/ControlsCategory"), NodePath("BubbleCluster/AccessibilityCategory"), NodePath("BubbleCluster/DataCategory"), NodePath("BubbleCluster/ApplySettingsButton"), NodePath("BubbleCluster/RootBackButton")])
+initial_focus_path = NodePath("BubbleCluster/DisplayCategory")
+back_focus_path = NodePath("BubbleCluster/RootBackButton")
+compact_maximum_layout_size = Vector2(544, 400)
-[node name="AutoClickHelp" type="Label" parent="Margin/Content"]
-layout_mode = 2
-text = "lower intervals click barriers faster while held."
+[node name="BubbleCluster" type="Control" parent="RootPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("5_cluster")
+profile = ExtResource("6_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
-[node name="AutoClickRow" type="HBoxContainer" parent="Margin/Content"]
-layout_mode = 2
-theme_override_constants/separation = 12
-
-[node name="Label" type="Label" parent="Margin/Content/AutoClickRow"]
-custom_minimum_size = Vector2(190, 0)
-layout_mode = 2
-text = "auto-click interval"
-
-[node name="AutoClickInterval" type="HSlider" parent="Margin/Content/AutoClickRow"]
+[node name="DisplayCategory" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 3
-min_value = 0.1
-max_value = 0.5
-step = 0.01
-value = 0.2
+custom_minimum_size = Vector2(0, 0)
+offset_left = 135.0
+offset_top = 90.0
+offset_right = 305.0
+offset_bottom = 250.0
+text = "display"
+neutral_size = Vector2(170, 160)
+desktop_anchor = Vector2(280, 180)
+compact_anchor = Vector2(220, 150)
+compact_minimum_size = Vector2(130, 124)
+minimum_font_size = 18
+maximum_font_size = 27
+motion_phase = 0.35
-[node name="AutoClickIntervalValue" type="Label" parent="Margin/Content/AutoClickRow"]
+[node name="ControlsCategory" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(62, 0)
-layout_mode = 2
-text = "0.20 s"
+custom_minimum_size = Vector2(0, 0)
+offset_left = 377.0
+offset_top = 96.0
+offset_right = 533.0
+offset_bottom = 244.0
+text = "controls"
+neutral_size = Vector2(156, 148)
+desktop_anchor = Vector2(440, 180)
+compact_anchor = Vector2(385, 150)
+compact_minimum_size = Vector2(124, 118)
+minimum_font_size = 17
+maximum_font_size = 25
+motion_phase = 1.45
-[node name="MouseRow" type="HBoxContainer" parent="Margin/Content"]
-layout_mode = 2
-theme_override_constants/separation = 12
-
-[node name="Label" type="Label" parent="Margin/Content/MouseRow"]
-custom_minimum_size = Vector2(190, 0)
-layout_mode = 2
-text = "mouse camera sensitivity"
-
-[node name="MouseSensitivity" type="HSlider" parent="Margin/Content/MouseRow"]
+[node name="SoundCategory" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 3
-min_value = 0.001
-max_value = 0.012
-step = 0.0005
-value = 0.005
+custom_minimum_size = Vector2(0, 0)
+text = "sound"
+neutral_size = Vector2(144, 136)
+desktop_anchor = Vector2(100, 180)
+compact_anchor = Vector2(90, 150)
+compact_minimum_size = Vector2(118, 112)
+minimum_font_size = 17
+maximum_font_size = 25
+motion_phase = 2.0
-[node name="MouseSensitivityValue" type="Label" parent="Margin/Content/MouseRow"]
+[node name="AccessibilityCategory" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(62, 0)
-layout_mode = 2
-text = "0.0050"
+custom_minimum_size = Vector2(0, 0)
+offset_left = 240.0
+offset_top = 262.0
+offset_right = 420.0
+offset_bottom = 428.0
+text = "accessibility"
+neutral_size = Vector2(180, 168)
+desktop_anchor = Vector2(367, 328)
+compact_anchor = Vector2(307, 311)
+compact_minimum_size = Vector2(150, 142)
+minimum_font_size = 17
+maximum_font_size = 27
+motion_phase = 2.65
-[node name="ControllerRow" type="HBoxContainer" parent="Margin/Content"]
-layout_mode = 2
-theme_override_constants/separation = 12
-
-[node name="Label" type="Label" parent="Margin/Content/ControllerRow"]
-custom_minimum_size = Vector2(190, 0)
-layout_mode = 2
-text = "controller camera sensitivity"
-
-[node name="ControllerSensitivity" type="HSlider" parent="Margin/Content/ControllerRow"]
+[node name="ApplySettingsButton" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 3
-min_value = 0.5
-max_value = 5.0
-step = 0.1
-value = 2.5
+custom_minimum_size = Vector2(0, 0)
+offset_left = 474.0
+offset_top = 308.0
+offset_right = 586.0
+offset_bottom = 412.0
+icon = ExtResource("8_check_mark_dark")
+tooltip_text = "apply"
+accessibility_name = "apply"
+neutral_size = Vector2(112, 104)
+desktop_anchor = Vector2(511, 332)
+compact_anchor = Vector2(470, 330)
+compact_minimum_size = Vector2(94, 90)
+minimum_font_size = 15
+maximum_font_size = 19
+motion_phase = 3.7
-[node name="ControllerSensitivityValue" type="Label" parent="Margin/Content/ControllerRow"]
+[node name="DataCategory" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(62, 0)
-layout_mode = 2
-text = "2.5"
+custom_minimum_size = Vector2(0, 0)
+text = "data &\nidentity"
+neutral_size = Vector2(140, 132)
+desktop_anchor = Vector2(610, 220)
+compact_anchor = Vector2(540, 205)
+compact_minimum_size = Vector2(116, 108)
+minimum_font_size = 14
+maximum_font_size = 21
+motion_phase = 5.4
-[node name="InvertYToggle" type="CheckButton" parent="Margin/Content"]
+[node name="RootBackButton" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-layout_mode = 2
-text = "invert vertical camera"
+custom_minimum_size = Vector2(0, 0)
+offset_left = 70.0
+offset_top = 343.0
+offset_right = 170.0
+offset_bottom = 437.0
+icon = ExtResource("7_undo_dark")
+tooltip_text = "back"
+accessibility_name = "back"
+neutral_size = Vector2(100, 94)
+desktop_anchor = Vector2(220, 300)
+compact_anchor = Vector2(155, 275)
+compact_minimum_size = Vector2(84, 80)
+minimum_font_size = 14
+maximum_font_size = 17
+motion_phase = 4.8
-[node name="SettingsFeedback" type="Label" parent="Margin/Content"]
+[node name="DisplayPage" type="Control" parent="."]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 28)
-layout_mode = 2
-horizontal_alignment = 1
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("3_page")
+page_id = &"display"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/WorldValue"), NodePath("BubbleCluster/WorldLegible"), NodePath("BubbleCluster/WorldCute"), NodePath("BubbleCluster/WorldRetro"), NodePath("BubbleCluster/WorldHardcore"), NodePath("BubbleCluster/WorldWtf"), NodePath("BubbleCluster/UIValue"), NodePath("BubbleCluster/UILegible"), NodePath("BubbleCluster/UICute"), NodePath("BubbleCluster/UIRetro"), NodePath("BubbleCluster/UIHardcore"), NodePath("BubbleCluster/UIWtf"), NodePath("BubbleCluster/ChatDock"), NodePath("BubbleCluster/ChatMode"), NodePath("BubbleCluster/PaintDock"), NodePath("BubbleCluster/FullscreenToggle"), NodePath("BubbleCluster/DisplayBackButton")])
+focus_paths = Array[NodePath]([NodePath("BubbleCluster/WorldLegible"), NodePath("BubbleCluster/WorldCute"), NodePath("BubbleCluster/WorldRetro"), NodePath("BubbleCluster/WorldHardcore"), NodePath("BubbleCluster/WorldWtf"), NodePath("BubbleCluster/UILegible"), NodePath("BubbleCluster/UICute"), NodePath("BubbleCluster/UIRetro"), NodePath("BubbleCluster/UIHardcore"), NodePath("BubbleCluster/UIWtf"), NodePath("BubbleCluster/ChatDock"), NodePath("BubbleCluster/ChatMode"), NodePath("BubbleCluster/PaintDock"), NodePath("BubbleCluster/FullscreenToggle"), NodePath("BubbleCluster/DisplayBackButton")])
+initial_focus_path = NodePath("BubbleCluster/WorldRetro")
+back_focus_path = NodePath("BubbleCluster/DisplayBackButton")
+compact_maximum_layout_size = Vector2(544, 400)
-[node name="Buttons" type="HBoxContainer" parent="Margin/Content"]
-layout_mode = 2
-theme_override_constants/separation = 12
-alignment = 2
+[node name="BubbleCluster" type="Control" parent="DisplayPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("5_cluster")
+profile = ExtResource("6_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
-[node name="ApplySettingsButton" type="Button" parent="Margin/Content/Buttons"]
+[node name="WorldValue" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(120, 42)
-layout_mode = 2
-text = "apply"
+custom_minimum_size = Vector2(0, 0)
+mouse_filter = 2
+focus_mode = 0
+text = "world\npixelation\nretro"
+neutral_size = Vector2(140, 130)
+desktop_anchor = Vector2(160, 240)
+compact_anchor = Vector2(175, 210)
+compact_minimum_size = Vector2(118, 110)
+minimum_font_size = 15
+maximum_font_size = 22
+motion_phase = 0.2
-[node name="CancelSettingsButton" type="Button" parent="Margin/Content/Buttons"]
+[node name="WorldLegible" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(120, 42)
-layout_mode = 2
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "legible"
+neutral_size = Vector2(90, 84)
+desktop_anchor = Vector2(55, 175)
+compact_anchor = Vector2(57, 135)
+compact_minimum_size = Vector2(76, 72)
+minimum_font_size = 12
+maximum_font_size = 15
+motion_phase = 0.8
+
+[node name="WorldCute" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "cute"
+neutral_size = Vector2(82, 78)
+desktop_anchor = Vector2(130, 124)
+compact_anchor = Vector2(145, 78)
+compact_minimum_size = Vector2(70, 68)
+minimum_font_size = 12
+maximum_font_size = 14
+motion_phase = 1.35
+
+[node name="WorldRetro" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+button_pressed = true
+text = "retro"
+neutral_size = Vector2(84, 80)
+desktop_anchor = Vector2(237, 149)
+compact_anchor = Vector2(272, 115)
+compact_minimum_size = Vector2(72, 68)
+minimum_font_size = 12
+maximum_font_size = 14
+motion_phase = 1.9
+
+[node name="WorldHardcore" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "hardcore"
+neutral_size = Vector2(100, 94)
+desktop_anchor = Vector2(288, 240)
+compact_anchor = Vector2(70, 310)
+compact_minimum_size = Vector2(88, 84)
+minimum_font_size = 12
+maximum_font_size = 16
+motion_phase = 2.45
+
+[node name="WorldWtf" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "wtf"
+neutral_size = Vector2(76, 72)
+desktop_anchor = Vector2(225, 337)
+compact_anchor = Vector2(284, 284)
+compact_minimum_size = Vector2(66, 64)
+minimum_font_size = 12
+maximum_font_size = 13
+motion_phase = 3.0
+
+[node name="UIValue" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+mouse_filter = 2
+focus_mode = 0
+text = "ui\npixelation\nretro"
+neutral_size = Vector2(140, 130)
+desktop_anchor = Vector2(560, 240)
+compact_anchor = Vector2(545, 210)
+compact_minimum_size = Vector2(118, 110)
+minimum_font_size = 15
+maximum_font_size = 22
+motion_phase = 3.55
+
+[node name="UILegible" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "legible"
+neutral_size = Vector2(90, 84)
+desktop_anchor = Vector2(665, 175)
+compact_anchor = Vector2(663, 135)
+compact_minimum_size = Vector2(76, 72)
+minimum_font_size = 12
+maximum_font_size = 15
+motion_phase = 4.1
+
+[node name="UICute" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "cute"
+neutral_size = Vector2(82, 78)
+desktop_anchor = Vector2(590, 124)
+compact_anchor = Vector2(575, 78)
+compact_minimum_size = Vector2(70, 68)
+minimum_font_size = 12
+maximum_font_size = 14
+motion_phase = 4.65
+
+[node name="UIRetro" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+button_pressed = true
+text = "retro"
+neutral_size = Vector2(84, 80)
+desktop_anchor = Vector2(483, 149)
+compact_anchor = Vector2(448, 115)
+compact_minimum_size = Vector2(72, 68)
+minimum_font_size = 12
+maximum_font_size = 14
+motion_phase = 5.2
+
+[node name="UIHardcore" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "hardcore"
+neutral_size = Vector2(100, 94)
+desktop_anchor = Vector2(432, 240)
+compact_anchor = Vector2(650, 310)
+compact_minimum_size = Vector2(88, 84)
+minimum_font_size = 12
+maximum_font_size = 16
+motion_phase = 5.75
+
+[node name="UIWtf" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+toggle_mode = true
+text = "wtf"
+neutral_size = Vector2(76, 72)
+desktop_anchor = Vector2(495, 337)
+compact_anchor = Vector2(436, 284)
+compact_minimum_size = Vector2(66, 64)
+minimum_font_size = 12
+maximum_font_size = 13
+motion_phase = 0.55
+
+[node name="ChatDock" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "chat dock\nleft"
+neutral_size = Vector2(96, 96)
+desktop_anchor = Vector2(70, 435)
+compact_anchor = Vector2(55, 378)
+compact_minimum_size = Vector2(82, 82)
+minimum_font_size = 12
+maximum_font_size = 16
+motion_phase = 1.1
+
+[node name="ChatMode" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "chat mode\ndesktop"
+neutral_size = Vector2(96, 96)
+desktop_anchor = Vector2(215, 435)
+compact_anchor = Vector2(180, 378)
+compact_minimum_size = Vector2(82, 82)
+minimum_font_size = 12
+maximum_font_size = 16
+motion_phase = 1.4
+
+[node name="PaintDock" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "paint dock\nright"
+neutral_size = Vector2(96, 96)
+desktop_anchor = Vector2(360, 435)
+compact_anchor = Vector2(305, 378)
+compact_minimum_size = Vector2(82, 82)
+minimum_font_size = 12
+maximum_font_size = 16
+motion_phase = 1.7
+
+[node name="FullscreenToggle" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "fullscreen\noff"
+neutral_size = Vector2(104, 96)
+desktop_anchor = Vector2(505, 435)
+compact_anchor = Vector2(430, 378)
+compact_minimum_size = Vector2(88, 82)
+minimum_font_size = 11
+maximum_font_size = 15
+motion_phase = 1.85
+
+[node name="DisplayBackButton" parent="DisplayPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
text = "back"
+neutral_size = Vector2(96, 90)
+desktop_anchor = Vector2(650, 435)
+compact_anchor = Vector2(555, 378)
+compact_minimum_size = Vector2(82, 78)
+minimum_font_size = 13
+maximum_font_size = 16
+motion_phase = 2.15
+
+[node name="SoundPage" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("3_page")
+page_id = &"sound"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/SoundBackButton")])
+focus_paths = Array[NodePath]([NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider"), NodePath("Paper/Content/VolumeGrid/MusicVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EffectsVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider"), NodePath("BubbleCluster/SoundBackButton")])
+initial_focus_path = NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider")
+back_focus_path = NodePath("BubbleCluster/SoundBackButton")
+compact_maximum_layout_size = Vector2(544, 400)
+
+[node name="BubbleCluster" type="Control" parent="SoundPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("5_cluster")
+profile = ExtResource("6_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
+
+[node name="Paper" type="PanelContainer" parent="SoundPage"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -310.0
+offset_top = -230.0
+offset_right = 310.0
+offset_bottom = 205.0
+grow_horizontal = 2
+grow_vertical = 2
+z_index = 1
+
+[node name="Content" type="VBoxContainer" parent="SoundPage/Paper"]
+layout_mode = 2
+theme_override_constants/separation = 18
+
+[node name="Heading" type="Label" parent="SoundPage/Paper/Content"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 28
+text = "sound"
+horizontal_alignment = 1
+
+[node name="Helper" type="Label" parent="SoundPage/Paper/Content"]
+layout_mode = 2
+text = "Adjust the mix. Changes are saved when you apply settings."
+horizontal_alignment = 1
+
+[node name="VolumeGrid" type="GridContainer" parent="SoundPage/Paper/Content"]
+layout_mode = 2
+theme_override_constants/h_separation = 16
+theme_override_constants/v_separation = 18
+columns = 3
+
+[node name="MasterLabel" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+custom_minimum_size = Vector2(118, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "master"
+vertical_alignment = 1
+
+[node name="MasterVolumeSlider" type="HSlider" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(350, 42)
+layout_mode = 2
+focus_mode = 2
+tooltip_text = "master volume"
+max_value = 100.0
+step = 1.0
+value = 100.0
+
+[node name="MasterVolumeValue" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(70, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "100%"
+horizontal_alignment = 2
+vertical_alignment = 1
+
+[node name="MusicLabel" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+custom_minimum_size = Vector2(118, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "music"
+vertical_alignment = 1
+
+[node name="MusicVolumeSlider" type="HSlider" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(350, 42)
+layout_mode = 2
+focus_mode = 2
+tooltip_text = "music volume"
+max_value = 100.0
+step = 1.0
+value = 100.0
+
+[node name="MusicVolumeValue" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(70, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "100%"
+horizontal_alignment = 2
+vertical_alignment = 1
+
+[node name="EffectsLabel" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+custom_minimum_size = Vector2(118, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "effects"
+vertical_alignment = 1
+
+[node name="EffectsVolumeSlider" type="HSlider" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(350, 42)
+layout_mode = 2
+focus_mode = 2
+tooltip_text = "effects volume"
+max_value = 100.0
+step = 1.0
+value = 100.0
+
+[node name="EffectsVolumeValue" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(70, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "100%"
+horizontal_alignment = 2
+vertical_alignment = 1
+
+[node name="EnvironmentLabel" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+custom_minimum_size = Vector2(118, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "environment"
+vertical_alignment = 1
+
+[node name="EnvironmentVolumeSlider" type="HSlider" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(350, 42)
+layout_mode = 2
+focus_mode = 2
+tooltip_text = "environment volume"
+max_value = 100.0
+step = 1.0
+value = 100.0
+
+[node name="EnvironmentVolumeValue" type="Label" parent="SoundPage/Paper/Content/VolumeGrid"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(70, 42)
+layout_mode = 2
+theme_override_font_sizes/font_size = 18
+text = "100%"
+horizontal_alignment = 2
+vertical_alignment = 1
+
+[node name="SoundBackButton" parent="SoundPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+z_index = 4
+layout_mode = 0
+text = "back"
+neutral_size = Vector2(96, 90)
+desktop_anchor = Vector2(60, 460)
+compact_anchor = Vector2(55, 410)
+compact_minimum_size = Vector2(82, 78)
+minimum_font_size = 13
+maximum_font_size = 16
+motion_phase = 5.0
+
+[node name="ControlsPage" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("3_page")
+page_id = &"controls"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")])
+focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")])
+initial_focus_path = NodePath("BubbleCluster/MouseValue")
+back_focus_path = NodePath("BubbleCluster/ControlsBackButton")
+
+[node name="BubbleCluster" type="Control" parent="ControlsPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("5_cluster")
+profile = ExtResource("6_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
+
+[node name="MouseDecrease" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "−"
+neutral_size = Vector2(80, 76)
+desktop_anchor = Vector2(75, 140)
+compact_anchor = Vector2(49, 125)
+compact_minimum_size = Vector2(68, 66)
+minimum_font_size = 17
+maximum_font_size = 24
+motion_phase = 0.3
+
+[node name="MouseValue" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "mouse\nsensitivity\n0.0050"
+neutral_size = Vector2(190, 180)
+desktop_anchor = Vector2(210, 140)
+compact_anchor = Vector2(200, 125)
+compact_minimum_size = Vector2(160, 152)
+minimum_font_size = 15
+maximum_font_size = 25
+motion_phase = 1.0
+
+[node name="MouseIncrease" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "+"
+neutral_size = Vector2(80, 76)
+desktop_anchor = Vector2(345, 140)
+compact_anchor = Vector2(351, 125)
+compact_minimum_size = Vector2(68, 66)
+minimum_font_size = 17
+maximum_font_size = 24
+motion_phase = 1.7
+
+[node name="ControllerDecrease" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "−"
+neutral_size = Vector2(80, 76)
+desktop_anchor = Vector2(75, 365)
+compact_anchor = Vector2(49, 340)
+compact_minimum_size = Vector2(68, 66)
+minimum_font_size = 17
+maximum_font_size = 24
+motion_phase = 2.4
+
+[node name="ControllerValue" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "controller\nsensitivity\n2.5"
+neutral_size = Vector2(190, 180)
+desktop_anchor = Vector2(210, 365)
+compact_anchor = Vector2(200, 340)
+compact_minimum_size = Vector2(160, 152)
+minimum_font_size = 15
+maximum_font_size = 25
+motion_phase = 3.1
+
+[node name="ControllerIncrease" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "+"
+neutral_size = Vector2(80, 76)
+desktop_anchor = Vector2(345, 365)
+compact_anchor = Vector2(351, 340)
+compact_minimum_size = Vector2(68, 66)
+minimum_font_size = 17
+maximum_font_size = 24
+motion_phase = 3.8
+
+[node name="InvertYToggle" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "invert\nvertical\ncamera\noff"
+neutral_size = Vector2(176, 170)
+compact_minimum_size = Vector2(170, 164)
+minimum_font_size = 14
+maximum_font_size = 24
+desktop_anchor = Vector2(525, 105)
+compact_anchor = Vector2(485, 95)
+motion_phase = 4.5
+
+[node name="OnScreenKeyboardToggle" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "on-screen\nkeyboard\noff"
+neutral_size = Vector2(170, 150)
+compact_minimum_size = Vector2(154, 136)
+minimum_font_size = 14
+maximum_font_size = 22
+desktop_anchor = Vector2(525, 255)
+compact_anchor = Vector2(485, 235)
+motion_phase = 4.7
+
+[node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "map\ncontroller"
+neutral_size = Vector2(160, 150)
+desktop_anchor = Vector2(525, 390)
+compact_anchor = Vector2(485, 355)
+compact_minimum_size = Vector2(148, 138)
+minimum_font_size = 14
+maximum_font_size = 23
+motion_phase = 4.9
+
+[node name="ControlsBackButton" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "back"
+neutral_size = Vector2(100, 94)
+desktop_anchor = Vector2(635, 470)
+compact_anchor = Vector2(555, 420)
+compact_minimum_size = Vector2(84, 80)
+minimum_font_size = 14
+maximum_font_size = 17
+motion_phase = 5.5
+
+[node name="AccessibilityPage" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("3_page")
+page_id = &"accessibility"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/IntervalHelp"), NodePath("BubbleCluster/AccessibilityBackButton")])
+focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/AccessibilityBackButton")])
+initial_focus_path = NodePath("BubbleCluster/AutoClickToggle")
+back_focus_path = NodePath("BubbleCluster/AccessibilityBackButton")
+
+[node name="BubbleCluster" type="Control" parent="AccessibilityPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("5_cluster")
+profile = ExtResource("6_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
+
+[node name="AutoClickToggle" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "accessibility\nauto-click\noff"
+neutral_size = Vector2(190, 180)
+desktop_anchor = Vector2(200, 195)
+compact_anchor = Vector2(129, 185)
+compact_minimum_size = Vector2(160, 152)
+minimum_font_size = 14
+maximum_font_size = 27
+motion_phase = 0.5
+
+[node name="IntervalDecrease" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "−"
+neutral_size = Vector2(80, 76)
+desktop_anchor = Vector2(340, 210)
+compact_anchor = Vector2(280, 205)
+compact_minimum_size = Vector2(68, 66)
+minimum_font_size = 17
+maximum_font_size = 24
+motion_phase = 1.4
+
+[node name="AutoClickIntervalValue" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "auto-click\ninterval\n0.20 s"
+neutral_size = Vector2(190, 180)
+desktop_anchor = Vector2(475, 210)
+compact_anchor = Vector2(431, 205)
+compact_minimum_size = Vector2(160, 152)
+minimum_font_size = 14
+maximum_font_size = 26
+motion_phase = 2.3
+
+[node name="IntervalIncrease" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "+"
+neutral_size = Vector2(80, 76)
+desktop_anchor = Vector2(610, 210)
+compact_anchor = Vector2(582, 205)
+compact_minimum_size = Vector2(68, 66)
+minimum_font_size = 17
+maximum_font_size = 24
+motion_phase = 3.2
+
+[node name="IntervalHelp" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+mouse_filter = 2
+focus_mode = 0
+text = "Focus a control\nfor a helpful\npreview."
+neutral_size = Vector2(210, 176)
+desktop_anchor = Vector2(315, 405)
+compact_anchor = Vector2(280, 370)
+compact_minimum_size = Vector2(160, 152)
+minimum_font_size = 13
+maximum_font_size = 22
+motion_phase = 4.1
+
+[node name="AccessibilityBackButton" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 0)
+text = "back"
+neutral_size = Vector2(96, 90)
+desktop_anchor = Vector2(560, 420)
+compact_anchor = Vector2(520, 380)
+compact_minimum_size = Vector2(82, 78)
+minimum_font_size = 13
+maximum_font_size = 16
+motion_phase = 5.0
+
+[node name="DataPage" type="Control" parent="."]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("3_page")
+page_id = &"data"
+bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")])
+focus_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")])
+initial_focus_path = NodePath("BubbleCluster/DataBackButton")
+back_focus_path = NodePath("BubbleCluster/DataBackButton")
+
+[node name="BubbleCluster" type="Control" parent="DataPage"]
+layout_mode = 0
+offset_right = 720.0
+offset_bottom = 520.0
+script = ExtResource("5_cluster")
+profile = ExtResource("6_profile")
+desktop_reference_size = Vector2(720, 520)
+compact_reference_size = Vector2(608, 448)
+
+[node name="Paper" type="PanelContainer" parent="DataPage"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -330.0
+offset_top = -255.0
+offset_right = 330.0
+offset_bottom = 210.0
+grow_horizontal = 2
+grow_vertical = 2
+z_index = 1
+
+[node name="Content" type="VBoxContainer" parent="DataPage/Paper"]
+layout_mode = 2
+theme_override_constants/separation = 10
+
+[node name="Heading" type="Label" parent="DataPage/Paper/Content"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 28
+text = "Data & Identity"
+horizontal_alignment = 1
+
+[node name="DataFolderLabel" type="Label" parent="DataPage/Paper/Content"]
+layout_mode = 2
+text = "Data Folder:"
+
+[node name="DataFolderPath" type="Label" parent="DataPage/Paper/Content"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Unavailable"
+autowrap_mode = 2
+
+[node name="DataStorageMode" type="Label" parent="DataPage/Paper/Content"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Storage Mode: unavailable"
+
+[node name="OpenDataFolder" type="Button" parent="DataPage/Paper/Content"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Open Data Folder"
+
+[node name="ChangeDataFolder" type="Button" parent="DataPage/Paper/Content"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Change Data Folder"
+
+[node name="IdentityHelper" type="Label" parent="DataPage/Paper/Content"]
+layout_mode = 2
+text = "Active identity keys stay on this device.\nEncrypted backups can be stored in your synced data folder.\nDo not play the same profile on two devices at the same time."
+autowrap_mode = 2
+
+[node name="CopyPlayerFingerprint" type="Button" parent="DataPage/Paper/Content"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Copy Player Fingerprint"
+
+[node name="IdentityGrid" type="GridContainer" parent="DataPage/Paper/Content"]
+layout_mode = 2
+columns = 2
+
+[node name="ExportPlayerIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Export Player Identity"
+
+[node name="ImportPlayerIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Import Player Identity"
+
+[node name="ExportHostIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Export Host Identity"
+
+[node name="ImportHostIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Import Host Identity"
+
+[node name="DataBackButton" parent="DataPage/BubbleCluster" instance=ExtResource("4_bubble")]
+unique_name_in_owner = true
+z_index = 4
+layout_mode = 0
+text = "back"
+neutral_size = Vector2(96, 90)
+desktop_anchor = Vector2(-25, 470)
+compact_anchor = Vector2(-20, 420)
+compact_minimum_size = Vector2(82, 78)
+minimum_font_size = 13
+maximum_font_size = 16
+motion_phase = 5.0
diff --git a/ui/surface_drawing_toolbar.gd b/ui/surface_drawing_toolbar.gd
new file mode 100644
index 0000000..e6c8efa
--- /dev/null
+++ b/ui/surface_drawing_toolbar.gd
@@ -0,0 +1,352 @@
+class_name SurfaceDrawingToolbar
+extends Control
+
+const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
+const MARKER_MODE_ICON: Texture2D = preload(
+ "res://items/icons/art/art_kit_marker.png"
+)
+const MARKER_MODE_SHADER: Shader = preload(
+ "res://ui/art_kit_marker_icon.gdshader"
+)
+const GRID_MODE_ICON: Texture2D = preload(
+ "res://items/icons/art/art_kit_grid_light.png"
+)
+const TOOLBAR_WIDTH: float = 510.0
+const TOOLBAR_HEIGHT: float = 373.0
+const COLOR_RAIL_WIDTH: float = 46.0
+
+@onready var _mode_button: Button = %ModeButton
+@onready var _brush_option: OptionButton = %BrushOption
+@onready var _grid_option: OptionButton = %GridOption
+@onready var _color_list: VBoxContainer = %ColorList
+@onready var _top_panel: PanelContainer = %TopPanel
+@onready var _color_panel: PanelContainer = %ColorPanel
+@onready var _eraser_button: Button = %EraserButton
+@onready var _undo_button: Button = %UndoButton
+@onready var _hide_guide_button: Button = %HideGuideButton
+@onready var _restore_guide_button: Button = %RestoreGuideButton
+@onready var _finalize_guide_button: Button = %FinalizeGuideButton
+
+var _service: NetworkSurfaceDrawingService
+var _unlocks: PlayerArtUnlocks
+var _color_buttons: Dictionary[StringName, Button] = {}
+var _dock_right: bool = true
+var _marker_mode_material: ShaderMaterial
+
+
+func _ready() -> void:
+ UtilityPageStyleType.apply_page(self)
+ _apply_toolbar_panel(_top_panel)
+ _apply_toolbar_panel(_color_panel)
+ UtilityPageStyleType.apply_ocean_button(_mode_button)
+ UtilityPageStyleType.apply_ocean_button(_brush_option)
+ UtilityPageStyleType.apply_ocean_button(_grid_option)
+ for button: Button in _action_buttons():
+ UtilityPageStyleType.apply_ocean_button(button)
+ button.custom_minimum_size = Vector2(48, 44)
+ _make_button_round(button, 22)
+ _mode_button.custom_minimum_size = Vector2(48, 48)
+ _make_button_round(_mode_button, 24)
+ _enlarge_action_icon(_mode_button)
+ _marker_mode_material = ShaderMaterial.new()
+ _marker_mode_material.shader = MARKER_MODE_SHADER
+ _marker_mode_material.set_shader_parameter(
+ "marker_color",
+ SurfaceDrawingPalette.get_color(SurfaceDrawingPalette.DEFAULT_COLOR_ID),
+ )
+ _mode_button.material = _marker_mode_material
+ for button: Button in _action_buttons():
+ _enlarge_action_icon(button)
+ _mode_button.pressed.connect(_toggle_mode)
+ _eraser_button.pressed.connect(_toggle_eraser)
+ _undo_button.pressed.connect(_undo_last_stroke)
+ _hide_guide_button.pressed.connect(
+ _arm_guide_action.bind(NetworkSurfaceDrawingService.GuideAction.HIDE)
+ )
+ _restore_guide_button.pressed.connect(
+ _arm_guide_action.bind(NetworkSurfaceDrawingService.GuideAction.RESTORE)
+ )
+ _finalize_guide_button.pressed.connect(
+ _arm_guide_action.bind(NetworkSurfaceDrawingService.GuideAction.FINALIZE)
+ )
+ _brush_option.item_selected.connect(_select_brush)
+ _grid_option.item_selected.connect(_select_grid)
+ _build_options()
+ _apply_dock_side()
+ hide()
+
+
+func _apply_toolbar_panel(panel: PanelContainer) -> void:
+ var style: StyleBoxFlat = UtilityPageStyleType.panel_style()
+ style.content_margin_left = 6.0
+ style.content_margin_top = 6.0
+ style.content_margin_right = 6.0
+ style.content_margin_bottom = 6.0
+ style.set_corner_radius_all(10)
+ panel.add_theme_stylebox_override("panel", style)
+
+
+func _action_buttons() -> Array[Button]:
+ return [
+ _eraser_button,
+ _undo_button,
+ _hide_guide_button,
+ _restore_guide_button,
+ _finalize_guide_button,
+ ]
+
+
+func _make_button_round(button: Button, radius: int) -> void:
+ for style_name: StringName in [
+ &"normal", &"hover", &"pressed", &"focus", &"disabled",
+ ]:
+ var existing: StyleBox = button.get_theme_stylebox(style_name)
+ var style := existing.duplicate() as StyleBoxFlat
+ if style == null:
+ continue
+ style.set_corner_radius_all(radius)
+ button.add_theme_stylebox_override(style_name, style)
+
+
+func _enlarge_action_icon(button: Button) -> void:
+ # The shared ocean button uses generous text padding, leaving only a
+ # 20-pixel icon area in these compact bubbles. Keep the 48x44 control and
+ # hitbox intact while giving icon-only actions a full 40x40 presentation.
+ button.add_theme_constant_override("icon_max_width", 40)
+ for style_name: StringName in [
+ &"normal", &"hover", &"pressed", &"focus", &"disabled",
+ ]:
+ var existing: StyleBox = button.get_theme_stylebox(style_name)
+ var style := existing.duplicate() as StyleBoxFlat
+ if style == null:
+ continue
+ style.content_margin_left = 4.0
+ style.content_margin_right = 4.0
+ style.content_margin_top = 2.0
+ style.content_margin_bottom = 2.0
+ button.add_theme_stylebox_override(style_name, style)
+
+
+func setup(
+ service: NetworkSurfaceDrawingService,
+ unlocks: PlayerArtUnlocks,
+) -> void:
+ _service = service
+ _unlocks = unlocks
+ if _service != null and not _service.hud_state_changed.is_connected(
+ _on_service_state_changed
+ ):
+ _service.hud_state_changed.connect(_on_service_state_changed)
+ if _unlocks != null and not _unlocks.unlocks_changed.is_connected(
+ _on_unlocks_changed
+ ):
+ _unlocks.unlocks_changed.connect(_on_unlocks_changed)
+ _refresh_unlocks()
+
+
+func set_dock_right(should_dock_right: bool) -> void:
+ _dock_right = should_dock_right
+ if is_node_ready():
+ _apply_dock_side()
+
+
+func is_docked_right() -> bool:
+ return _dock_right
+
+
+func _apply_dock_side() -> void:
+ anchor_left = 1.0 if _dock_right else 0.0
+ anchor_right = anchor_left
+ offset_left = -TOOLBAR_WIDTH if _dock_right else 0.0
+ offset_right = 0.0 if _dock_right else TOOLBAR_WIDTH
+ offset_top = 0.0
+ offset_bottom = TOOLBAR_HEIGHT
+ _top_panel.offset_left = 0.0
+ _top_panel.offset_right = TOOLBAR_WIDTH
+ _color_panel.offset_left = (
+ TOOLBAR_WIDTH - COLOR_RAIL_WIDTH if _dock_right else 0.0
+ )
+ _color_panel.offset_right = (
+ TOOLBAR_WIDTH if _dock_right else COLOR_RAIL_WIDTH
+ )
+
+
+func owns_pointer_event(event: InputEvent) -> bool:
+ if not visible:
+ return false
+ if _brush_option.get_popup().visible or _grid_option.get_popup().visible:
+ return true
+ if event is InputEventMouse:
+ var pointer_position: Vector2 = (event as InputEventMouse).position
+ return (
+ _top_panel.get_global_rect().has_point(pointer_position)
+ or _color_panel.get_global_rect().has_point(pointer_position)
+ )
+ return false
+
+
+func _build_options() -> void:
+ _brush_option.clear()
+ for brush_size: int in PlayerArtUnlocks.BRUSH_SIZES:
+ _brush_option.add_item("%d×" % brush_size, brush_size)
+ _grid_option.clear()
+ for grid_size: int in PlayerArtUnlocks.GRID_SIZES:
+ _grid_option.add_item("%d×" % grid_size, grid_size)
+ for child: Node in _color_list.get_children():
+ child.queue_free()
+ _color_buttons.clear()
+ for color_id: StringName in SurfaceDrawingPalette.get_color_ids():
+ var button := Button.new()
+ button.custom_minimum_size = Vector2(34, 34)
+ button.focus_mode = Control.FOCUS_ALL
+ button.tooltip_text = SurfaceDrawingPalette.get_display_name(color_id)
+ button.pressed.connect(_select_color.bind(color_id))
+ _color_list.add_child(button)
+ _color_buttons[color_id] = button
+ _apply_color_button_style(button, color_id, false)
+
+
+func _refresh_unlocks() -> void:
+ if not is_node_ready() or _unlocks == null:
+ return
+ var brush_popup: PopupMenu = _brush_option.get_popup()
+ for index: int in range(_brush_option.item_count):
+ var brush_size: int = _brush_option.get_item_id(index)
+ brush_popup.set_item_disabled(
+ index, not _unlocks.is_brush_size_unlocked(brush_size)
+ )
+ var grid_popup: PopupMenu = _grid_option.get_popup()
+ for index: int in range(_grid_option.item_count):
+ var grid_size: int = _grid_option.get_item_id(index)
+ grid_popup.set_item_disabled(
+ index, not _unlocks.is_grid_size_unlocked(grid_size)
+ )
+ for color_id: StringName in _color_buttons:
+ var button: Button = _color_buttons[color_id]
+ button.disabled = not _unlocks.is_color_unlocked(color_id)
+ _apply_color_button_style(
+ button,
+ color_id,
+ (
+ _service != null
+ and not _service.is_eraser_mode()
+ and _service.get_color_id() == color_id
+ ),
+ )
+
+
+func _apply_color_button_style(
+ button: Button,
+ color_id: StringName,
+ selected: bool,
+) -> void:
+ var color: Color = (
+ SurfaceDrawingPalette.get_color(color_id)
+ if not button.disabled
+ else UtilityPageStyleType.OCEAN_DISABLED
+ )
+ for style_name: StringName in [&"normal", &"hover", &"pressed", &"focus"]:
+ var style := StyleBoxFlat.new()
+ style.bg_color = color.lightened(0.12) if style_name == &"hover" else color
+ style.set_border_width_all(3 if selected else 0)
+ style.border_color = UtilityPageStyleType.OCEAN_TEXT_PRIMARY
+ style.set_corner_radius_all(17)
+ button.add_theme_stylebox_override(style_name, style)
+ var disabled_style := StyleBoxFlat.new()
+ disabled_style.bg_color = UtilityPageStyleType.OCEAN_DISABLED
+ disabled_style.set_corner_radius_all(17)
+ button.add_theme_stylebox_override("disabled", disabled_style)
+
+
+func _toggle_mode() -> void:
+ if _service != null:
+ _service.set_placement_mode(not _service.is_placement_mode())
+
+
+func _select_brush(index: int) -> void:
+ if _service != null:
+ _service.set_brush_size(_brush_option.get_item_id(index))
+
+
+func _select_grid(index: int) -> void:
+ if _service != null:
+ _service.set_grid_size(_grid_option.get_item_id(index))
+
+
+func _select_color(color_id: StringName) -> void:
+ if _service != null:
+ _service.set_color_id(color_id)
+
+
+func _toggle_eraser() -> void:
+ if _service != null:
+ _service.set_eraser_mode(not _service.is_eraser_mode())
+
+
+func _undo_last_stroke() -> void:
+ if _service != null:
+ _service.request_undo_last_stroke()
+
+
+func _arm_guide_action(action: int) -> void:
+ if _service != null:
+ _service.arm_guide_action(action)
+
+
+func _on_unlocks_changed(_unlock_mask: int) -> void:
+ _refresh_unlocks()
+
+
+func _on_service_state_changed(
+ is_active: bool,
+ mode_name: String,
+ _color_name: String,
+ color_value: Color,
+ brush_size: int,
+ grid_size: int,
+ _status: String,
+) -> void:
+ visible = is_active
+ if not is_active:
+ return
+ _mode_button.icon = (
+ GRID_MODE_ICON if mode_name == "place grid" else MARKER_MODE_ICON
+ )
+ _marker_mode_material.set_shader_parameter("marker_color", color_value)
+ _mode_button.accessibility_name = (
+ "switch to marker mode"
+ if mode_name == "place grid"
+ else "switch to grid mode"
+ )
+ _mode_button.tooltip_text = (
+ "Switch to marker mode"
+ if mode_name == "place grid"
+ else "Switch to grid mode"
+ )
+ _select_option_by_id(_brush_option, brush_size)
+ _select_option_by_id(_grid_option, grid_size)
+ _refresh_action_state()
+ _refresh_unlocks()
+
+
+func _refresh_action_state() -> void:
+ if _service == null:
+ return
+ _eraser_button.button_pressed = _service.is_eraser_mode()
+ var guide_action: int = _service.get_armed_guide_action()
+ _hide_guide_button.button_pressed = (
+ guide_action == NetworkSurfaceDrawingService.GuideAction.HIDE
+ )
+ _restore_guide_button.button_pressed = (
+ guide_action == NetworkSurfaceDrawingService.GuideAction.RESTORE
+ )
+ _finalize_guide_button.button_pressed = (
+ guide_action == NetworkSurfaceDrawingService.GuideAction.FINALIZE
+ )
+
+
+func _select_option_by_id(option: OptionButton, item_id: int) -> void:
+ for index: int in range(option.item_count):
+ if option.get_item_id(index) == item_id:
+ option.select(index)
+ return
diff --git a/ui/surface_drawing_toolbar.gd.uid b/ui/surface_drawing_toolbar.gd.uid
new file mode 100644
index 0000000..d116d9d
--- /dev/null
+++ b/ui/surface_drawing_toolbar.gd.uid
@@ -0,0 +1 @@
+uid://ebnfrdwfm1gm
diff --git a/ui/surface_drawing_toolbar.tscn b/ui/surface_drawing_toolbar.tscn
new file mode 100644
index 0000000..448cae2
--- /dev/null
+++ b/ui/surface_drawing_toolbar.tscn
@@ -0,0 +1,124 @@
+[gd_scene load_steps=11 format=3]
+
+[ext_resource type="Script" path="res://ui/surface_drawing_toolbar.gd" id="1_script"]
+[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/undo_light.png" id="3_undo_light"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit_eraser.png" id="4_art_kit_eraser"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit_marker.png" id="5_art_kit_marker"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit_grid_light.png" id="6_grid_light"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit_grid_hidden_light.png" id="7_grid_hidden"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit_grid_restore_light.png" id="8_grid_restore"]
+[ext_resource type="Texture2D" path="res://items/icons/art/art_kit_grid_finish_light.png" id="9_grid_finish"]
+
+[sub_resource type="ButtonGroup" id="GuideActionGroup"]
+allow_unpress = true
+
+[node name="SurfaceDrawingToolbar" type="Control"]
+visible = false
+z_index = 85
+layout_mode = 0
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -510.0
+offset_bottom = 373.0
+grow_horizontal = 0
+mouse_filter = 2
+theme = ExtResource("2_theme")
+script = ExtResource("1_script")
+
+[node name="TopPanel" type="PanelContainer" parent="."]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 510.0
+offset_bottom = 60.0
+
+[node name="Top" type="HBoxContainer" parent="TopPanel"]
+layout_mode = 2
+theme_override_constants/separation = 5
+
+[node name="BrushOption" type="OptionButton" parent="TopPanel/Top"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(80, 48)
+layout_mode = 2
+tooltip_text = "brush size"
+
+[node name="GridOption" type="OptionButton" parent="TopPanel/Top"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(92, 48)
+layout_mode = 2
+tooltip_text = "grid size"
+
+[node name="EraserButton" type="Button" parent="TopPanel/Top"]
+unique_name_in_owner = true
+layout_mode = 2
+texture_filter = 1
+tooltip_text = "toggle eraser"
+toggle_mode = true
+accessibility_name = "toggle eraser"
+icon = ExtResource("4_art_kit_eraser")
+expand_icon = true
+
+[node name="UndoButton" type="Button" parent="TopPanel/Top"]
+unique_name_in_owner = true
+layout_mode = 2
+texture_filter = 1
+tooltip_text = "undo last stroke"
+accessibility_name = "undo last stroke"
+icon = ExtResource("3_undo_light")
+expand_icon = true
+
+[node name="HideGuideButton" type="Button" parent="TopPanel/Top"]
+unique_name_in_owner = true
+layout_mode = 2
+texture_filter = 1
+tooltip_text = "toggle hide grid mode"
+toggle_mode = true
+button_group = SubResource("GuideActionGroup")
+accessibility_name = "toggle hide grid mode"
+icon = ExtResource("7_grid_hidden")
+expand_icon = true
+
+[node name="RestoreGuideButton" type="Button" parent="TopPanel/Top"]
+unique_name_in_owner = true
+layout_mode = 2
+texture_filter = 1
+tooltip_text = "toggle show grid mode"
+toggle_mode = true
+button_group = SubResource("GuideActionGroup")
+accessibility_name = "toggle show grid mode"
+icon = ExtResource("8_grid_restore")
+expand_icon = true
+
+[node name="FinalizeGuideButton" type="Button" parent="TopPanel/Top"]
+unique_name_in_owner = true
+layout_mode = 2
+texture_filter = 1
+tooltip_text = "toggle finalize grid mode"
+toggle_mode = true
+button_group = SubResource("GuideActionGroup")
+accessibility_name = "toggle finalize grid mode"
+icon = ExtResource("9_grid_finish")
+expand_icon = true
+
+[node name="ModeButton" type="Button" parent="TopPanel/Top"]
+unique_name_in_owner = true
+layout_mode = 2
+texture_filter = 1
+tooltip_text = "switch between grid and marker mode"
+accessibility_name = "switch to grid mode"
+icon = ExtResource("5_art_kit_marker")
+expand_icon = true
+
+[node name="ColorPanel" type="PanelContainer" parent="."]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 464.0
+offset_top = 54.0
+offset_right = 510.0
+offset_bottom = 373.0
+
+[node name="ColorList" type="VBoxContainer" parent="ColorPanel"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_constants/separation = 5
diff --git a/ui/the_net_page.gd b/ui/the_net_page.gd
new file mode 100644
index 0000000..ff83a11
--- /dev/null
+++ b/ui/the_net_page.gd
@@ -0,0 +1,517 @@
+class_name TheNetPage
+extends Control
+
+const CurrencyPresentationType = preload(
+ "res://ui/currency_presentation.gd"
+)
+
+enum View {
+ DAILY,
+ LIFETIME,
+ PAYMENTS,
+}
+
+var _jobs: PlayerJobService
+var _world_time: WorldTimeService
+var _header: Label
+var _refresh_label: Label
+var _forecast_list: HBoxContainer
+var _tabs: HBoxContainer
+var _list: VBoxContainer
+var _status: Label
+var _current_view: View = View.DAILY
+var _forecast_start_index: int = -1
+var _active: bool = false
+var _interactive: bool = false
+
+
+func _ready() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ UtilityPageStyle.apply_page(self)
+ _build_laptop()
+ set_process(false)
+
+
+func setup(jobs: PlayerJobService, world_time: WorldTimeService) -> void:
+ _jobs = jobs
+ _world_time = world_time
+ _jobs.changed.connect(_refresh)
+ _jobs.status_changed.connect(_on_status_changed)
+ _refresh()
+
+
+func activate() -> void:
+ _active = true
+ set_process(true)
+ _refresh()
+ focus_initial()
+
+
+func deactivate() -> void:
+ _active = false
+ set_process(false)
+ set_interactive(false)
+ _status.text = ""
+
+
+func set_interactive(interactive: bool) -> void:
+ _interactive = interactive
+ mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ if _tabs != null:
+ for child: Node in _tabs.get_children():
+ var button := child as Button
+ if button != null:
+ button.focus_mode = (
+ Control.FOCUS_ALL
+ if interactive
+ else Control.FOCUS_NONE
+ )
+ button.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ _refresh()
+
+
+func focus_initial() -> void:
+ if _interactive and _tabs != null and _tabs.get_child_count() > 0:
+ var button := _tabs.get_child(int(_current_view)) as Button
+ if button != null:
+ button.grab_focus()
+
+
+func _process(_delta: float) -> void:
+ if _active and _jobs != null:
+ _refresh_label.text = _daily_refresh_text()
+ _refresh_forecast(false)
+
+
+func _build_laptop() -> void:
+ var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self)
+
+ var layout := VBoxContainer.new()
+ layout.add_theme_constant_override("separation", 9)
+ margin.add_child(layout)
+
+ var title_row := HBoxContainer.new()
+ title_row.add_theme_constant_override("separation", 16)
+ layout.add_child(title_row)
+ var header_left := VBoxContainer.new()
+ header_left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ header_left.add_theme_constant_override("separation", 0)
+ title_row.add_child(header_left)
+ _header = Label.new()
+ _header.text = "fishnet"
+ _header.add_theme_font_size_override("font_size", 32)
+ _header.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ header_left.add_child(_header)
+ var header_spacer := Control.new()
+ header_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ header_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ header_left.add_child(header_spacer)
+ _tabs = HBoxContainer.new()
+ _tabs.add_theme_constant_override("separation", 8)
+ header_left.add_child(_tabs)
+ for view_index: int in 3:
+ var tab := Button.new()
+ tab.text = ["daily jobs", "lifetime jobs", "payments"][view_index]
+ tab.toggle_mode = true
+ tab.pressed.connect(_select_view.bind(view_index as View))
+ UtilityPageStyle.apply_ocean_button(tab)
+ _tabs.add_child(tab)
+ var refresh_alignment_spacer := Control.new()
+ refresh_alignment_spacer.custom_minimum_size.y = 20.0
+ refresh_alignment_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ header_left.add_child(refresh_alignment_spacer)
+ var forecast_column := VBoxContainer.new()
+ forecast_column.custom_minimum_size.x = 292.0
+ forecast_column.size_flags_horizontal = Control.SIZE_SHRINK_END
+ forecast_column.add_theme_constant_override("separation", 3)
+ title_row.add_child(forecast_column)
+ var forecast_panel := PanelContainer.new()
+ forecast_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
+ forecast_panel.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.row_style(false)
+ )
+ forecast_column.add_child(forecast_panel)
+ var forecast_margin := MarginContainer.new()
+ forecast_margin.add_theme_constant_override("margin_left", 10)
+ forecast_margin.add_theme_constant_override("margin_top", 8)
+ forecast_margin.add_theme_constant_override("margin_right", 10)
+ forecast_margin.add_theme_constant_override("margin_bottom", 8)
+ forecast_panel.add_child(forecast_margin)
+ var forecast_stack := VBoxContainer.new()
+ forecast_stack.add_theme_constant_override("separation", 7)
+ forecast_margin.add_child(forecast_stack)
+ var forecast_heading := Label.new()
+ forecast_heading.text = "weather forecast"
+ forecast_heading.add_theme_font_size_override("font_size", 20)
+ forecast_heading.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ forecast_stack.add_child(forecast_heading)
+ _forecast_list = HBoxContainer.new()
+ _forecast_list.custom_minimum_size = Vector2(272.0, 66.0)
+ _forecast_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER
+ _forecast_list.add_theme_constant_override("separation", 6)
+ forecast_stack.add_child(_forecast_list)
+ _refresh_label = Label.new()
+ _refresh_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ _refresh_label.add_theme_font_size_override("font_size", 14)
+ _refresh_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ forecast_column.add_child(_refresh_label)
+
+ var jobs_column := VBoxContainer.new()
+ jobs_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ jobs_column.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ jobs_column.add_theme_constant_override("separation", 8)
+ layout.add_child(jobs_column)
+
+ var scroll := ScrollContainer.new()
+ scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ jobs_column.add_child(scroll)
+ _list = VBoxContainer.new()
+ _list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _list.add_theme_constant_override("separation", 7)
+ scroll.add_child(_list)
+
+ _status = Label.new()
+ _status.add_theme_font_size_override("font_size", 16)
+ _status.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ jobs_column.add_child(_status)
+
+func _select_view(view: View) -> void:
+ _current_view = view
+ _refresh()
+ focus_initial()
+
+
+func _refresh() -> void:
+ if _jobs == null or _list == null:
+ return
+ _refresh_label.text = _daily_refresh_text()
+ _refresh_forecast(true)
+ for index: int in _tabs.get_child_count():
+ var tab := _tabs.get_child(index) as Button
+ if tab != null:
+ tab.button_pressed = index == int(_current_view)
+ for child: Node in _list.get_children():
+ _list.remove_child(child)
+ child.queue_free()
+ match _current_view:
+ View.DAILY:
+ _build_job_rows(_jobs.get_daily_jobs(), "no daily jobs available")
+ View.LIFETIME:
+ _build_job_rows(
+ _jobs.get_lifetime_jobs(), "all lifetime jobs complete"
+ )
+ View.PAYMENTS:
+ _build_payment_rows()
+
+
+func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void:
+ if jobs.is_empty():
+ _add_empty(empty_text)
+ return
+ for job: Dictionary in jobs:
+ var row := PanelContainer.new()
+ row.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.row_style(false)
+ )
+ var content := HBoxContainer.new()
+ content.add_theme_constant_override("separation", 14)
+ row.add_child(content)
+
+ var text_column := VBoxContainer.new()
+ text_column.custom_minimum_size.x = 245.0
+ text_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ content.add_child(text_column)
+ var title := Label.new()
+ title.text = str(job.get("title", "job"))
+ title.add_theme_font_size_override("font_size", 20)
+ title.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ text_column.add_child(title)
+ var description := Label.new()
+ description.text = str(job.get("description", ""))
+ description.add_theme_font_size_override("font_size", 16)
+ description.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ text_column.add_child(description)
+
+ var target: int = int(job.get("target", 1))
+ var progress: int = int(job.get("progress", 0))
+ var progress_bar := ProgressBar.new()
+ progress_bar.custom_minimum_size = Vector2(100.0, 24.0)
+ progress_bar.max_value = float(target)
+ progress_bar.value = float(progress)
+ progress_bar.show_percentage = false
+ progress_bar.add_theme_stylebox_override(
+ "background", UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_PANEL_DEEP, 9
+ )
+ )
+ progress_bar.add_theme_stylebox_override(
+ "fill", UtilityPageStyle.rounded_style(
+ UtilityPageStyle.OCEAN_SELECTED, 9
+ )
+ )
+ content.add_child(progress_bar)
+
+ var count := Label.new()
+ count.custom_minimum_size.x = 54.0
+ count.text = "%d / %d" % [progress, target]
+ count.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ count.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ content.add_child(count)
+
+ var reward := _make_reward_display(
+ int(job.get("fish_coin", 0)),
+ int(job.get("experience", 0)),
+ )
+ reward.custom_minimum_size.x = 128.0
+ content.add_child(reward)
+
+ var claim := Button.new()
+ claim.custom_minimum_size = Vector2(76.0, 36.0)
+ var claimable: bool = bool(job.get("claimable", false))
+ claim.text = "claim" if claimable else (
+ "done" if int(job.get("completed_count", 0)) > 0 else "working"
+ )
+ claim.disabled = not claimable or not _interactive
+ claim.focus_mode = (
+ Control.FOCUS_ALL if not claim.disabled else Control.FOCUS_NONE
+ )
+ claim.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if not claim.disabled
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ var claim_id: String = str(
+ job.get("claim_id", job.get("id", ""))
+ )
+ claim.pressed.connect(_claim.bind(claim_id))
+ UtilityPageStyle.apply_compact_ocean_button(claim)
+ content.add_child(claim)
+ _list.add_child(row)
+
+
+func _build_payment_rows() -> void:
+ var rewards: Array[Dictionary] = _jobs.get_pending_rewards()
+ if rewards.is_empty():
+ _add_empty("no completed payments waiting")
+ return
+ for reward: Dictionary in rewards:
+ var row := PanelContainer.new()
+ row.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.row_style(false)
+ )
+ var content := HBoxContainer.new()
+ content.add_theme_constant_override("separation", 14)
+ row.add_child(content)
+ var reward_details := VBoxContainer.new()
+ reward_details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ var label := Label.new()
+ label.text = str(reward.get("title", "completed job"))
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ reward_details.add_child(label)
+ reward_details.add_child(_make_reward_display(
+ int(reward.get("fish_coin", 0)),
+ int(reward.get("experience", 0)),
+ ))
+ content.add_child(reward_details)
+ var claim := Button.new()
+ claim.text = "claim"
+ claim.custom_minimum_size = Vector2(110.0, 42.0)
+ claim.disabled = not _interactive
+ claim.focus_mode = (
+ Control.FOCUS_ALL if _interactive else Control.FOCUS_NONE
+ )
+ claim.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if _interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ claim.pressed.connect(
+ _claim.bind(str(reward.get("claim_id", "")))
+ )
+ UtilityPageStyle.apply_ocean_button(claim)
+ content.add_child(claim)
+ _list.add_child(row)
+
+
+func _claim(claim_id: String) -> void:
+ if not _jobs.claim(claim_id):
+ _status.text = "payment could not be completed"
+ _refresh()
+
+
+func _make_reward_display(fish_coin: int, experience: int) -> HBoxContainer:
+ var reward := HBoxContainer.new()
+ reward.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ reward.add_theme_constant_override("separation", 7)
+ var currency: CurrencyAmount = (
+ CurrencyPresentationType.instantiate_amount(fish_coin, 18.0)
+ )
+ currency.get_amount_label().add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ reward.add_child(currency)
+ var experience_label := Label.new()
+ experience_label.text = "• %d xp" % experience
+ experience_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ experience_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ reward.add_child(experience_label)
+ return reward
+
+
+func _add_empty(message: String) -> void:
+ var empty := Label.new()
+ empty.text = message
+ empty.custom_minimum_size = Vector2(0.0, 92.0)
+ empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ empty.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ empty.add_theme_font_size_override("font_size", 20)
+ empty.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _list.add_child(empty)
+
+
+func _refresh_forecast(force: bool) -> void:
+ if _forecast_list == null:
+ return
+ var current_index: int = _current_forecast_index()
+ if not force and current_index == _forecast_start_index:
+ return
+ _forecast_start_index = current_index
+ for child: Node in _forecast_list.get_children():
+ _forecast_list.remove_child(child)
+ child.queue_free()
+ if _jobs == null or _world_time == null:
+ _add_forecast_empty("forecast unavailable")
+ return
+ var forecast: Array[Dictionary] = _jobs.get_forecast()
+ if forecast.is_empty():
+ _add_forecast_empty("forecast unavailable")
+ return
+ current_index = clampi(current_index, 0, forecast.size() - 1)
+ var visible_count: int = mini(4, forecast.size())
+ for offset: int in visible_count:
+ var index: int = (current_index + offset) % forecast.size()
+ var entry: Dictionary = forecast[index]
+ var start_hour: float = float(entry.get("start_hour", 0.0))
+ var weather: WorldWeatherService.Weather = (
+ int(entry.get("weather", 0)) as WorldWeatherService.Weather
+ )
+ var slot := Control.new()
+ slot.custom_minimum_size = Vector2(63.0, 66.0)
+ slot.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _forecast_list.add_child(slot)
+ var icon := WeatherIcon.new()
+ icon.position = Vector2(10.5, 17.0)
+ icon.size = Vector2(42.0, 42.0)
+ icon.custom_minimum_size = Vector2(42.0, 42.0)
+ icon.set_weather(weather)
+ icon.set_nighttime(
+ WorldTimeService.phase_for_hour(start_hour)
+ == WorldTimeService.Phase.NIGHT
+ )
+ slot.add_child(icon)
+ var time_label := Label.new()
+ time_label.position = Vector2(27.0, 0.0)
+ time_label.size = Vector2(36.0, 22.0)
+ time_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ time_label.text = _forecast_exponent_time(start_hour)
+ time_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ time_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ time_label.add_theme_font_size_override("font_size", 13)
+ time_label.add_theme_constant_override("outline_size", 3)
+ time_label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
+ )
+ time_label.add_theme_color_override(
+ "font_outline_color", UtilityPageStyle.OCEAN_PANEL_DEEP
+ )
+ slot.add_child(time_label)
+
+
+func _current_forecast_index() -> int:
+ if _world_time == null:
+ return -1
+ var elapsed: float = fposmod(
+ _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR,
+ WorldTimeService.HOURS_PER_DAY,
+ )
+ return floori(elapsed / JobCatalog.WEATHER_SEGMENT_HOURS)
+
+
+func _compact_clock_time(time_hours: float) -> String:
+ var normalized: float = fposmod(
+ time_hours, WorldTimeService.HOURS_PER_DAY
+ )
+ var total_minutes: int = floori(normalized * 60.0)
+ var hour_24: int = floori(float(total_minutes) / 60.0)
+ var minute: int = total_minutes % 60
+ var hour_12: int = hour_24 % 12
+ if hour_12 == 0:
+ hour_12 = 12
+ var suffix: String = "AM" if hour_24 < 12 else "PM"
+ if minute == 0:
+ return "%d%s" % [hour_12, suffix]
+ return "%d:%02d%s" % [hour_12, minute, suffix]
+
+
+func _forecast_exponent_time(time_hours: float) -> String:
+ return _compact_clock_time(time_hours).to_lower()
+
+
+func _daily_refresh_text() -> String:
+ if _jobs == null:
+ return "daily jobs unavailable"
+ var countdown: String = _jobs.get_time_until_refresh_text()
+ if countdown.begins_with("refreshes in "):
+ return "daily jobs refresh in %s" % countdown.trim_prefix(
+ "refreshes in "
+ )
+ return countdown
+
+
+func _add_forecast_empty(message: String) -> void:
+ var label := Label.new()
+ label.text = message
+ label.custom_minimum_size = Vector2(252.0, 66.0)
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ label.autowrap_mode = TextServer.AUTOWRAP_OFF
+ label.add_theme_color_override(
+ "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
+ )
+ _forecast_list.add_child(label)
+
+
+func _on_status_changed(message: String) -> void:
+ _status.text = message
diff --git a/ui/the_net_page.gd.uid b/ui/the_net_page.gd.uid
new file mode 100644
index 0000000..614db5e
--- /dev/null
+++ b/ui/the_net_page.gd.uid
@@ -0,0 +1 @@
+uid://couoolj844sj3
diff --git a/ui/the_net_page.tscn b/ui/the_net_page.tscn
new file mode 100644
index 0000000..db0bb43
--- /dev/null
+++ b/ui/the_net_page.tscn
@@ -0,0 +1,13 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://ui/the_net_page.gd" id="1_net"]
+
+[node name="TheNetPage" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
+script = ExtResource("1_net")
diff --git a/ui/title_confirmation_bubble_page.gd b/ui/title_confirmation_bubble_page.gd
new file mode 100644
index 0000000..a1a75da
--- /dev/null
+++ b/ui/title_confirmation_bubble_page.gd
@@ -0,0 +1,259 @@
+class_name TitleConfirmationBubblePage
+extends Control
+
+signal confirmed
+signal cancelled
+
+@export var cluster_path: NodePath = ^"BubbleCluster"
+@export var prompt_path: NodePath = ^"BubbleCluster/PromptBubble"
+@export var prompt_label_path: NodePath = ^"BubbleCluster/PromptBubble/PromptLabel"
+@export var confirm_path: NodePath = ^"BubbleCluster/ConfirmButton"
+@export var confirm_label_path: NodePath = (
+ ^"BubbleCluster/ConfirmButton/ConfirmLabel"
+)
+@export var cancel_path: NodePath = ^"BubbleCluster/CancelButton"
+@export var multiline_confirm_neutral_size: Vector2 = Vector2(116.0, 116.0)
+@export var multiline_confirm_compact_minimum_size: Vector2 = (
+ Vector2(90.0, 90.0)
+)
+@export var compact_height_threshold: float = 250.0
+@export_range(0.0, 128.0, 1.0) var transition_safe_margin: float = 24.0
+
+var _cluster: BubbleCluster
+var _prompt: BubbleButton
+var _prompt_label: Label
+var _confirm_button: BubbleButton
+var _confirm_label: Label
+var _cancel_button: BubbleButton
+var _default_confirm_neutral_size: Vector2
+var _default_confirm_compact_minimum_size: Vector2
+var _bubbles: Array[BubbleButton] = []
+var _resting_cluster_position: Vector2 = Vector2.ZERO
+var _transition: Tween
+var _transition_generation: int = 0
+var _is_transitioning: bool = false
+
+
+func _ready() -> void:
+ _cluster = get_node(cluster_path) as BubbleCluster
+ _prompt = get_node(prompt_path) as BubbleButton
+ _prompt_label = get_node(prompt_label_path) as Label
+ _confirm_button = get_node(confirm_path) as BubbleButton
+ _confirm_label = get_node(confirm_label_path) as Label
+ _cancel_button = get_node(cancel_path) as BubbleButton
+ _default_confirm_neutral_size = _confirm_button.neutral_size
+ _default_confirm_compact_minimum_size = (
+ _confirm_button.compact_minimum_size
+ )
+ _bubbles = [_prompt, _confirm_button, _cancel_button]
+ _cluster.configure(_bubbles)
+ _prompt.focus_mode = Control.FOCUS_NONE
+ _prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _confirm_button.pressed.connect(confirmed.emit)
+ _cancel_button.pressed.connect(cancelled.emit)
+ _configure_focus()
+ hide_page()
+
+
+func configure(
+ prompt_text: String,
+ confirm_text: String,
+ use_multiline_action_layout: bool = false,
+) -> void:
+ _prompt_label.text = prompt_text
+ if use_multiline_action_layout:
+ _confirm_button.text = ""
+ _confirm_label.text = confirm_text
+ _confirm_label.show()
+ _confirm_button.label_control_path = ^"ConfirmLabel"
+ else:
+ _confirm_label.text = ""
+ _confirm_label.hide()
+ _confirm_button.label_control_path = NodePath()
+ _confirm_button.text = confirm_text
+ _confirm_button.apply_profile()
+ _confirm_button.neutral_size = (
+ multiline_confirm_neutral_size
+ if use_multiline_action_layout
+ else _default_confirm_neutral_size
+ )
+ _confirm_button.compact_minimum_size = (
+ multiline_confirm_compact_minimum_size
+ if use_multiline_action_layout
+ else _default_confirm_compact_minimum_size
+ )
+
+
+func set_stage_rect(stage_rect: Rect2) -> void:
+ if not is_node_ready():
+ return
+ var field_size := Vector2(
+ maxf(1.0, stage_rect.size.x),
+ maxf(1.0, stage_rect.size.y)
+ )
+ _resting_cluster_position = stage_rect.position
+ _cluster.size = field_size
+ _cluster.apply_layout(
+ field_size,
+ false
+ )
+ if not _is_transitioning:
+ _cluster.position = _resting_cluster_position
+
+
+func transition_in(
+ _duration: float,
+ completed: Callable,
+) -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ show()
+ _is_transitioning = true
+ _set_interactive(false)
+ set_process(true)
+ _cluster.position = Vector2(
+ _resting_cluster_position.x,
+ size.y + transition_safe_margin
+ )
+ var duration := UIMotion.bubble_duration(
+ _cluster.position.y - _resting_cluster_position.y
+ )
+ var generation: int = _transition_generation
+ _transition = create_tween()
+ _transition.tween_property(
+ _cluster,
+ "position",
+ _resting_cluster_position,
+ duration
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _transition.finished.connect(
+ _finish_transition_in.bind(generation, completed),
+ CONNECT_ONE_SHOT
+ )
+
+
+func transition_out(
+ _duration: float,
+ completed: Callable,
+) -> void:
+ if not visible or _is_transitioning:
+ return
+ _transition_generation += 1
+ _cancel_transition()
+ _is_transitioning = true
+ _set_interactive(false)
+ var target_y: float = -_cluster.size.y - transition_safe_margin
+ var duration := UIMotion.bubble_duration(
+ target_y - _cluster.position.y
+ )
+ var generation: int = _transition_generation
+ _transition = create_tween()
+ _transition.tween_property(
+ _cluster,
+ "position:y",
+ target_y,
+ duration
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _transition.finished.connect(
+ _finish_transition_out.bind(generation, completed),
+ CONNECT_ONE_SHOT
+ )
+
+
+func hide_page() -> void:
+ _transition_generation += 1
+ _cancel_transition()
+ _is_transitioning = false
+ _set_interactive(false)
+ set_process(false)
+ hide()
+ if _cluster != null:
+ _cluster.position = _resting_cluster_position
+
+
+func lock_interaction() -> void:
+ _set_interactive(false)
+
+
+func is_transitioning() -> bool:
+ return _is_transitioning
+
+
+func focus_confirm() -> void:
+ if (
+ _confirm_button.visible
+ and _confirm_button.focus_mode != Control.FOCUS_NONE
+ ):
+ _confirm_button.grab_focus()
+
+
+func _process(delta: float) -> void:
+ if visible:
+ _cluster.advance_motion(delta)
+
+
+func _configure_focus() -> void:
+ _confirm_button.focus_neighbor_left = _confirm_button.get_path_to(
+ _cancel_button
+ )
+ _confirm_button.focus_neighbor_right = _confirm_button.focus_neighbor_left
+ _confirm_button.focus_neighbor_top = _confirm_button.focus_neighbor_left
+ _confirm_button.focus_neighbor_bottom = _confirm_button.focus_neighbor_left
+ _cancel_button.focus_neighbor_left = _cancel_button.get_path_to(
+ _confirm_button
+ )
+ _cancel_button.focus_neighbor_right = _cancel_button.focus_neighbor_left
+ _cancel_button.focus_neighbor_top = _cancel_button.focus_neighbor_left
+ _cancel_button.focus_neighbor_bottom = _cancel_button.focus_neighbor_left
+
+
+func _set_interactive(interactive: bool) -> void:
+ mouse_filter = (
+ Control.MOUSE_FILTER_PASS
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+ for bubble: BubbleButton in [_confirm_button, _cancel_button]:
+ if bubble == null:
+ continue
+ bubble.focus_mode = (
+ Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
+ )
+ bubble.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _finish_transition_in(
+ generation: int,
+ completed: Callable,
+) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _transition = null
+ _is_transitioning = false
+ _set_interactive(true)
+ focus_confirm()
+ completed.call()
+
+
+func _finish_transition_out(
+ generation: int,
+ completed: Callable,
+) -> void:
+ if generation != _transition_generation or not visible:
+ return
+ _transition = null
+ _is_transitioning = false
+ hide()
+ _cluster.position = _resting_cluster_position
+ set_process(false)
+ completed.call()
+
+
+func _cancel_transition() -> void:
+ if _transition != null:
+ _transition.kill()
+ _transition = null
diff --git a/ui/title_confirmation_bubble_page.gd.uid b/ui/title_confirmation_bubble_page.gd.uid
new file mode 100644
index 0000000..d8c6899
--- /dev/null
+++ b/ui/title_confirmation_bubble_page.gd.uid
@@ -0,0 +1 @@
+uid://cvxkxoqfvkp3h
diff --git a/ui/title_confirmation_bubble_page.tscn b/ui/title_confirmation_bubble_page.tscn
new file mode 100644
index 0000000..d6921f3
--- /dev/null
+++ b/ui/title_confirmation_bubble_page.tscn
@@ -0,0 +1,129 @@
+[gd_scene load_steps=6 format=3]
+
+[ext_resource type="Script" path="res://ui/title_confirmation_bubble_page.gd" id="1_page"]
+[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_button.tscn" id="2_bubble"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="3_cluster"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_menu_profile.gd" id="4_profile_script"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="5_profile"]
+
+[sub_resource type="Resource" id="BubbleMenuProfile_confirmation_action"]
+script = ExtResource("4_profile_script")
+normal_border_width = 3
+emphasized_border_width = 4
+
+[node name="TitleConfirmationBubblePage" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("1_page")
+
+[node name="BubbleCluster" type="Control" parent="."]
+layout_mode = 0
+offset_right = 396.0
+offset_bottom = 318.0
+script = ExtResource("3_cluster")
+profile = ExtResource("5_profile")
+desktop_reference_size = Vector2(396, 318)
+compact_reference_size = Vector2(294, 200)
+
+[node name="PromptBubble" parent="BubbleCluster" instance=ExtResource("2_bubble")]
+custom_minimum_size = Vector2(0, 0)
+offset_left = 70.0
+offset_top = -2.0
+offset_right = 326.0
+offset_bottom = 254.0
+neutral_size = Vector2(256, 256)
+desktop_anchor = Vector2(198, 126)
+compact_anchor = Vector2(198, 119)
+compact_minimum_size = Vector2(190, 190)
+label_control_path = NodePath("PromptLabel")
+minimum_font_size = 15
+maximum_font_size = 26
+horizontal_amplitude = 1.6
+vertical_amplitude = 4.2
+motion_period = 6.4
+motion_phase = 0.45
+deformation_amplitude = 0.012
+deformation_period = 7.1
+
+[node name="PromptLabel" type="Label" parent="BubbleCluster/PromptBubble"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 22.0
+offset_top = 22.0
+offset_right = -22.0
+offset_bottom = -22.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
+theme_override_constants/line_spacing = -2
+text = "confirm?"
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
+
+[node name="ConfirmButton" parent="BubbleCluster" instance=ExtResource("2_bubble")]
+custom_minimum_size = Vector2(0, 0)
+offset_left = 20.0
+offset_top = 222.0
+offset_right = 136.0
+offset_bottom = 334.0
+profile = SubResource("BubbleMenuProfile_confirmation_action")
+neutral_size = Vector2(116, 112)
+desktop_anchor = Vector2(78, 278)
+compact_anchor = Vector2(34, 280)
+compact_minimum_size = Vector2(88, 86)
+label_control_path = NodePath("ConfirmLabel")
+minimum_font_size = 14
+maximum_font_size = 21
+horizontal_amplitude = 2.0
+vertical_amplitude = 4.8
+motion_period = 5.6
+motion_phase = 2.15
+deformation_amplitude = 0.016
+deformation_period = 5.9
+
+[node name="ConfirmLabel" type="Label" parent="BubbleCluster/ConfirmButton"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 12.0
+offset_top = 12.0
+offset_right = -12.0
+offset_bottom = -12.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+text = "confirm"
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 2
+
+[node name="CancelButton" parent="BubbleCluster" instance=ExtResource("2_bubble")]
+custom_minimum_size = Vector2(0, 0)
+offset_left = 262.0
+offset_top = 216.0
+offset_right = 374.0
+offset_bottom = 324.0
+text = "cancel"
+neutral_size = Vector2(112, 108)
+desktop_anchor = Vector2(318, 270)
+compact_anchor = Vector2(362, 272)
+compact_minimum_size = Vector2(86, 84)
+minimum_font_size = 14
+maximum_font_size = 20
+horizontal_amplitude = 1.7
+vertical_amplitude = 4.1
+motion_period = 5.1
+motion_phase = 4.0
+deformation_amplitude = 0.017
+deformation_period = 5.4
diff --git a/ui/title_credits_page.gd b/ui/title_credits_page.gd
new file mode 100644
index 0000000..f8841ab
--- /dev/null
+++ b/ui/title_credits_page.gd
@@ -0,0 +1,87 @@
+class_name TitleCreditsPage
+extends Control
+
+signal back_requested(restore_navigation_focus: bool)
+
+@onready var _paper: PanelContainer = %Paper
+@onready var _back_button: Button = %CreditsBackButton
+
+var _navigation_focus_active: bool = false
+
+
+func _ready() -> void:
+ UtilityPageStyle.apply_page(self)
+ _paper.add_theme_stylebox_override(
+ "panel", UtilityPageStyle.panel_style()
+ )
+ UtilityPageStyle.apply_ocean_button(_back_button)
+ _back_button.pressed.connect(_request_back)
+ hide()
+
+
+func open_page(use_navigation_focus: bool) -> void:
+ _navigation_focus_active = use_navigation_focus
+ show()
+ UtilityPageStyle.animate_in(self)
+ if use_navigation_focus:
+ call_deferred("_focus_back")
+ else:
+ _release_page_focus()
+
+
+func close_page() -> void:
+ _navigation_focus_active = false
+ _release_page_focus()
+ hide()
+ scale = Vector2.ONE
+ modulate.a = 1.0
+
+
+func handle_input(event: InputEvent) -> bool:
+ if not visible:
+ return false
+ if event is InputEventMouseMotion:
+ _navigation_focus_active = false
+ _release_page_focus()
+ return false
+ if event is InputEventKey and (event as InputEventKey).echo:
+ return false
+ var moves_focus: bool = (
+ event.is_action_pressed("ui_up")
+ or event.is_action_pressed("ui_down")
+ or event.is_action_pressed("ui_left")
+ or event.is_action_pressed("ui_right")
+ )
+ if moves_focus:
+ _navigation_focus_active = true
+ if not _page_has_focus():
+ _back_button.grab_focus()
+ return true
+ if event.is_action_pressed("ui_cancel"):
+ _request_back()
+ return true
+ return false
+
+
+func get_back_button() -> Button:
+ return _back_button
+
+
+func _focus_back() -> void:
+ if visible and _navigation_focus_active:
+ _back_button.grab_focus()
+
+
+func _request_back() -> void:
+ back_requested.emit(_navigation_focus_active)
+
+
+func _page_has_focus() -> bool:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ return focus_owner != null and is_ancestor_of(focus_owner)
+
+
+func _release_page_focus() -> void:
+ var focus_owner: Control = get_viewport().gui_get_focus_owner()
+ if focus_owner != null and is_ancestor_of(focus_owner):
+ focus_owner.release_focus()
diff --git a/ui/title_credits_page.gd.uid b/ui/title_credits_page.gd.uid
new file mode 100644
index 0000000..1cbdf93
--- /dev/null
+++ b/ui/title_credits_page.gd.uid
@@ -0,0 +1 @@
+uid://c07nehcd5yw6m
diff --git a/ui/title_credits_page.tscn b/ui/title_credits_page.tscn
new file mode 100644
index 0000000..a667465
--- /dev/null
+++ b/ui/title_credits_page.tscn
@@ -0,0 +1,180 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://ui/title_credits_page.gd" id="1_script"]
+[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
+
+[node name="TitleCreditsPage" type="Control"]
+visible = false
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
+theme = ExtResource("2_theme")
+script = ExtResource("1_script")
+
+[node name="Paper" type="PanelContainer" parent="."]
+unique_name_in_owner = true
+layout_mode = 0
+offset_left = 160.0
+offset_top = 38.0
+offset_right = 1120.0
+offset_bottom = 682.0
+
+[node name="Margin" type="MarginContainer" parent="Paper"]
+layout_mode = 2
+theme_override_constants/margin_left = 52
+theme_override_constants/margin_top = 26
+theme_override_constants/margin_right = 52
+theme_override_constants/margin_bottom = 24
+
+[node name="Layout" type="VBoxContainer" parent="Paper/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 10
+
+[node name="Title" type="Label" parent="Paper/Margin/Layout"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 36
+text = "credits"
+horizontal_alignment = 1
+
+[node name="Subtitle" type="Label" parent="Paper/Margin/Layout"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 18
+text = "NETfishing is created by Woofmeow"
+horizontal_alignment = 1
+
+[node name="Columns" type="HBoxContainer" parent="Paper/Margin/Layout"]
+layout_mode = 2
+size_flags_vertical = 3
+theme_override_constants/separation = 52
+
+[node name="CreativeCredits" type="VBoxContainer" parent="Paper/Margin/Layout/Columns"]
+custom_minimum_size = Vector2(404, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/separation = 5
+
+[node name="CreativeHeading" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 23
+text = "creative team"
+
+[node name="VoyagerName" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 18
+text = "Voyager"
+
+[node name="VoyagerRoles" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "co-owner • development • project direction • 3d art • music"
+autowrap_mode = 2
+
+[node name="EndeavourName" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 18
+text = "Endeavour"
+
+[node name="EndeavourRoles" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "co-owner • development • 2d art"
+autowrap_mode = 2
+
+[node name="ContributorSpacer" type="Control" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+custom_minimum_size = Vector2(0, 9)
+layout_mode = 2
+
+[node name="ContributorHeading" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 23
+text = "additional contributors"
+
+[node name="ChillnfillName" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 18
+text = "chillnfill"
+
+[node name="ChillnfillCredit" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "original 3d models for character bodies and arms, ears and tails, and multiple world props and decorative assets including trees and bridges"
+autowrap_mode = 2
+
+[node name="TekgatorName" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 18
+text = "Tekgator"
+
+[node name="TekgatorCredit" type="Label" parent="Paper/Margin/Layout/Columns/CreativeCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "cooler-capacity, reel-speed, and barrier-power shop icon artwork"
+autowrap_mode = 2
+
+[node name="AdditionalCredits" type="VBoxContainer" parent="Paper/Margin/Layout/Columns"]
+custom_minimum_size = Vector2(404, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/separation = 5
+
+[node name="AudioHeading" type="Label" parent="Paper/Margin/Layout/Columns/AdditionalCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 23
+text = "additional audio"
+
+[node name="AudioCredits" type="Label" parent="Paper/Margin/Layout/Columns/AdditionalCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "tosha73 • fishing reel sounds\nkkenny101 • ocean ambience\nqubodup • water impact\nAyton • rain ambience\nivolipa • dog bark\nChristyboy100 • cat meow"
+
+[node name="TechnologySpacer" type="Control" parent="Paper/Margin/Layout/Columns/AdditionalCredits"]
+custom_minimum_size = Vector2(0, 9)
+layout_mode = 2
+
+[node name="TechnologyHeading" type="Label" parent="Paper/Margin/Layout/Columns/AdditionalCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
+theme_override_font_sizes/font_size = 23
+text = "fonts & technology"
+
+[node name="TechnologyCredits" type="Label" parent="Paper/Margin/Layout/Columns/AdditionalCredits"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 15
+text = "Tuffy • Thatcher Ulrich, Karoly Barta & Michael Evans\nSeattle Avenue • JLH Fonts\nbuilt with Godot Engine"
+autowrap_mode = 2
+
+[node name="Notice" type="Label" parent="Paper/Margin/Layout"]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
+theme_override_font_sizes/font_size = 13
+text = "complete attribution, licensing, and asset provenance are included with every release"
+horizontal_alignment = 1
+autowrap_mode = 2
+
+[node name="BackCenter" type="CenterContainer" parent="Paper/Margin/Layout"]
+layout_mode = 2
+
+[node name="CreditsBackButton" type="Button" parent="Paper/Margin/Layout/BackCenter"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(112, 50)
+layout_mode = 2
+text = "back"
diff --git a/ui/title_screen.gd b/ui/title_screen.gd
index 2c70655..9e17d3e 100644
--- a/ui/title_screen.gd
+++ b/ui/title_screen.gd
@@ -7,6 +7,22 @@ const SettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
const SettingsPanelType = preload("res://ui/settings_panel.gd")
+const BubbleButtonType = preload(
+ "res://ui/components/bubble_menu/bubble_button.gd"
+)
+const BubbleClusterType = preload(
+ "res://ui/components/bubble_menu/bubble_cluster.gd"
+)
+const TitleConfirmationBubblePageType = preload(
+ "res://ui/title_confirmation_bubble_page.gd"
+)
+const TitleCreditsPageType = preload("res://ui/title_credits_page.gd")
+const NetworkSessionType = preload("res://network/network_session.gd")
+const SavedServerStoreType = preload("res://network/saved_server_store.gd")
+const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
+const CurrencyPresentationType = preload(
+ "res://ui/currency_presentation.gd"
+)
const DECORATIVE_FISH_TEXTURES: Array[Texture2D] = [
preload("res://fish/species/bass/fish_bass_striped.png"),
preload("res://fish/species/bluegill/fish_bluegill.png"),
@@ -44,18 +60,39 @@ const BUBBLE_WOBBLE_MIN: float = 3.0
const BUBBLE_WOBBLE_MAX: float = 9.0
const BUBBLE_EDGE_MARGIN: float = 16.0
const MAX_DECORATIVE_BUBBLES: int = 6
+const TRANSITION_BUBBLE_COUNT_MIN: int = 4
+const TRANSITION_BUBBLE_COUNT_MAX: int = 5
+const MAX_TRANSITION_BUBBLES: int = 30
+const TRANSITION_BUBBLE_X_MIN: float = 0.36
+const TRANSITION_BUBBLE_X_MAX: float = 0.64
+const TRANSITION_BUBBLE_Y_MIN: float = 0.60
+const TRANSITION_BUBBLE_Y_MAX: float = 0.78
const LOGO_ASPECT_RATIO: float = 2560.0 / 760.0
-const LOGO_WIDTH_FACTOR: float = 0.52
-const LOGO_MIN_WIDTH: float = 320.0
-const LOGO_MAX_WIDTH: float = 720.0
-const BUTTON_COLUMN_WIDTH: float = 360.0
+const LOGO_WIDTH_FACTOR: float = 0.4784
+const LOGO_MIN_WIDTH: float = 294.0
+const LOGO_MAX_WIDTH: float = 662.0
const TITLE_HORIZONTAL_MARGIN: float = 48.0
+const TITLE_DESKTOP_REFERENCE_SIZE: Vector2 = Vector2(1280.0, 720.0)
+const TITLE_COMPACT_REFERENCE_SIZE: Vector2 = Vector2(640.0, 480.0)
+const BUBBLE_FIELD_MAX_WIDTH: float = 440.0
+const BUBBLE_FIELD_DESKTOP_HEIGHT: float = 318.0
+const BUBBLE_FIELD_COMPACT_WIDTH: float = 294.0
+const BUBBLE_FIELD_COMPACT_HEIGHT: float = 200.0
+const BUBBLE_COMPACT_HEIGHT_THRESHOLD: float = 560.0
const START_PROMPT_MIN_SCALE: float = 0.985
const START_PROMPT_MAX_SCALE: float = 1.015
const START_PROMPT_CYCLE_SECONDS: float = 3.0
+const DISABLED_BUBBLE_LABEL_ALPHA: float = 0.55
+const INTRO_PROMPT_FADE_DURATION: float = 0.55
+const INTRO_BUBBLE_TRAVEL_DURATION: float = 2.40
+const INTRO_BRANDING_TRAVEL_DURATION: float = 2.0
+const INTRO_BRANDING_START_DELAY: float = 0.35
+const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
-signal gameplay_requested
+signal new_game_requested
+signal continue_game_requested
signal quit_requested
+signal join_game_requested(endpoint: String)
enum ConfirmationAction {
NONE,
@@ -63,26 +100,46 @@ enum ConfirmationAction {
DELETE_SAVE,
}
-@onready var _continue_button: Button = %ContinueButton
-@onready var _new_game_button: Button = %NewGameButton
-@onready var _settings_button: Button = %SettingsButton
-@onready var _delete_button: Button = %DeleteSaveButton
-@onready var _quit_button: Button = %QuitButton
-@onready var _feedback_label: Label = %FeedbackLabel
-@onready var _confirmation_panel: PanelContainer = %ConfirmationPanel
-@onready var _confirmation_text: Label = %ConfirmationText
-@onready var _confirm_button: Button = %ConfirmButton
+@onready var _continue_button: BubbleButtonType = %ContinueButton
+@onready var _new_game_button: BubbleButtonType = %NewGameButton
+@onready var _settings_button: BubbleButtonType = %SettingsButton
+@onready var _credits_button: BubbleButtonType = %CreditsButton
+@onready var _delete_button: BubbleButtonType = %DeleteSaveButton
+@onready var _quit_button: BubbleButtonType = %QuitButton
+@onready var _join_game_button: BubbleButtonType = %JoinGameButton
+@onready var _new_game_label: Label = %NewGameLabel
+@onready var _delete_save_label: Label = %DeleteSaveLabel
+@onready var _feedback_label: RichTextLabel = %FeedbackLabel
+@onready var _confirmation_page: TitleConfirmationBubblePageType = (
+ %ConfirmationPage
+)
@onready var _settings_panel: SettingsPanelType = %SettingsPanel
+@onready var _title_presentation_scale_root: Control = (
+ %TitlePresentationScaleRoot
+)
+@onready var _background: ColorRect = %Background
@onready var _decorative_fish_layer: Control = %DecorativeFishLayer
@onready var _decorative_fish_timer: Timer = %DecorativeFishTimer
@onready var _decorative_bubble_layer: Control = %DecorativeBubbleLayer
@onready var _decorative_bubble_event_timer: Timer = %DecorativeBubbleEventTimer
@onready var _decorative_bubble_cluster_timer: Timer = %DecorativeBubbleClusterTimer
@onready var _title_logo: TextureRect = %TitleLogo
+@onready var _playtest_label: Label = %PlaytestLabel
+@onready var _intro_branding_anchor: Control = %IntroBrandingAnchor
+@onready var _presentation_center: CenterContainer = %Center
+@onready var _main_content: VBoxContainer = %MainContent
+@onready var _branding_slot: Control = %BrandingSlot
+@onready var _branding_motion_root: Control = %BrandingMotionRoot
+@onready var _version_row: Control = %VersionRow
+@onready var _title_spacer: Control = %Spacer
@onready var _button_center: CenterContainer = %ButtonCenter
-@onready var _button_stack: VBoxContainer = %ButtonStack
+@onready var _bubble_layout_slot: Control = %BubbleLayoutSlot
+@onready var _bubble_motion_root: Control = %BubbleMotionRoot
+@onready var _bubble_field: BubbleClusterType = %BubbleField
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter
@onready var _start_prompt_label: Label = %StartPromptLabel
+@onready var _join_game_page: JoinGamePageType = %JoinGamePage
+@onready var _credits_page: TitleCreditsPageType = %CreditsPage
var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType
@@ -97,23 +154,72 @@ var _decorative_presentation_active: bool = false
var _decorative_generation: int = 0
var _decorative_bubbles: Array[TextureRect] = []
var _decorative_bubble_tweens: Dictionary[int, Tween] = {}
+var _transition_bubble_ids: Dictionary[int, bool] = {}
var _pending_cluster_bubbles: int = 0
var _awaiting_start_input: bool = false
var _start_prompt_elapsed: float = 0.0
var _navigation_focus_active: bool = false
var _modal_restore_navigation_focus: bool = false
+var _world_pixel_size: int = PlayerSettings.DEFAULT_WORLD_PIXEL_SIZE
+var _title_settings_transition: Tween
+var _title_settings_transition_generation: int = 0
+var _title_settings_transition_active: bool = false
+var _title_bubble_rest_position: Vector2 = Vector2.ZERO
+var _title_content_rest_position: Vector2 = Vector2.ZERO
+var _title_entry_transition: Tween
+var _title_entry_generation: int = 0
+var _title_entry_transition_active: bool = false
+var _intro_geometry_ready: bool = false
+var _intro_layout_generation: int = 0
+var _branding_intro_position: Vector2 = Vector2.ZERO
+var _bubble_intro_position: Vector2 = Vector2.ZERO
+var _feedback_visible_before_settings: bool = false
+var _feedback_text_before_settings: String = ""
+var _continue_stats_hovered: bool = false
+var _continue_stats_focused: bool = false
+var _continue_stats_fade: Tween
+var _confirmation_transition: Tween
+var _confirmation_transition_generation: int = 0
+var _confirmation_transition_active: bool = false
+var _confirmation_title_content_rest_position: Vector2 = Vector2.ZERO
func _ready() -> void:
+ var release_version: String = str(
+ ProjectSettings.get_setting("application/config/version", "")
+ ).strip_edges()
+ if not release_version.is_empty():
+ _playtest_label.text = "v%s" % release_version
_continue_button.pressed.connect(_on_continue_pressed)
+ _continue_button.mouse_entered.connect(
+ _on_continue_stats_hover_changed.bind(true)
+ )
+ _continue_button.mouse_exited.connect(
+ _on_continue_stats_hover_changed.bind(false)
+ )
+ _continue_button.focus_entered.connect(
+ _on_continue_stats_focus_changed.bind(true)
+ )
+ _continue_button.focus_exited.connect(
+ _on_continue_stats_focus_changed.bind(false)
+ )
_new_game_button.pressed.connect(_on_new_game_pressed)
+ _join_game_button.pressed.connect(_open_join_game)
_settings_button.pressed.connect(_open_settings)
+ _credits_button.pressed.connect(_open_credits)
_delete_button.pressed.connect(_on_delete_pressed)
%QuitButton.pressed.connect(_on_quit_pressed)
- _confirm_button.pressed.connect(_on_confirmation_accepted)
- %CancelConfirmButton.pressed.connect(_close_confirmation)
+ _confirmation_page.confirmed.connect(_on_confirmation_accepted)
+ _confirmation_page.cancelled.connect(_request_close_confirmation)
_settings_panel.applied.connect(_on_settings_applied)
_settings_panel.closed.connect(_on_settings_closed)
+ _settings_panel.closing.connect(_on_settings_closing)
+ _settings_panel.opened.connect(_on_settings_opened)
+ _settings_panel.navigation_transition_started.connect(
+ _emit_navigation_bubble_flurry
+ )
+ _credits_page.back_requested.connect(_close_credits)
+ _bubble_field.configure(_get_title_buttons())
_decorative_fish_timer.timeout.connect(_on_decorative_fish_timer_timeout)
_decorative_bubble_event_timer.timeout.connect(
_on_decorative_bubble_event_timer_timeout
@@ -122,20 +228,31 @@ func _ready() -> void:
_on_decorative_bubble_cluster_timer_timeout
)
visibility_changed.connect(_on_title_visibility_changed)
- resized.connect(_update_title_layout)
+ resized.connect(_update_responsive_title_stage)
_start_prompt_label.resized.connect(_update_start_prompt_pivot)
_decorative_rng.randomize()
set_process(false)
- call_deferred("_update_title_layout")
+ call_deferred("_update_responsive_title_stage")
call_deferred("_update_start_prompt_pivot")
+ call_deferred("_capture_title_bubble_rest_position")
+ call_deferred("_capture_title_content_rest_position")
func setup(
save_manager: SaveManagerType,
settings_manager: SettingsManagerType,
+ network_session: NetworkSessionType,
+ saved_servers: SavedServerStoreType,
+ server_trust: ServerTrustStore,
+ discovery: DiscoveryClient,
) -> void:
_save_manager = save_manager
_settings_manager = settings_manager
+ _join_game_page.setup(
+ network_session, saved_servers, false, server_trust, discovery
+ )
+ _join_game_page.join_requested.connect(join_game_requested.emit)
+ _join_game_page.back_requested.connect(_close_join_game)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_refresh_save_inspection()
show()
@@ -145,16 +262,103 @@ func setup(
func reopen() -> void:
+ _cancel_title_entry_transition()
+ _cancel_title_settings_transition()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_prepare_awaiting_start_input()
- _close_confirmation()
+ _reset_confirmation()
_settings_panel.hide()
+ _join_game_page.close_page()
+ _credits_page.close_page()
_refresh_save_inspection()
show()
_start_decorative_presentation()
_start_entry_prompt_animation()
+func reopen_to_menu() -> void:
+ reopen()
+ _awaiting_start_input = false
+ _stop_entry_prompt_animation()
+ _start_prompt_center.hide()
+ _presentation_center.show()
+ _button_center.show()
+ var bubble_field := get_node_or_null("%BubbleField") as Control
+ if bubble_field != null:
+ bubble_field.show()
+ _set_title_bubbles_interactive(true)
+
+
+func open_join_game_page(endpoint: String = "") -> void:
+ _cancel_title_entry_transition()
+ _awaiting_start_input = false
+ _start_prompt_center.hide()
+ _presentation_center.hide()
+ _settings_panel.hide()
+ _confirmation_page.hide_page()
+ _credits_page.close_page()
+ _join_game_page.open_page(endpoint)
+
+
+func report_network_error(message: String) -> void:
+ _join_game_page.set_status(message)
+ if not _join_game_page.visible:
+ _feedback_label.text = _center_feedback_text(message)
+ _feedback_label.show()
+ _feedback_label.modulate.a = 1.0
+
+
+func _open_join_game() -> void:
+ if (
+ _action_in_progress
+ or _is_confirmation_active()
+ or _credits_page.visible
+ ):
+ return
+ open_join_game_page()
+
+
+func _close_join_game() -> void:
+ _join_game_page.close_page()
+ _presentation_center.show()
+ _button_center.show()
+ _start_prompt_center.hide()
+ _focus_initial_button()
+
+
+func _open_credits() -> void:
+ if (
+ _action_in_progress
+ or _is_confirmation_active()
+ or _settings_panel.visible
+ or _join_game_page.visible
+ or _credits_page.visible
+ ):
+ return
+ _hide_continue_stats_context()
+ var restore_navigation_focus: bool = _navigation_focus_active
+ _set_title_bubbles_interactive(false)
+ _release_primary_menu_focus()
+ _presentation_center.hide()
+ _start_prompt_center.hide()
+ _credits_page.open_page(restore_navigation_focus)
+
+
+func _close_credits(restore_navigation_focus: bool) -> void:
+ _credits_page.close_page()
+ _presentation_center.show()
+ _button_center.show()
+ _start_prompt_center.hide()
+ _set_title_bubbles_interactive(true)
+ if restore_navigation_focus:
+ _navigation_focus_active = true
+ _credits_button.grab_focus()
+ else:
+ _navigation_focus_active = false
+ _release_title_focus()
+ _update_continue_stats_visibility()
+
+
func is_awaiting_start_input() -> bool:
return _awaiting_start_input
@@ -184,10 +388,14 @@ func is_decorative_bubble_scheduler_active() -> bool:
func _update_title_layout() -> void:
if not is_node_ready():
return
- var available_width: float = maxf(1.0, size.x - TITLE_HORIZONTAL_MARGIN)
+ var layout_size: Vector2 = _title_presentation_scale_root.size
+ var available_width: float = maxf(
+ 1.0,
+ layout_size.x - TITLE_HORIZONTAL_MARGIN
+ )
var logo_width: float = minf(
clampf(
- size.x * LOGO_WIDTH_FACTOR,
+ layout_size.x * LOGO_WIDTH_FACTOR,
LOGO_MIN_WIDTH,
LOGO_MAX_WIDTH
),
@@ -197,39 +405,182 @@ func _update_title_layout() -> void:
logo_width,
logo_width / LOGO_ASPECT_RATIO
)
- _button_stack.custom_minimum_size = Vector2(
- minf(BUTTON_COLUMN_WIDTH, available_width),
- 0.0
+ var branding_separation: float = float(
+ _main_content.get_theme_constant("separation")
+ )
+ var branding_size := Vector2(
+ logo_width,
+ logo_width / LOGO_ASPECT_RATIO
+ + branding_separation
+ + _version_row.get_combined_minimum_size().y
+ )
+ _branding_slot.custom_minimum_size = branding_size
+ _branding_motion_root.size = branding_size
+ var field_width: float = minf(BUBBLE_FIELD_MAX_WIDTH, available_width)
+ var field_height: float = (
+ BUBBLE_FIELD_COMPACT_HEIGHT
+ if layout_size.y < BUBBLE_COMPACT_HEIGHT_THRESHOLD
+ else BUBBLE_FIELD_DESKTOP_HEIGHT
+ )
+ if layout_size.y < BUBBLE_COMPACT_HEIGHT_THRESHOLD:
+ field_width = minf(BUBBLE_FIELD_COMPACT_WIDTH, available_width)
+ _bubble_layout_slot.custom_minimum_size = Vector2(field_width, field_height)
+ _bubble_motion_root.size = Vector2(field_width, field_height)
+ _bubble_field.custom_minimum_size = Vector2(field_width, field_height)
+ var compact_layout: bool = field_height == BUBBLE_FIELD_COMPACT_HEIGHT
+ _bubble_field.apply_layout(
+ Vector2(field_width, field_height),
+ compact_layout
+ )
+ if not _title_settings_transition_active:
+ call_deferred("_capture_title_bubble_rest_position")
+ _new_game_label.text = "new\ngame" if compact_layout else "new game"
+ _delete_save_label.text = (
+ "delete\nsave" if compact_layout else "delete save"
+ )
+ if _awaiting_start_input and not _title_entry_transition_active:
+ _schedule_intro_presentation()
+ _update_world_preview_resolution()
+
+
+func _update_responsive_title_stage() -> void:
+ if not is_node_ready():
+ return
+ _title_presentation_scale_root.size = TITLE_DESKTOP_REFERENCE_SIZE
+ _title_presentation_scale_root.scale = Vector2.ONE
+ _title_presentation_scale_root.position = Vector2.ZERO
+ _update_title_layout()
+ if (
+ _is_confirmation_active()
+ and not _confirmation_page.is_transitioning()
+ ):
+ _set_confirmation_stage_rect()
+
+
+func _get_title_buttons() -> Array[BubbleButton]:
+ return [
+ _continue_button,
+ _new_game_button,
+ _join_game_button,
+ _settings_button,
+ _credits_button,
+ _delete_button,
+ _quit_button,
+ ]
+
+
+func set_world_pixelation(pixel_size: int) -> void:
+ _world_pixel_size = clampi(
+ pixel_size,
+ PlayerSettings.MIN_WORLD_PIXEL_SIZE,
+ PlayerSettings.MAX_WORLD_PIXEL_SIZE
+ )
+ if is_node_ready():
+ _update_world_preview_resolution()
+
+
+func _update_world_preview_resolution() -> void:
+ var displayed_size := Vector2i(
+ maxi(1, roundi(size.x)),
+ maxi(1, roundi(size.y))
+ )
+ var grid_size: Vector2i = PlayerSettings.get_world_grid_size(
+ _world_pixel_size,
+ displayed_size
+ )
+ var shader_material := _background.material as ShaderMaterial
+ if shader_material != null:
+ shader_material.set_shader_parameter(
+ "virtual_pixel_density",
+ Vector2(grid_size)
+ )
+ var logo_material := _title_logo.material as ShaderMaterial
+ if logo_material != null:
+ var render_scale: float = (
+ float(grid_size.y) / float(displayed_size.y)
+ )
+ var effect_scale: float = 1.0 / render_scale
+ logo_material.set_shader_parameter(
+ "horizontal_displacement_pixels",
+ minf(2.0, 1.5 * effect_scale)
+ )
+ logo_material.set_shader_parameter(
+ "bob_amount_pixels",
+ minf(4.0, 3.0 * effect_scale)
+ )
+
+
+func _snap_world_preview(value: Vector2) -> Vector2:
+ var displayed_size := Vector2i(
+ maxi(1, roundi(size.x)),
+ maxi(1, roundi(size.y))
+ )
+ var grid_size: Vector2i = PlayerSettings.get_world_grid_size(
+ _world_pixel_size,
+ displayed_size
+ )
+ var render_scale: float = float(grid_size.y) / float(displayed_size.y)
+ var step: float = 1.0 / render_scale
+ return Vector2(
+ roundf(value.x / step) * step,
+ roundf(value.y / step) * step
)
func _process(delta: float) -> void:
- if (
- not _awaiting_start_input
- or not visible
- or not _start_prompt_center.visible
- ):
+ if not visible:
return
- _start_prompt_elapsed = fmod(
- _start_prompt_elapsed + delta,
- START_PROMPT_CYCLE_SECONDS
- )
- var phase: float = _start_prompt_elapsed / START_PROMPT_CYCLE_SECONDS
- var pulse_weight: float = (sin(phase * TAU - PI * 0.5) + 1.0) * 0.5
- var prompt_scale: float = lerpf(
- START_PROMPT_MIN_SCALE,
- START_PROMPT_MAX_SCALE,
- pulse_weight
- )
- _start_prompt_label.scale = Vector2.ONE * prompt_scale
+ if _awaiting_start_input and _start_prompt_center.visible:
+ _start_prompt_elapsed = fmod(
+ _start_prompt_elapsed + delta,
+ START_PROMPT_CYCLE_SECONDS
+ )
+ var phase: float = (
+ _start_prompt_elapsed / START_PROMPT_CYCLE_SECONDS
+ )
+ var pulse_weight: float = (
+ sin(phase * TAU - PI * 0.5) + 1.0
+ ) * 0.5
+ var prompt_scale: float = lerpf(
+ START_PROMPT_MIN_SCALE,
+ START_PROMPT_MAX_SCALE,
+ pulse_weight
+ )
+ _start_prompt_label.scale = Vector2.ONE * prompt_scale
+ if _main_content.visible and _button_center.visible:
+ _bubble_field.advance_motion(delta)
func _input(event: InputEvent) -> void:
if not visible:
return
+ if (
+ _settings_panel.visible
+ and _settings_panel.is_controller_mapping_capturing()
+ ):
+ return
+ if _credits_page.visible:
+ if _credits_page.handle_input(event):
+ get_viewport().set_input_as_handled()
+ return
+ if _join_game_page.visible:
+ if event.is_action_pressed("ui_cancel"):
+ _close_join_game()
+ get_viewport().set_input_as_handled()
+ return
+ if (
+ _title_settings_transition_active
+ or _title_entry_transition_active
+ or _confirmation_transition_active
+ ):
+ get_viewport().set_input_as_handled()
+ return
if _awaiting_start_input:
if not _is_start_prompt_reveal_event(event):
return
+ if not _intro_geometry_ready:
+ get_viewport().set_input_as_handled()
+ return
_reveal_primary_menu()
get_viewport().set_input_as_handled()
return
@@ -238,10 +589,10 @@ func _input(event: InputEvent) -> void:
return
if not event.is_action_pressed("ui_cancel"):
return
- if _confirmation_panel.visible:
- _close_confirmation()
+ if _is_confirmation_active():
+ _request_close_confirmation()
elif _settings_panel.visible:
- _close_settings()
+ _settings_panel.handle_back()
else:
return
get_viewport().set_input_as_handled()
@@ -261,13 +612,15 @@ func _is_start_prompt_reveal_event(event: InputEvent) -> bool:
func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
if (
not _button_center.visible
- or _confirmation_panel.visible
+ or _is_confirmation_active()
or _settings_panel.visible
+ or _credits_page.visible
):
return false
if event is InputEventMouseMotion:
_navigation_focus_active = false
_release_primary_menu_focus()
+ _update_continue_stats_visibility()
return false
if event is InputEventKey and (event as InputEventKey).echo:
return false
@@ -318,15 +671,29 @@ func _begin_title_entry() -> void:
func _prepare_awaiting_start_input() -> void:
+ _cancel_title_entry_transition()
_awaiting_start_input = true
+ _intro_geometry_ready = false
_navigation_focus_active = false
_modal_restore_navigation_focus = false
- _button_center.hide()
- _feedback_label.hide()
+ _button_center.show()
+ var bubble_field := get_node_or_null("%BubbleField") as Control
+ if bubble_field != null:
+ bubble_field.hide()
+ call_deferred("set_process", visible)
+ _feedback_label.show()
+ _feedback_label.modulate.a = 0.0
+ _continue_stats_hovered = false
+ _continue_stats_focused = false
+ _cancel_continue_stats_fade()
+ _main_content.show()
_start_prompt_center.show()
+ _start_prompt_center.modulate.a = 1.0
+ _set_title_bubbles_interactive(false)
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and is_ancestor_of(focus_owner):
focus_owner.release_focus()
+ _schedule_intro_presentation()
func _start_entry_prompt_animation() -> void:
@@ -337,7 +704,7 @@ func _start_entry_prompt_animation() -> void:
func _stop_entry_prompt_animation() -> void:
- set_process(false)
+ set_process(visible and _button_center.visible)
_start_prompt_elapsed = 0.0
_start_prompt_label.scale = Vector2.ONE
@@ -349,73 +716,332 @@ func _update_start_prompt_pivot() -> void:
func _reveal_primary_menu() -> void:
- if not _awaiting_start_input:
+ if (
+ not _awaiting_start_input
+ or not _intro_geometry_ready
+ or _title_entry_transition_active
+ ):
return
_awaiting_start_input = false
- _stop_entry_prompt_animation()
- _start_prompt_center.hide()
_button_center.show()
- _feedback_label.show()
+ var bubble_field := get_node_or_null("%BubbleField") as Control
+ if bubble_field != null:
+ bubble_field.show()
+ _stop_entry_prompt_animation()
+ _title_entry_generation += 1
+ _title_entry_transition_active = true
+ _set_title_bubbles_interactive(false)
+ var generation: int = _title_entry_generation
+ set_process(true)
_navigation_focus_active = false
_release_title_focus()
+ _title_entry_transition = create_tween()
+ _title_entry_transition.set_parallel(true)
+ _title_entry_transition.tween_property(
+ _start_prompt_center,
+ "modulate:a",
+ 0.0,
+ INTRO_PROMPT_FADE_DURATION
+ ).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
+ _title_entry_transition.tween_property(
+ _bubble_motion_root,
+ "position",
+ Vector2.ZERO,
+ INTRO_BUBBLE_TRAVEL_DURATION
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _title_entry_transition.tween_property(
+ _branding_motion_root,
+ "position",
+ Vector2.ZERO,
+ INTRO_BRANDING_TRAVEL_DURATION
+ ).set_delay(INTRO_BRANDING_START_DELAY).set_trans(
+ Tween.TRANS_QUAD
+ ).set_ease(Tween.EASE_IN_OUT)
+ _title_entry_transition.finished.connect(
+ _finish_primary_menu_reveal.bind(generation),
+ CONNECT_ONE_SHOT
+ )
+
+
+func _finish_primary_menu_reveal(generation: int) -> void:
+ if (
+ generation != _title_entry_generation
+ or not _title_entry_transition_active
+ ):
+ return
+ _title_entry_transition = null
+ _title_entry_transition_active = false
+ _start_prompt_center.hide()
+ _start_prompt_center.modulate.a = 1.0
+ _feedback_label.modulate.a = 0.0
+ _set_title_bubbles_interactive(true)
+
+
+func _cancel_title_entry_transition() -> void:
+ _title_entry_generation += 1
+ if _title_entry_transition != null:
+ _title_entry_transition.kill()
+ _title_entry_transition = null
+ _title_entry_transition_active = false
+ if is_node_ready():
+ _start_prompt_center.modulate.a = 1.0
+
+
+func _schedule_intro_presentation() -> void:
+ _intro_layout_generation += 1
+ _intro_geometry_ready = false
+ call_deferred(
+ "_prepare_intro_presentation",
+ _intro_layout_generation
+ )
+
+
+func _prepare_intro_presentation(generation: int) -> void:
+ await get_tree().process_frame
+ await get_tree().process_frame
+ if (
+ generation != _intro_layout_generation
+ or not is_node_ready()
+ or not _awaiting_start_input
+ or _title_entry_transition_active
+ ):
+ return
+ _branding_motion_root.size = _branding_slot.size
+ _bubble_motion_root.size = _bubble_layout_slot.size
+ var intro_tail_height: float = (
+ _title_spacer.get_combined_minimum_size().y
+ + float(_main_content.get_theme_constant("separation"))
+ )
+ var inverse_stage_transform: Transform2D = (
+ _title_presentation_scale_root.get_global_transform().affine_inverse()
+ )
+ var branding_anchor_position: Vector2 = (
+ inverse_stage_transform
+ * _intro_branding_anchor.get_global_transform().origin
+ )
+ var branding_slot_position: Vector2 = (
+ inverse_stage_transform
+ * _branding_slot.get_global_transform().origin
+ )
+ var branding_target_position := Vector2(
+ floorf(
+ branding_anchor_position.x
+ - _branding_motion_root.size.x * 0.5
+ ),
+ floorf(
+ branding_anchor_position.y
+ - (_branding_motion_root.size.y + intro_tail_height) * 0.5
+ )
+ )
+ _branding_intro_position = (
+ branding_target_position
+ - branding_slot_position
+ )
+ var bubble_bounds: Rect2 = _get_title_bubble_stage_bounds()
+ bubble_bounds.position -= _bubble_motion_root.position
+ var presentation_allowance: float = _get_bubble_offscreen_allowance()
+ _bubble_intro_position = Vector2(
+ 0.0,
+ _title_presentation_scale_root.size.y
+ + presentation_allowance
+ - bubble_bounds.position.y
+ )
+ _branding_motion_root.position = _branding_intro_position
+ _bubble_motion_root.position = _bubble_intro_position
+ _title_content_rest_position = _presentation_center.position
+ _title_bubble_rest_position = _bubble_field.position
+ _intro_geometry_ready = true
+
+
+func _get_title_bubble_stage_bounds() -> Rect2:
+ var bubbles: Array[BubbleButton] = _get_title_buttons()
+ var bounds: Rect2 = _get_title_stage_rect(bubbles[0])
+ for index: int in range(1, bubbles.size()):
+ bounds = bounds.merge(_get_title_stage_rect(bubbles[index]))
+ return bounds
+
+
+func _get_title_stage_rect(control: Control) -> Rect2:
+ var inverse_stage_transform: Transform2D = (
+ _title_presentation_scale_root.get_global_transform().affine_inverse()
+ )
+ var global_rect: Rect2 = control.get_global_rect()
+ var local_position: Vector2 = (
+ inverse_stage_transform * global_rect.position
+ )
+ var local_end: Vector2 = inverse_stage_transform * global_rect.end
+ return Rect2(local_position, local_end - local_position)
+
+
+func _get_bubble_offscreen_allowance() -> float:
+ var maximum_vertical_amplitude: float = 0.0
+ var maximum_deformation_growth: float = 0.0
+ for bubble: BubbleButton in _get_title_buttons():
+ maximum_vertical_amplitude = maxf(
+ maximum_vertical_amplitude,
+ bubble.vertical_amplitude
+ )
+ var maximum_scale: float = (
+ 1.0 + bubble.deformation_amplitude
+ ) * _bubble_field.profile.hover_focus_scale
+ maximum_deformation_growth = maxf(
+ maximum_deformation_growth,
+ bubble.presented_size.y * (maximum_scale - 1.0) * 0.5
+ )
+ return (
+ HOST_CLUSTER_SAFE_MARGIN
+ + maximum_vertical_amplitude
+ + maximum_deformation_growth
+ + _bubble_field.profile.hover_focus_lift
+ + _bubble_field.profile.maximum_separation
+ )
+
+
+func _on_continue_stats_hover_changed(hovered: bool) -> void:
+ _continue_stats_hovered = hovered
+ _update_continue_stats_visibility()
+
+
+func _on_continue_stats_focus_changed(focused: bool) -> void:
+ _continue_stats_focused = focused
+ _update_continue_stats_visibility()
+
+
+func _update_continue_stats_visibility() -> void:
+ if not is_node_ready():
+ return
+ var requested_visible: bool = (
+ _continue_stats_hovered
+ or (
+ _continue_stats_focused
+ and _navigation_focus_active
+ )
+ )
+ requested_visible = (
+ requested_visible
+ and not _continue_button.disabled
+ and _inspection != null
+ and _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED
+ and not _awaiting_start_input
+ and not _title_entry_transition_active
+ and not _title_settings_transition_active
+ and not _settings_panel.visible
+ and not _is_confirmation_active()
+ and not _action_in_progress
+ and _presentation_center.visible
+ )
+ if requested_visible:
+ _feedback_label.text = _get_continue_stats_text()
+ _fade_continue_stats_to(1.0 if requested_visible else 0.0)
+
+
+func _hide_continue_stats_context() -> void:
+ _continue_stats_hovered = false
+ _continue_stats_focused = false
+ _fade_continue_stats_to(0.0)
+
+
+func _fade_continue_stats_to(target_opacity: float) -> void:
+ if not is_node_ready():
+ return
+ _cancel_continue_stats_fade()
+ if is_equal_approx(_feedback_label.modulate.a, target_opacity):
+ _feedback_label.modulate.a = target_opacity
+ return
+ _continue_stats_fade = create_tween()
+ _continue_stats_fade.tween_property(
+ _feedback_label,
+ "modulate:a",
+ target_opacity,
+ INTRO_PROMPT_FADE_DURATION
+ ).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
+
+
+func _cancel_continue_stats_fade() -> void:
+ if _continue_stats_fade != null:
+ _continue_stats_fade.kill()
+ _continue_stats_fade = null
+
+
+func _get_continue_stats_text() -> String:
+ if (
+ _inspection == null
+ or _inspection.status != SaveInspectionType.Status.VALID_SUPPORTED
+ ):
+ return ""
+ return _center_feedback_text(
+ "%d fish • %s • %d discovered" % [
+ _inspection.catch_count,
+ CurrencyPresentationType.bbcode_amount(
+ _inspection.wallet_balance, 18
+ ),
+ _inspection.discovered_species_count,
+ ]
+ )
+
+
+func _center_feedback_text(message: String) -> String:
+ if message.begins_with("[center]"):
+ return message
+ return "[center]%s[/center]" % message
func _on_continue_pressed() -> void:
if (
_action_in_progress
- or _confirmation_panel.visible
+ or _is_confirmation_active()
or _settings_panel.visible
or _inspection == null
or not _inspection.can_continue()
):
return
+ _hide_continue_stats_context()
_action_in_progress = true
- if _save_manager.load_player_data():
- _feedback_label.text = "save loaded."
- gameplay_requested.emit()
- else:
- _refresh_save_inspection()
- _feedback_label.text = "failed to load save. the original was preserved."
+ continue_game_requested.emit()
_action_in_progress = false
func _on_new_game_pressed() -> void:
if (
_action_in_progress
- or _confirmation_panel.visible
+ or _is_confirmation_active()
or _settings_panel.visible
or _inspection == null
):
return
+ _hide_continue_stats_context()
if _inspection.status == SaveInspectionType.Status.MISSING:
- if _save_manager.initialize_new_game():
- gameplay_requested.emit()
+ new_game_requested.emit()
return
if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION:
- _feedback_label.text = (
+ _feedback_label.text = _center_feedback_text(
"this save is from a newer game version. "
+ "use delete save before starting over."
)
return
if _inspection.status == SaveInspectionType.Status.IO_ERROR:
- _feedback_label.text = "the existing save cannot be accessed safely."
+ _feedback_label.text = _center_feedback_text(
+ "the existing save cannot be accessed safely."
+ )
return
_open_confirmation(
ConfirmationAction.NEW_GAME,
"start a new game? existing progression will be deleted.",
- "start new game"
+ "start\nnew game",
+ true
)
func _on_delete_pressed() -> void:
if (
_action_in_progress
- or _confirmation_panel.visible
+ or _is_confirmation_active()
or _settings_panel.visible
or _inspection == null
or not _inspection.can_delete()
):
return
+ _hide_continue_stats_context()
_open_confirmation(
ConfirmationAction.DELETE_SAVE,
"delete your saved progression? this cannot be undone.",
@@ -424,22 +1050,35 @@ func _on_delete_pressed() -> void:
func _on_confirmation_accepted() -> void:
- if _action_in_progress:
+ if (
+ _action_in_progress
+ or _confirmation_transition_active
+ or _confirmation_action == ConfirmationAction.NONE
+ ):
return
var action: ConfirmationAction = _confirmation_action
- _close_confirmation()
+ _confirmation_page.lock_interaction()
_action_in_progress = true
+ if action == ConfirmationAction.NEW_GAME:
+ _confirmation_action = ConfirmationAction.NONE
+ _confirmation_transition_generation += 1
+ _cancel_confirmation_transition()
+ _confirmation_page.hide_page()
+ new_game_requested.emit()
+ _action_in_progress = false
+ return
if not _save_manager.delete_progression_save():
- _feedback_label.text = "failed to delete saved progression."
+ _feedback_label.text = _center_feedback_text(
+ "failed to delete saved progression."
+ )
_action_in_progress = false
_refresh_save_inspection()
+ _begin_confirmation_return()
return
_save_manager.initialize_new_game()
_refresh_save_inspection()
- if action == ConfirmationAction.NEW_GAME:
- gameplay_requested.emit()
- else:
- _feedback_label.text = "saved progression deleted."
+ _feedback_label.text = _center_feedback_text("saved progression deleted.")
+ _begin_confirmation_return()
_action_in_progress = false
@@ -447,35 +1086,192 @@ func _open_confirmation(
action: ConfirmationAction,
message: String,
confirm_text: String,
+ use_multiline_action_layout: bool = false,
) -> void:
+ _hide_continue_stats_context()
_modal_restore_navigation_focus = _navigation_focus_active
_confirmation_action = action
- _confirmation_text.text = message
- _confirm_button.text = confirm_text
- _confirmation_panel.visible = true
- _confirm_button.grab_focus()
+ _confirmation_page.configure(
+ message,
+ confirm_text,
+ use_multiline_action_layout
+ )
+ _begin_confirmation_open()
-func _close_confirmation() -> void:
+func _request_close_confirmation() -> void:
+ if (
+ not _confirmation_page.visible
+ or _confirmation_transition_active
+ or _action_in_progress
+ ):
+ return
+ _begin_confirmation_return()
+
+
+func _begin_confirmation_open() -> void:
+ _confirmation_transition_generation += 1
+ _cancel_confirmation_transition()
+ _confirmation_transition_active = true
+ _emit_navigation_bubble_flurry()
+ _confirmation_title_content_rest_position = _presentation_center.position
+ _set_confirmation_stage_rect()
+ _confirmation_page.hide_page()
+ _set_title_bubbles_interactive(false)
+ _release_primary_menu_focus()
+ var generation: int = _confirmation_transition_generation
+ var content_bounds: Rect2 = _get_title_stage_rect(_main_content)
+ var exit_distance: float = (
+ content_bounds.end.y + HOST_CLUSTER_SAFE_MARGIN
+ )
+ _confirmation_transition = create_tween()
+ _confirmation_transition.tween_property(
+ _presentation_center,
+ "position:y",
+ _confirmation_title_content_rest_position.y - exit_distance,
+ UIMotion.bubble_duration(exit_distance)
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _confirmation_transition.finished.connect(
+ _finish_confirmation_title_exit.bind(generation),
+ CONNECT_ONE_SHOT
+ )
+
+
+func _finish_confirmation_title_exit(generation: int) -> void:
+ if (
+ generation != _confirmation_transition_generation
+ or not _confirmation_transition_active
+ ):
+ return
+ _confirmation_transition = null
+ _presentation_center.hide()
+ _presentation_center.position = _confirmation_title_content_rest_position
+ _confirmation_page.transition_in(
+ 0.0,
+ _finish_confirmation_open.bind(generation)
+ )
+
+
+func _finish_confirmation_open(generation: int) -> void:
+ if (
+ generation != _confirmation_transition_generation
+ or not _confirmation_transition_active
+ ):
+ return
+ _confirmation_transition_active = false
+
+
+func _begin_confirmation_return() -> void:
+ if not _confirmation_page.visible or _confirmation_transition_active:
+ return
+ _confirmation_transition_generation += 1
+ _cancel_confirmation_transition()
+ _confirmation_transition_active = true
+ _emit_navigation_bubble_flurry()
+ _confirmation_page.lock_interaction()
+ var generation: int = _confirmation_transition_generation
+ _confirmation_page.transition_out(
+ 0.0,
+ _finish_confirmation_page_exit.bind(generation)
+ )
+
+
+func _finish_confirmation_page_exit(generation: int) -> void:
+ if (
+ generation != _confirmation_transition_generation
+ or not _confirmation_transition_active
+ ):
+ return
+ _confirmation_action = ConfirmationAction.NONE
+ _presentation_center.position = Vector2(
+ _confirmation_title_content_rest_position.x,
+ _title_presentation_scale_root.size.y
+ + HOST_CLUSTER_SAFE_MARGIN
+ )
+ _presentation_center.show()
+ var entry_distance: float = absf(
+ _presentation_center.position.y
+ - _confirmation_title_content_rest_position.y
+ )
+ _confirmation_transition = create_tween()
+ _confirmation_transition.tween_property(
+ _presentation_center,
+ "position",
+ _confirmation_title_content_rest_position,
+ UIMotion.bubble_duration(entry_distance)
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _confirmation_transition.finished.connect(
+ _finish_confirmation_return.bind(generation),
+ CONNECT_ONE_SHOT
+ )
+
+
+func _finish_confirmation_return(generation: int) -> void:
+ if (
+ generation != _confirmation_transition_generation
+ or not _confirmation_transition_active
+ ):
+ return
+ _confirmation_transition = null
+ _confirmation_transition_active = false
+ _presentation_center.position = _confirmation_title_content_rest_position
+ _set_title_bubbles_interactive(true)
var restore_navigation_focus: bool = _modal_restore_navigation_focus
_modal_restore_navigation_focus = false
- _confirmation_action = ConfirmationAction.NONE
- _confirmation_panel.visible = false
- if _awaiting_start_input:
- return
if restore_navigation_focus:
_focus_initial_button()
else:
_navigation_focus_active = false
_release_title_focus()
+ _update_continue_stats_visibility()
+
+
+func _set_confirmation_stage_rect() -> void:
+ var slot_rect: Rect2 = _get_title_stage_rect(_bubble_layout_slot)
+ _confirmation_page.set_stage_rect(
+ slot_rect
+ )
+
+
+func _reset_confirmation() -> void:
+ _confirmation_transition_generation += 1
+ _cancel_confirmation_transition()
+ _confirmation_transition_active = false
+ _confirmation_action = ConfirmationAction.NONE
+ _confirmation_page.hide_page()
+ if is_node_ready():
+ _presentation_center.position = _confirmation_title_content_rest_position
+ _presentation_center.show()
+
+
+func _cancel_confirmation_transition() -> void:
+ if _confirmation_transition != null:
+ _confirmation_transition.kill()
+ _confirmation_transition = null
+
+
+func _is_confirmation_active() -> bool:
+ return (
+ _confirmation_action != ConfirmationAction.NONE
+ or _confirmation_page.visible
+ or _confirmation_transition_active
+ )
func _open_settings() -> void:
- if _confirmation_panel.visible or _action_in_progress:
+ if (
+ _is_confirmation_active()
+ or _action_in_progress
+ or _settings_panel.visible
+ or _credits_page.visible
+ or _title_settings_transition_active
+ ):
return
+ _hide_continue_stats_context()
_modal_restore_navigation_focus = _navigation_focus_active
- _feedback_label.text = ""
- _settings_panel.open_panel(_settings_manager)
+ _feedback_visible_before_settings = _feedback_label.visible
+ _feedback_text_before_settings = _feedback_label.text
+ _begin_title_cluster_exit()
func _close_settings() -> void:
@@ -483,12 +1279,169 @@ func _close_settings() -> void:
func _on_settings_applied() -> void:
- _feedback_label.text = "settings saved."
- _restore_settings_focus()
+ _feedback_text_before_settings = "settings saved."
func _on_settings_closed() -> void:
+ pass
+
+
+func _on_settings_closing() -> void:
+ await get_tree().create_timer(
+ UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
+ ).timeout
+ if _settings_panel.visible:
+ _begin_title_cluster_return(_feedback_text_before_settings)
+
+
+func _on_settings_opened() -> void:
+ pass
+
+
+func _begin_title_cluster_exit() -> void:
+ _title_settings_transition_generation += 1
+ _cancel_title_settings_tween()
+ _title_settings_transition_active = true
+ _emit_navigation_bubble_flurry()
+ _title_content_rest_position = _presentation_center.position
+ _title_bubble_rest_position = _bubble_field.position
+ _set_title_bubbles_interactive(false)
+ _release_primary_menu_focus()
+ var generation: int = _title_settings_transition_generation
+ var content_bounds: Rect2 = _get_title_stage_rect(_main_content)
+ var exit_distance: float = (
+ content_bounds.end.y + HOST_CLUSTER_SAFE_MARGIN
+ )
+ _title_settings_transition = create_tween()
+ _title_settings_transition.tween_property(
+ _presentation_center,
+ "position:y",
+ _title_content_rest_position.y - exit_distance,
+ UIMotion.bubble_duration(exit_distance)
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _title_settings_transition.finished.connect(
+ _finish_title_cluster_exit.bind(generation),
+ CONNECT_ONE_SHOT
+ )
+ await get_tree().create_timer(
+ UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
+ ).timeout
+ if (
+ generation == _title_settings_transition_generation
+ and _title_settings_transition_active
+ ):
+ _settings_panel.open_panel(
+ _settings_manager,
+ SettingsPanelType.PresentationMode.TITLE_EMBEDDED
+ )
+
+
+func _finish_title_cluster_exit(generation: int) -> void:
+ if (
+ generation != _title_settings_transition_generation
+ or not _title_settings_transition_active
+ ):
+ return
+ _title_settings_transition = null
+ _title_settings_transition_active = false
+ _presentation_center.hide()
+ _presentation_center.position = _title_content_rest_position
+
+
+func _begin_title_cluster_return(feedback_text: String) -> void:
+ _title_settings_transition_generation += 1
+ _cancel_title_settings_tween()
+ _title_settings_transition_active = true
+ var centered_feedback: String = _center_feedback_text(feedback_text)
+ if _feedback_label.text != centered_feedback:
+ _feedback_label.text = centered_feedback
+ _feedback_label.visible = _feedback_visible_before_settings
+ _hide_continue_stats_context()
+ _set_title_bubbles_interactive(false)
+ var generation: int = _title_settings_transition_generation
+ _presentation_center.position = Vector2(
+ _title_content_rest_position.x,
+ _title_presentation_scale_root.size.y + HOST_CLUSTER_SAFE_MARGIN
+ )
+ _presentation_center.show()
+ var entry_distance: float = absf(
+ _presentation_center.position.y - _title_content_rest_position.y
+ )
+ _title_settings_transition = create_tween()
+ _title_settings_transition.tween_property(
+ _presentation_center,
+ "position",
+ _title_content_rest_position,
+ UIMotion.bubble_duration(entry_distance)
+ ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+ _title_settings_transition.finished.connect(
+ _finish_title_cluster_return.bind(generation),
+ CONNECT_ONE_SHOT
+ )
+
+
+func _finish_title_cluster_return(generation: int) -> void:
+ if (
+ generation != _title_settings_transition_generation
+ or not _title_settings_transition_active
+ ):
+ return
+ _title_settings_transition = null
+ _title_settings_transition_active = false
+ _presentation_center.position = _title_content_rest_position
+ _bubble_field.position = _title_bubble_rest_position
+ _set_title_bubbles_interactive(true)
_restore_settings_focus()
+ _update_continue_stats_visibility()
+
+func _set_title_bubbles_interactive(interactive: bool) -> void:
+ for bubble: BubbleButton in _get_title_buttons():
+ bubble.focus_mode = (
+ Control.FOCUS_ALL if interactive else Control.FOCUS_NONE
+ )
+ bubble.mouse_filter = (
+ Control.MOUSE_FILTER_STOP
+ if interactive
+ else Control.MOUSE_FILTER_IGNORE
+ )
+
+
+func _capture_title_bubble_rest_position() -> void:
+ if (
+ not is_node_ready()
+ or _title_settings_transition_active
+ or not _button_center.visible
+ ):
+ return
+ _title_bubble_rest_position = _bubble_field.position
+
+
+func _capture_title_content_rest_position() -> void:
+ if (
+ not is_node_ready()
+ or _title_settings_transition_active
+ or _title_entry_transition_active
+ or not _button_center.visible
+ ):
+ return
+ _title_content_rest_position = _presentation_center.position
+
+
+func _cancel_title_settings_transition() -> void:
+ _title_settings_transition_generation += 1
+ _cancel_title_settings_tween()
+ _title_settings_transition_active = false
+ if is_node_ready():
+ _presentation_center.position = _title_content_rest_position
+ _presentation_center.show()
+ _bubble_field.position = _title_bubble_rest_position
+ _set_title_bubbles_interactive(true)
+
+
+func _cancel_title_settings_tween() -> void:
+ if _title_settings_transition != null:
+ _title_settings_transition.kill()
+ _title_settings_transition = null
func _restore_settings_focus() -> void:
@@ -506,16 +1459,16 @@ func _refresh_save_inspection() -> void:
_inspection = _save_manager.inspect_save()
_continue_button.disabled = not _inspection.can_continue()
_delete_button.disabled = not _inspection.can_delete()
- _feedback_label.text = _inspection.message
+ _delete_save_label.modulate.a = (
+ DISABLED_BUBBLE_LABEL_ALPHA if _delete_button.disabled else 1.0
+ )
+ _feedback_label.text = _center_feedback_text(_inspection.message)
if _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED:
- _feedback_label.text = (
- "%d fish • $%d • %d discovered"
- % [
- _inspection.catch_count,
- _inspection.wallet_balance,
- _inspection.discovered_species_count,
- ]
- )
+ _feedback_label.text = _get_continue_stats_text()
+ if _continue_button.disabled:
+ _hide_continue_stats_context()
+ else:
+ _update_continue_stats_visibility()
func _focus_initial_button() -> void:
@@ -524,6 +1477,7 @@ func _focus_initial_button() -> void:
or not visible
or _awaiting_start_input
or not _button_center.visible
+ or _credits_page.visible
):
return
if not _continue_button.disabled:
@@ -531,14 +1485,17 @@ func _focus_initial_button() -> void:
else:
_new_game_button.grab_focus()
_navigation_focus_active = true
+ _update_continue_stats_visibility()
func _on_quit_pressed() -> void:
if (
not _action_in_progress
- and not _confirmation_panel.visible
+ and not _is_confirmation_active()
and not _settings_panel.visible
+ and not _credits_page.visible
):
+ _hide_continue_stats_context()
quit_requested.emit()
@@ -546,11 +1503,15 @@ func _on_title_visibility_changed() -> void:
if not _decorative_presentation_ready:
return
if visible:
+ set_process(true)
_start_decorative_presentation()
else:
+ _hide_continue_stats_context()
_stop_entry_prompt_animation()
_navigation_focus_active = false
_modal_restore_navigation_focus = false
+ _reset_confirmation()
+ _credits_page.close_page()
_stop_decorative_presentation()
@@ -594,6 +1555,7 @@ func _stop_decorative_presentation() -> void:
if is_instance_valid(bubble):
bubble.queue_free()
_decorative_bubbles.clear()
+ _transition_bubble_ids.clear()
func _schedule_next_decorative_fish(
@@ -677,12 +1639,12 @@ func _spawn_decorative_fish(generation: int) -> void:
CROSSING_DURATION_MIN,
CROSSING_DURATION_MAX
)
- fish_control.position = Vector2(
- -presentation_size.x - FISH_EDGE_MARGIN
- if direction > 0.0
- else layer_size.x + FISH_EDGE_MARGIN,
- base_y
+ var horizontal_bounds: Vector2 = get_decorative_fish_crossing_bounds(
+ layer_size.x,
+ presentation_size.x,
+ direction,
)
+ fish_control.position = Vector2(horizontal_bounds.x, base_y)
_decorative_fish_layer.add_child(fish_control)
_decorative_fish = fish_control
_decorative_fish_tween = create_tween()
@@ -722,17 +1684,14 @@ func _update_decorative_fish(
or not is_instance_valid(fish_control)
):
return
- var start_x: float = (
- -fish_control.size.x - FISH_EDGE_MARGIN
- if direction > 0.0
- else _decorative_fish_layer.size.x + FISH_EDGE_MARGIN
+ var horizontal_bounds: Vector2 = get_decorative_fish_crossing_bounds(
+ _decorative_fish_layer.size.x,
+ fish_control.size.x,
+ direction,
)
- var end_x: float = (
- _decorative_fish_layer.size.x + FISH_EDGE_MARGIN
- if direction > 0.0
- else -fish_control.size.x - FISH_EDGE_MARGIN
+ fish_control.position.x = lerpf(
+ horizontal_bounds.x, horizontal_bounds.y, progress
)
- fish_control.position.x = lerpf(start_x, end_x, progress)
var bobbed_y: float = (
base_y
+ sin(progress * crossing_duration * bob_speed) * bob_amplitude
@@ -742,6 +1701,21 @@ func _update_decorative_fish(
8.0,
maxf(8.0, _decorative_fish_layer.size.y - fish_control.size.y - 8.0)
)
+ fish_control.position = _snap_world_preview(fish_control.position)
+
+
+static func get_decorative_fish_crossing_bounds(
+ layer_width: float,
+ fish_width: float,
+ direction: float,
+) -> Vector2:
+ var outside_left: float = -fish_width - FISH_EDGE_MARGIN
+ var outside_right: float = layer_width + FISH_EDGE_MARGIN
+ return (
+ Vector2(outside_left, outside_right)
+ if direction > 0.0
+ else Vector2(outside_right, outside_left)
+ )
func _on_decorative_fish_finished(
@@ -776,7 +1750,7 @@ func _on_decorative_bubble_event_timer_timeout() -> void:
if not _decorative_presentation_active or not visible:
return
var available_slots: int = (
- MAX_DECORATIVE_BUBBLES - _decorative_bubbles.size()
+ MAX_DECORATIVE_BUBBLES - _get_idle_decorative_bubble_count()
)
if available_slots <= 0:
_schedule_next_decorative_bubble_event(
@@ -825,7 +1799,7 @@ func _on_decorative_bubble_cluster_timer_timeout() -> void:
):
_pending_cluster_bubbles = 0
return
- if _decorative_bubbles.size() < MAX_DECORATIVE_BUBBLES:
+ if _get_idle_decorative_bubble_count() < MAX_DECORATIVE_BUBBLES:
_spawn_decorative_bubble(_decorative_generation)
_pending_cluster_bubbles -= 1
if _pending_cluster_bubbles > 0:
@@ -837,15 +1811,24 @@ func _on_decorative_bubble_cluster_timer_timeout() -> void:
)
-func _spawn_decorative_bubble(generation: int) -> bool:
+func _spawn_decorative_bubble(
+ generation: int,
+ start_y_ratio: float = -1.0,
+ normalized_x_override: float = -1.0,
+ is_transition_bubble: bool = false,
+) -> bool:
if (
generation != _decorative_generation
or not _decorative_presentation_active
or not visible
- or _decorative_bubbles.size() >= MAX_DECORATIVE_BUBBLES
or DECORATIVE_BUBBLE_TEXTURES.is_empty()
):
return false
+ if is_transition_bubble:
+ if _transition_bubble_ids.size() >= MAX_TRANSITION_BUBBLES:
+ return false
+ elif _get_idle_decorative_bubble_count() >= MAX_DECORATIVE_BUBBLES:
+ return false
var layer_size: Vector2 = _decorative_bubble_layer.size
if layer_size.x <= 1.0 or layer_size.y <= 1.0:
return false
@@ -874,8 +1857,12 @@ func _spawn_decorative_bubble(generation: int) -> bool:
)
_decorative_bubble_layer.add_child(bubble)
_decorative_bubbles.append(bubble)
+ if is_transition_bubble:
+ _transition_bubble_ids[bubble.get_instance_id()] = true
- var normalized_x: float = _decorative_rng.randf_range(0.08, 0.92)
+ var normalized_x: float = normalized_x_override
+ if normalized_x < 0.0:
+ normalized_x = _decorative_rng.randf_range(0.08, 0.92)
var drift_direction: float = (
1.0 if _decorative_rng.randi_range(0, 1) == 1 else -1.0
)
@@ -896,6 +1883,28 @@ func _spawn_decorative_bubble(generation: int) -> bool:
BUBBLE_TRAVEL_DURATION_MIN,
BUBBLE_TRAVEL_DURATION_MAX
)
+ if start_y_ratio >= 0.0:
+ var full_start_y: float = (
+ layer_size.y + presentation_size.y + BUBBLE_EDGE_MARGIN
+ )
+ var end_y: float = -presentation_size.y - BUBBLE_EDGE_MARGIN
+ var burst_start_y: float = (
+ start_y_ratio * layer_size.y - presentation_size.y * 0.5
+ )
+ var remaining_distance: float = maxf(burst_start_y - end_y, 1.0)
+ var full_distance: float = maxf(full_start_y - end_y, 1.0)
+ travel_duration *= remaining_distance / full_distance
+ _update_decorative_bubble(
+ 0.0,
+ bubble,
+ normalized_x,
+ horizontal_drift,
+ wobble_amplitude,
+ wobble_cycles,
+ wobble_phase,
+ start_y_ratio,
+ generation
+ )
var bubble_tween: Tween = create_tween()
_decorative_bubble_tweens[bubble.get_instance_id()] = bubble_tween
bubble_tween.tween_method(
@@ -906,6 +1915,7 @@ func _spawn_decorative_bubble(generation: int) -> bool:
wobble_amplitude,
wobble_cycles,
wobble_phase,
+ start_y_ratio,
generation
),
0.0,
@@ -927,6 +1937,7 @@ func _update_decorative_bubble(
wobble_amplitude: float,
wobble_cycles: float,
wobble_phase: float,
+ start_y_ratio: float,
generation: int,
) -> void:
if (
@@ -939,18 +1950,20 @@ func _update_decorative_bubble(
var start_y: float = (
layer_size.y + bubble.size.y + BUBBLE_EDGE_MARGIN
)
+ if start_y_ratio >= 0.0:
+ start_y = start_y_ratio * layer_size.y - bubble.size.y * 0.5
var end_y: float = -bubble.size.y - BUBBLE_EDGE_MARGIN
var base_x: float = normalized_x * layer_size.x
var wobble: float = sin(
wobble_phase + progress * TAU * wobble_cycles
) * wobble_amplitude
- bubble.position = Vector2(
+ bubble.position = _snap_world_preview(Vector2(
base_x
+ horizontal_drift * progress
+ wobble
- bubble.size.x * 0.5,
lerpf(start_y, end_y, progress)
- )
+ ))
func _on_decorative_bubble_finished(
@@ -961,9 +1974,37 @@ func _on_decorative_bubble_finished(
return
if is_instance_valid(bubble):
_decorative_bubble_tweens.erase(bubble.get_instance_id())
+ _transition_bubble_ids.erase(bubble.get_instance_id())
_decorative_bubbles.erase(bubble)
bubble.queue_free()
+func _get_idle_decorative_bubble_count() -> int:
+ return _decorative_bubbles.size() - _transition_bubble_ids.size()
+
+
+func _emit_navigation_bubble_flurry() -> void:
+ if not _decorative_presentation_active or not visible:
+ return
+ var bubble_count: int = _decorative_rng.randi_range(
+ TRANSITION_BUBBLE_COUNT_MIN,
+ TRANSITION_BUBBLE_COUNT_MAX
+ )
+ for _bubble_index: int in bubble_count:
+ if not _spawn_decorative_bubble(
+ _decorative_generation,
+ _decorative_rng.randf_range(
+ TRANSITION_BUBBLE_Y_MIN,
+ TRANSITION_BUBBLE_Y_MAX
+ ),
+ _decorative_rng.randf_range(
+ TRANSITION_BUBBLE_X_MIN,
+ TRANSITION_BUBBLE_X_MAX
+ ),
+ true
+ ):
+ break
+
+
func _exit_tree() -> void:
_stop_decorative_presentation()
diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn
index 6919405..ae0c0c6 100644
--- a/ui/title_screen.tscn
+++ b/ui/title_screen.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=9 format=3]
+[gd_scene load_steps=19 format=3]
[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
@@ -6,6 +6,15 @@
[ext_resource type="Shader" path="res://ui/title_water_background.gdshader" id="4_water_shader"]
[ext_resource type="Texture2D" path="res://ui/assets/title/netfishing_logo.png" id="5_logo"]
[ext_resource type="Shader" path="res://ui/title_logo_underwater.gdshader" id="6_logo_shader"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_button.gd" id="7_bubble_button"]
+[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="8_bubble_cluster"]
+[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="9_bubble_profile"]
+[ext_resource type="PackedScene" path="res://ui/title_confirmation_bubble_page.tscn" id="10_confirmation_page"]
+[ext_resource type="PackedScene" path="res://ui/network/join_game_page.tscn" id="11_join_page"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/online_dark.png" id="12_online_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/settings_dark.png" id="13_settings_dark"]
+[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_dark.png" id="14_x_dark"]
+[ext_resource type="PackedScene" path="res://ui/title_credits_page.tscn" id="15_credits_page"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
shader = ExtResource("4_water_shader")
@@ -13,6 +22,17 @@ shader = ExtResource("4_water_shader")
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_logo"]
shader = ExtResource("6_logo_shader")
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_continue_stats"]
+content_margin_left = 14.0
+content_margin_top = 6.0
+content_margin_right = 14.0
+content_margin_bottom = 6.0
+bg_color = Color(0.031, 0.122, 0.169, 1)
+corner_radius_top_left = 14
+corner_radius_top_right = 14
+corner_radius_bottom_right = 14
+corner_radius_bottom_left = 14
+
[node name="TitleScreen" type="Control"]
unique_name_in_owner = true
z_index = 200
@@ -27,6 +47,8 @@ theme = ExtResource("2_theme")
script = ExtResource("1_script")
[node name="Background" type="ColorRect" parent="."]
+unique_name_in_owner = true
+visible = false
material = SubResource("ShaderMaterial_title_water")
layout_mode = 1
anchors_preset = 15
@@ -72,7 +94,25 @@ one_shot = true
unique_name_in_owner = true
one_shot = true
-[node name="StartPromptCenter" type="CenterContainer" parent="."]
+[node name="ResponsiveTitleStage" type="Control" parent="."]
+unique_name_in_owner = true
+z_index = 10
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 1
+
+[node name="TitlePresentationScaleRoot" type="Control" parent="ResponsiveTitleStage"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 1280.0
+offset_bottom = 720.0
+mouse_filter = 1
+
+[node name="StartPromptCenter" type="CenterContainer" parent="ResponsiveTitleStage/TitlePresentationScaleRoot"]
unique_name_in_owner = true
z_index = 12
layout_mode = 1
@@ -85,19 +125,31 @@ grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
-[node name="StartPromptLabel" type="Label" parent="StartPromptCenter"]
+[node name="StartPromptLabel" type="Label" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/StartPromptCenter"]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0.02, 0.075, 0.11, 0.9)
theme_override_constants/outline_size = 3
-theme_override_font_sizes/font_size = 26
+theme_override_font_sizes/font_size = 39
text = "press any key to start"
horizontal_alignment = 1
vertical_alignment = 1
-[node name="Center" type="CenterContainer" parent="."]
+[node name="IntroBrandingAnchor" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot"]
+unique_name_in_owner = true
+layout_mode = 1
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="Center" type="CenterContainer" parent="ResponsiveTitleStage/TitlePresentationScaleRoot"]
+unique_name_in_owner = true
z_index = 10
layout_mode = 1
anchors_preset = 15
@@ -106,12 +158,34 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-[node name="MainContent" type="VBoxContainer" parent="Center"]
+[node name="MainContent" type="VBoxContainer" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center"]
+unique_name_in_owner = true
layout_mode = 2
-theme_override_constants/separation = 8
+theme_override_constants/separation = 7
alignment = 1
-[node name="TitleLogo" type="TextureRect" parent="Center/MainContent"]
+[node name="BrandingSlot" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(640, 215)
+layout_mode = 2
+
+[node name="BrandingMotionRoot" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/BrandingSlot"]
+unique_name_in_owner = true
+layout_mode = 0
+offset_right = 640.0
+offset_bottom = 215.0
+mouse_filter = 2
+
+[node name="BrandingContent" type="VBoxContainer" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/BrandingSlot/BrandingMotionRoot"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/separation = 7
+
+[node name="TitleLogo" type="TextureRect" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/BrandingSlot/BrandingMotionRoot/BrandingContent"]
unique_name_in_owner = true
custom_minimum_size = Vector2(640, 190)
material = SubResource("ShaderMaterial_title_logo")
@@ -122,121 +196,278 @@ texture = ExtResource("5_logo")
expand_mode = 1
stretch_mode = 5
-[node name="PlaytestLabel" type="Label" parent="Center/MainContent"]
+[node name="VersionRow" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/BrandingSlot/BrandingMotionRoot/BrandingContent"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 18)
+layout_mode = 2
+mouse_filter = 2
+
+[node name="VersionAnchor" type="CenterContainer" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/BrandingSlot/BrandingMotionRoot/BrandingContent/VersionRow"]
+layout_mode = 1
+anchor_left = 0.7
+anchor_top = 0.5
+anchor_right = 0.7
+anchor_bottom = 0.5
+offset_left = -140.0
+offset_top = -17.0
+offset_right = 140.0
+offset_bottom = 11.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="PlaytestLabel" type="Label" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/BrandingSlot/BrandingMotionRoot/BrandingContent/VersionRow/VersionAnchor"]
+unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
-theme_override_font_sizes/font_size = 13
-text = "pre-alpha playtest 0.2"
+theme_override_font_sizes/font_size = 22
+text = "v0.7.1-alpha"
horizontal_alignment = 1
-[node name="Spacer" type="Control" parent="Center/MainContent"]
-custom_minimum_size = Vector2(0, 4)
+[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 3)
layout_mode = 2
-[node name="ButtonCenter" type="CenterContainer" parent="Center/MainContent"]
+[node name="ButtonCenter" type="CenterContainer" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
unique_name_in_owner = true
visible = false
layout_mode = 2
-[node name="ButtonStack" type="VBoxContainer" parent="Center/MainContent/ButtonCenter"]
+[node name="BubbleLayoutSlot" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(360, 0)
+custom_minimum_size = Vector2(440, 318)
layout_mode = 2
-theme_override_constants/separation = 8
-[node name="ContinueButton" type="Button" parent="Center/MainContent/ButtonCenter/ButtonStack"]
+[node name="BubbleMotionRoot" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 44)
-layout_mode = 2
+layout_mode = 0
+offset_right = 440.0
+offset_bottom = 318.0
+mouse_filter = 2
+
+[node name="BubbleField" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(440, 318)
+layout_mode = 0
+offset_right = 440.0
+offset_bottom = 318.0
+script = ExtResource("8_bubble_cluster")
+profile = ExtResource("9_bubble_profile")
+
+[node name="ContinueButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
+unique_name_in_owner = true
+offset_left = 123.0
+offset_top = 86.0
+offset_right = 307.0
+offset_bottom = 264.0
text = "continue"
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+neutral_size = Vector2(184, 178)
+desktop_anchor = Vector2(215, 175)
+compact_anchor = Vector2(215, 175)
+minimum_font_size = 19
+maximum_font_size = 30
+horizontal_amplitude = 1.8
+vertical_amplitude = 4.6
+motion_period = 5.8
+deformation_amplitude = 0.01
+deformation_period = 6.7
-[node name="NewGameButton" type="Button" parent="Center/MainContent/ButtonCenter/ButtonStack"]
+[node name="NewGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 44)
-layout_mode = 2
+offset_left = 42.0
+offset_top = 4.0
+offset_right = 170.0
+offset_bottom = 126.0
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+neutral_size = Vector2(128, 122)
+desktop_anchor = Vector2(106, 65)
+compact_anchor = Vector2(106, 68)
+compact_minimum_size = Vector2(84, 84)
+label_control_path = NodePath("NewGameLabel")
+minimum_font_size = 15
+maximum_font_size = 23
+horizontal_amplitude = 2.3
+vertical_amplitude = 5.5
+motion_period = 6.3
+motion_phase = 1.2
+deformation_amplitude = 0.018
+deformation_period = 5.9
+
+[node name="NewGameLabel" type="Label" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField/NewGameButton"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
+theme_override_constants/line_spacing = -3
text = "new game"
-
-[node name="SettingsButton" type="Button" parent="Center/MainContent/ButtonCenter/ButtonStack"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 44)
-layout_mode = 2
-text = "settings"
-
-[node name="DeleteSaveButton" type="Button" parent="Center/MainContent/ButtonCenter/ButtonStack"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 44)
-layout_mode = 2
-text = "delete save"
-
-[node name="QuitButton" type="Button" parent="Center/MainContent/ButtonCenter/ButtonStack"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(0, 44)
-layout_mode = 2
-text = "quit"
-
-[node name="FeedbackLabel" type="Label" parent="Center/MainContent"]
-unique_name_in_owner = true
-visible = false
-custom_minimum_size = Vector2(0, 42)
-layout_mode = 2
-text = ""
horizontal_alignment = 1
vertical_alignment = 1
-autowrap_mode = 2
-[node name="ConfirmationPanel" type="PanelContainer" parent="."]
+[node name="SettingsButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
+unique_name_in_owner = true
+offset_left = 264.0
+offset_top = 17.0
+offset_right = 384.0
+offset_bottom = 133.0
+texture_filter = 1
+icon = ExtResource("13_settings_dark")
+tooltip_text = "settings"
+accessibility_name = "settings"
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+desktop_anchor = Vector2(324, 75)
+compact_anchor = Vector2(324, 68)
+compact_minimum_size = Vector2(94, 90)
+minimum_font_size = 15
+maximum_font_size = 22
+vertical_amplitude = 3.7
+motion_period = 4.9
+motion_phase = 2.5
+deformation_period = 6.3
+
+[node name="CreditsButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
+unique_name_in_owner = true
+offset_left = 330.0
+offset_top = 125.0
+offset_right = 414.0
+offset_bottom = 207.0
+text = "credits"
+tooltip_text = "credits"
+accessibility_name = "credits"
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+neutral_size = Vector2(84, 82)
+desktop_anchor = Vector2(372, 166)
+compact_anchor = Vector2(372, 166)
+compact_minimum_size = Vector2(62, 62)
+minimum_font_size = 12
+maximum_font_size = 17
+horizontal_amplitude = 1.4
+vertical_amplitude = 3.0
+motion_period = 5.0
+motion_phase = 1.8
+deformation_amplitude = 0.015
+deformation_period = 5.6
+
+[node name="JoinGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
+unique_name_in_owner = true
+offset_left = 126.0
+offset_top = 224.0
+offset_right = 248.0
+offset_bottom = 342.0
+texture_filter = 1
+icon = ExtResource("12_online_dark")
+tooltip_text = "join game"
+accessibility_name = "join game"
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+neutral_size = Vector2(122, 118)
+desktop_anchor = Vector2(187, 268)
+compact_anchor = Vector2(187, 268)
+compact_minimum_size = Vector2(82, 80)
+minimum_font_size = 15
+maximum_font_size = 22
+horizontal_amplitude = 1.7
+vertical_amplitude = 3.8
+motion_period = 5.2
+motion_phase = 3.1
+deformation_amplitude = 0.015
+deformation_period = 5.7
+
+[node name="DeleteSaveButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
+unique_name_in_owner = true
+offset_left = -11.0
+offset_top = 122.0
+offset_right = 123.0
+offset_bottom = 248.0
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+neutral_size = Vector2(134, 126)
+desktop_anchor = Vector2(56, 185)
+compact_anchor = Vector2(53, 196)
+compact_minimum_size = Vector2(86, 86)
+label_control_path = NodePath("DeleteSaveLabel")
+minimum_font_size = 15
+maximum_font_size = 23
+vertical_amplitude = 5.1
+motion_period = 5.5
+motion_phase = 3.7
+deformation_amplitude = 0.018
+deformation_period = 5.4
+
+[node name="DeleteSaveLabel" type="Label" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField/DeleteSaveButton"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1)
+theme_override_constants/line_spacing = -3
+text = "delete save"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="QuitButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
+unique_name_in_owner = true
+offset_left = 275.0
+offset_top = 217.0
+offset_right = 369.0
+offset_bottom = 309.0
+texture_filter = 1
+icon = ExtResource("14_x_dark")
+tooltip_text = "quit"
+accessibility_name = "quit"
+script = ExtResource("7_bubble_button")
+profile = ExtResource("9_bubble_profile")
+neutral_size = Vector2(94, 92)
+desktop_anchor = Vector2(322, 263)
+compact_anchor = Vector2(322, 263)
+compact_minimum_size = Vector2(68, 68)
+minimum_font_size = 14
+maximum_font_size = 18
+horizontal_amplitude = 1.4
+vertical_amplitude = 3.2
+motion_period = 4.2
+motion_phase = 4.8
+deformation_amplitude = 0.02
+deformation_period = 4.8
+
+[node name="FeedbackLabel" type="RichTextLabel" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
unique_name_in_owner = true
visible = false
-z_index = 210
-layout_mode = 1
-anchors_preset = 8
-anchor_left = 0.5
-anchor_top = 0.5
-anchor_right = 0.5
-anchor_bottom = 0.5
-offset_left = -230.0
-offset_top = -95.0
-offset_right = 230.0
-offset_bottom = 95.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="Margin" type="MarginContainer" parent="ConfirmationPanel"]
+custom_minimum_size = Vector2(430, 44)
layout_mode = 2
-theme_override_constants/margin_left = 24
-theme_override_constants/margin_top = 20
-theme_override_constants/margin_right = 24
-theme_override_constants/margin_bottom = 20
-
-[node name="Content" type="VBoxContainer" parent="ConfirmationPanel/Margin"]
-layout_mode = 2
-theme_override_constants/separation = 14
-
-[node name="ConfirmationText" type="Label" parent="ConfirmationPanel/Margin/Content"]
-unique_name_in_owner = true
-layout_mode = 2
-text = "confirm?"
-horizontal_alignment = 1
+size_flags_horizontal = 4
+text = ""
+bbcode_enabled = true
+scroll_active = false
autowrap_mode = 2
+vertical_alignment = 1
+theme_override_styles/normal = SubResource("StyleBoxFlat_continue_stats")
-[node name="Buttons" type="HBoxContainer" parent="ConfirmationPanel/Margin/Content"]
-layout_mode = 2
-theme_override_constants/separation = 12
-alignment = 1
-
-[node name="ConfirmButton" type="Button" parent="ConfirmationPanel/Margin/Content/Buttons"]
+[node name="ConfirmationPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("10_confirmation_page")]
unique_name_in_owner = true
-custom_minimum_size = Vector2(150, 42)
-layout_mode = 2
-text = "confirm"
+z_index = 11
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
-[node name="CancelConfirmButton" type="Button" parent="ConfirmationPanel/Margin/Content/Buttons"]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(120, 42)
-layout_mode = 2
-text = "cancel"
-
-[node name="SettingsPanel" parent="." instance=ExtResource("3_settings")]
+[node name="SettingsPanel" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("3_settings")]
unique_name_in_owner = true
z_index = 210
layout_mode = 1
@@ -245,9 +476,29 @@ anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
-offset_left = -300.0
-offset_top = -245.0
-offset_right = 300.0
-offset_bottom = 245.0
+offset_left = -360.0
+offset_top = -300.0
+offset_right = 360.0
+offset_bottom = 300.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="CreditsPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("15_credits_page")]
+unique_name_in_owner = true
+z_index = 220
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="JoinGamePage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("11_join_page")]
+unique_name_in_owner = true
+z_index = 220
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
diff --git a/ui/typewriter_reveal.gd b/ui/typewriter_reveal.gd
new file mode 100644
index 0000000..aa4f3d6
--- /dev/null
+++ b/ui/typewriter_reveal.gd
@@ -0,0 +1,47 @@
+class_name TypewriterReveal
+extends RefCounted
+
+const DEFAULT_CHARACTERS_PER_SECOND: float = 28.0
+const MIN_CHARACTERS_PER_SECOND: float = 12.0
+const MAX_CHARACTERS_PER_SECOND: float = 60.0
+
+static var _characters_per_second: float = DEFAULT_CHARACTERS_PER_SECOND
+
+
+static func set_characters_per_second(value: float) -> void:
+ _characters_per_second = clampf(
+ value,
+ MIN_CHARACTERS_PER_SECOND,
+ MAX_CHARACTERS_PER_SECOND,
+ )
+
+
+static func get_characters_per_second() -> float:
+ return _characters_per_second
+
+
+static func start(
+ label: Label,
+ characters_per_second: float = -1.0,
+) -> float:
+ if label == null:
+ return 0.0
+ var character_count: int = label.get_total_character_count()
+ if character_count <= 0:
+ label.visible_characters = -1
+ return 0.0
+ label.visible_characters = 0
+ var resolved_characters_per_second := (
+ characters_per_second
+ if characters_per_second > 0.0
+ else _characters_per_second
+ )
+ var duration := float(character_count) / resolved_characters_per_second
+ var reveal_tween := label.create_tween()
+ reveal_tween.tween_property(
+ label,
+ "visible_characters",
+ character_count,
+ duration,
+ ).from(0).set_trans(Tween.TRANS_LINEAR)
+ return duration
diff --git a/ui/typewriter_reveal.gd.uid b/ui/typewriter_reveal.gd.uid
new file mode 100644
index 0000000..2d96fd3
--- /dev/null
+++ b/ui/typewriter_reveal.gd.uid
@@ -0,0 +1 @@
+uid://c1as5qlog6ijy
diff --git a/ui/ui_motion.gd b/ui/ui_motion.gd
new file mode 100644
index 0000000..e538744
--- /dev/null
+++ b/ui/ui_motion.gd
@@ -0,0 +1,19 @@
+class_name UIMotion
+extends RefCounted
+
+const BUBBLE_VERTICAL_SPEED: float = 420.0
+const BUBBLE_TRANSITION_OVERLAP_DELAY: float = 0.05
+const UTILITY_ENTER_DURATION: float = 0.19
+const UTILITY_EXIT_DURATION: float = 0.15
+const UTILITY_ENTER_SCALE: float = 0.97
+const PLAYER_MENU_ENTER_DURATION: float = 0.16
+const PLAYER_MENU_EXIT_DURATION: float = 0.14
+const PLAYER_MENU_PAGE_DURATION: float = 0.12
+const PLAYER_MENU_INVENTORY_DURATION: float = 0.10
+const PLAYER_MENU_ENTER_SCALE: float = 0.985
+const PLAYER_MENU_EXIT_SCALE: float = 0.99
+const CHAT_RESIZE_DURATION: float = 0.15
+
+
+static func bubble_duration(distance: float) -> float:
+ return maxf(absf(distance) / BUBBLE_VERTICAL_SPEED, 0.08)
diff --git a/ui/ui_motion.gd.uid b/ui/ui_motion.gd.uid
new file mode 100644
index 0000000..70fd633
--- /dev/null
+++ b/ui/ui_motion.gd.uid
@@ -0,0 +1 @@
+uid://biwyjd2usqsm6
diff --git a/ui/ui_palette.gd b/ui/ui_palette.gd
index e2e7604..dec14ce 100644
--- a/ui/ui_palette.gd
+++ b/ui/ui_palette.gd
@@ -18,6 +18,12 @@ const RARITY_RARE := Color("5596f6")
const RARITY_EPIC := Color("b176e8")
const RARITY_LEGENDARY := Color("f276b3")
+const QUALITY_BORING := Color("e8eef0")
+const QUALITY_AVERAGE := Color("64c87c")
+const QUALITY_IMPRESSIVE := Color("6098dd")
+const QUALITY_EXCEPTIONAL := Color("a979cf")
+const QUALITY_SHINY := Color("db78a7")
+
static func get_rarity_color(rarity: int) -> Color:
match rarity:
@@ -31,3 +37,17 @@ static func get_rarity_color(rarity: int) -> Color:
return RARITY_LEGENDARY
_:
return RARITY_COMMON
+
+
+static func get_quality_color(quality: int) -> Color:
+ match quality:
+ 1:
+ return QUALITY_AVERAGE
+ 2:
+ return QUALITY_IMPRESSIVE
+ 3:
+ return QUALITY_EXCEPTIONAL
+ 4:
+ return QUALITY_SHINY
+ _:
+ return QUALITY_BORING
diff --git a/ui/ui_pixelation_presenter.gd b/ui/ui_pixelation_presenter.gd
new file mode 100644
index 0000000..2cc2063
--- /dev/null
+++ b/ui/ui_pixelation_presenter.gd
@@ -0,0 +1,195 @@
+class_name UIPixelationPresenter
+extends SubViewportContainer
+
+const MIN_UI_VIEWPORT_SIZE: Vector2i = Vector2i(256, 180)
+const UIReferencePresentationType = preload(
+ "res://ui/ui_reference_presentation.gd"
+)
+const ControllerFocusPresentationType = preload(
+ "res://ui/controller_focus_presentation.gd"
+)
+const ControllerFocusRecoveryType = preload(
+ "res://ui/controller_focus_recovery.gd"
+)
+const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd")
+
+signal effective_pixel_size_changed(
+ requested_pixel_size: int,
+ effective_pixel_size: int,
+)
+
+@onready var _ui_viewport: SubViewport = $UIViewport
+@onready var _ui_root: Control = $UIViewport/GameUI/UIRoot
+@onready var _canonical_stage: Control = (
+ $UIViewport/GameUI/UIRoot/CanonicalStage
+)
+@onready var _hotbar: HotbarUI = (
+ $UIViewport/GameUI/UIRoot/CanonicalStage/Hotbar
+)
+@onready var _chat_ui: ChatUI = $UIViewport/GameUI/UIRoot/ChatUI
+@onready var _title_content_stage: Control = (
+ $UIViewport/GameUI/UIRoot/TitleScreen/ResponsiveTitleStage
+)
+
+var _requested_pixel_size: int = PlayerSettings.DEFAULT_UI_PIXEL_SIZE
+var _effective_pixel_size: int = PlayerSettings.DEFAULT_UI_PIXEL_SIZE
+var _gameplay_active: bool = false
+var _interactive_ui_open: bool = false
+var _passive_pointer_ui_enabled: bool = false
+var _on_screen_keyboard: OnScreenKeyboardType
+
+
+func _ready() -> void:
+ _on_screen_keyboard = OnScreenKeyboardType.new()
+ _ui_root.add_child(_on_screen_keyboard)
+ var controller_focus_recovery := ControllerFocusRecoveryType.new()
+ _ui_root.add_child(controller_focus_recovery)
+ var controller_focus_presentation := ControllerFocusPresentationType.new()
+ _ui_root.add_child(controller_focus_presentation)
+ var root_viewport: Viewport = get_viewport()
+ root_viewport.size_changed.connect(_resize_presentation)
+ _resize_presentation()
+
+
+func set_on_screen_keyboard_enabled(enabled: bool) -> void:
+ _on_screen_keyboard.set_enabled(enabled)
+
+
+func set_pixel_size(pixel_size: int) -> void:
+ _requested_pixel_size = clampi(
+ pixel_size,
+ PlayerSettings.MIN_UI_PIXEL_SIZE,
+ PlayerSettings.MAX_UI_PIXEL_SIZE
+ )
+ _resize_presentation()
+
+
+func get_requested_pixel_size() -> int:
+ return _requested_pixel_size
+
+
+func get_effective_pixel_size() -> int:
+ return _effective_pixel_size
+
+
+func get_ui_viewport_size() -> Vector2i:
+ return _ui_viewport.size
+
+
+func set_gameplay_active(active: bool) -> void:
+ _gameplay_active = active
+ _refresh_mouse_filter()
+
+
+func set_interactive_ui_open(is_open: bool) -> void:
+ _interactive_ui_open = is_open
+ _refresh_mouse_filter()
+
+
+func set_passive_pointer_ui_enabled(is_enabled: bool) -> void:
+ _passive_pointer_ui_enabled = is_enabled
+ _refresh_mouse_filter()
+
+
+func _refresh_mouse_filter() -> void:
+ if not _gameplay_active or _interactive_ui_open:
+ mouse_filter = Control.MOUSE_FILTER_STOP
+ elif _passive_pointer_ui_enabled:
+ # Forward pointer events to lightweight gameplay overlays such as Chat,
+ # while allowing events they do not consume to continue to gameplay.
+ mouse_filter = Control.MOUSE_FILTER_PASS
+ else:
+ mouse_filter = Control.MOUSE_FILTER_IGNORE
+
+
+func _resize_presentation() -> void:
+ if not is_node_ready():
+ return
+ var root_size: Vector2i = get_window().size
+ if root_size.x <= 0 or root_size.y <= 0:
+ return
+ var display_size := Vector2(root_size)
+ var reference_scale: float = (
+ UIReferencePresentationType.get_scale(display_size)
+ )
+ var previous_effective_size: int = _effective_pixel_size
+ _effective_pixel_size = _resolve_effective_pixel_size(reference_scale)
+ var render_height: int = _get_render_height(
+ _effective_pixel_size,
+ reference_scale,
+ )
+ var render_scale: float = (
+ float(render_height)
+ / UIReferencePresentationType.REFERENCE_SIZE.y
+ )
+ var visible_reference_size: Vector2 = (
+ UIReferencePresentationType.get_visible_reference_size(display_size)
+ )
+ var viewport_size := Vector2i(
+ roundi(visible_reference_size.x * render_scale),
+ roundi(visible_reference_size.y * render_scale),
+ )
+ set_anchors_preset(Control.PRESET_TOP_LEFT)
+ position = Vector2.ZERO
+ size = Vector2(viewport_size)
+ scale = Vector2.ONE * (reference_scale / render_scale)
+ stretch_shrink = 1
+ _ui_root.position = Vector2.ZERO
+ _ui_root.scale = Vector2.ONE * render_scale
+ _ui_root.size = visible_reference_size
+ _canonical_stage.position = (
+ UIReferencePresentationType.get_stage_position(display_size)
+ )
+ _canonical_stage.size = UIReferencePresentationType.REFERENCE_SIZE
+ _hotbar.position = Vector2(0.0, _canonical_stage.position.y)
+ _title_content_stage.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ _title_content_stage.position = _canonical_stage.position
+ _title_content_stage.size = UIReferencePresentationType.REFERENCE_SIZE
+ _chat_ui.set_output_scale(reference_scale)
+ if (
+ previous_effective_size != _effective_pixel_size
+ or _requested_pixel_size != _effective_pixel_size
+ ):
+ effective_pixel_size_changed.emit(
+ _requested_pixel_size,
+ _effective_pixel_size
+ )
+
+
+func _get_render_height(pixel_size: int, reference_scale: float) -> int:
+ var displayed_canvas_height: int = maxi(
+ 1,
+ roundi(
+ UIReferencePresentationType.REFERENCE_SIZE.y
+ * reference_scale
+ ),
+ )
+ if pixel_size == PlayerSettings.MIN_UI_PIXEL_SIZE:
+ return displayed_canvas_height
+ var target_height: int = PlayerSettings.get_ui_render_height(
+ pixel_size,
+ roundi(UIReferencePresentationType.REFERENCE_SIZE.y),
+ )
+ return mini(displayed_canvas_height, target_height)
+
+
+func _resolve_effective_pixel_size(reference_scale: float) -> int:
+ for candidate: int in range(_requested_pixel_size, 0, -1):
+ var candidate_height: int = _get_render_height(
+ candidate,
+ reference_scale,
+ )
+ var candidate_size := Vector2i(
+ roundi(
+ UIReferencePresentationType.REFERENCE_SIZE.x
+ * float(candidate_height)
+ / UIReferencePresentationType.REFERENCE_SIZE.y
+ ),
+ candidate_height,
+ )
+ if (
+ candidate_size.x >= MIN_UI_VIEWPORT_SIZE.x
+ and candidate_size.y >= MIN_UI_VIEWPORT_SIZE.y
+ ):
+ return candidate
+ return PlayerSettings.MIN_UI_PIXEL_SIZE
diff --git a/ui/ui_pixelation_presenter.gd.uid b/ui/ui_pixelation_presenter.gd.uid
new file mode 100644
index 0000000..1b390d6
--- /dev/null
+++ b/ui/ui_pixelation_presenter.gd.uid
@@ -0,0 +1 @@
+uid://bdtb05vkiicd
diff --git a/ui/ui_reference_presentation.gd b/ui/ui_reference_presentation.gd
new file mode 100644
index 0000000..e9710a3
--- /dev/null
+++ b/ui/ui_reference_presentation.gd
@@ -0,0 +1,43 @@
+class_name UIReferencePresentation
+extends RefCounted
+
+# All functional UI is authored in this coordinate system and receives one
+# centered, uniform transform at presentation time. Texture source dimensions
+# describe sampling quality and aspect ratio; a canonical Control rect owns
+# every texture's on-screen layout size.
+#
+# Edge-docked overlays use the same canonical units and uniform scale, but may
+# use the extra logical canvas outside the centered stage on non-16:9 windows.
+# This keeps deliberate window-edge attachment without letting individual
+# controls calculate monitor- or DPI-specific geometry.
+const REFERENCE_SIZE: Vector2 = Vector2(1280.0, 720.0)
+
+
+static func get_scale(display_size: Vector2) -> float:
+ return minf(
+ maxf(display_size.x, 1.0) / REFERENCE_SIZE.x,
+ maxf(display_size.y, 1.0) / REFERENCE_SIZE.y,
+ )
+
+
+static func get_offset(display_size: Vector2) -> Vector2:
+ var presentation_scale: float = get_scale(display_size)
+ return (
+ display_size - REFERENCE_SIZE * presentation_scale
+ ) * 0.5
+
+
+static func get_visible_reference_size(display_size: Vector2) -> Vector2:
+ return display_size / get_scale(display_size)
+
+
+static func get_stage_position(display_size: Vector2) -> Vector2:
+ return (get_visible_reference_size(display_size) - REFERENCE_SIZE) * 0.5
+
+
+static func get_rect(display_size: Vector2) -> Rect2:
+ var presentation_scale: float = get_scale(display_size)
+ return Rect2(
+ get_offset(display_size),
+ REFERENCE_SIZE * presentation_scale,
+ )
diff --git a/ui/ui_reference_presentation.gd.uid b/ui/ui_reference_presentation.gd.uid
new file mode 100644
index 0000000..b68d727
--- /dev/null
+++ b/ui/ui_reference_presentation.gd.uid
@@ -0,0 +1 @@
+uid://duv2b6847fb3y
diff --git a/ui/utility_page_style.gd b/ui/utility_page_style.gd
new file mode 100644
index 0000000..9045cbc
--- /dev/null
+++ b/ui/utility_page_style.gd
@@ -0,0 +1,295 @@
+class_name UtilityPageStyle
+extends RefCounted
+
+const TuffyFont: Font = preload("res://ui/fonts/Tuffy_Bold.otf")
+const PAPER: Color = Color("eee2bd")
+const PAPER_ALT: Color = Color("f3ecd7")
+const INK: Color = Color("28251f")
+const MUTED_INK: Color = Color("5f5547")
+const BORDER: Color = Color("473d2e")
+# PAPER/INK remain available only for intentionally paper-like content such as
+# a letter body. Technical surfaces use this shared ocean palette.
+const OCEAN_PANEL_DEEP: Color = Color("0d2c3a")
+const OCEAN_PANEL_MID: Color = Color("123f4e")
+const OCEAN_FIELD: Color = Color("081f2b")
+const OCEAN_BUTTON: Color = Color("123f4e")
+const OCEAN_BUTTON_HOVER: Color = Color("196072")
+const OCEAN_BUTTON_PRESSED: Color = Color("0b3341")
+const OCEAN_SELECTED: Color = Color("238697")
+const OCEAN_TEXT_PRIMARY: Color = Color("e6f7f7")
+const OCEAN_TEXT_SECONDARY: Color = Color("9fcfd2")
+const OCEAN_DISABLED: Color = Color("38535b")
+const OCEAN_DANGER: Color = Color("9e4550")
+const NAVY: Color = Color("092b3d")
+const NAVY_HOVER: Color = Color("12465b")
+const GREEN: Color = Color("31594d")
+const LIGHT_TEXT: Color = Color("f5eed9")
+const DISABLED_TEXT: Color = Color(0.33, 0.36, 0.35, 0.72)
+const MOTION_TWEEN_META: StringName = &"utility_page_motion_tween"
+const LAPTOP_RECT: Rect2 = Rect2(66.0, 132.0, 1148.0, 520.0)
+
+
+static func apply_page(root: Control) -> void:
+ root.add_theme_font_override("font", TuffyFont)
+
+
+static func rounded_style(color: Color, radius: int) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = color
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(radius)
+ return style
+
+
+static func build_laptop_screen(
+ root: Control,
+ laptop_rect: Rect2 = LAPTOP_RECT,
+) -> MarginContainer:
+ var laptop := PanelContainer.new()
+ laptop.position = laptop_rect.position
+ laptop.size = laptop_rect.size
+ laptop.add_theme_stylebox_override(
+ "panel", rounded_style(OCEAN_PANEL_MID, 24)
+ )
+ root.add_child(laptop)
+
+ var shell_margin := MarginContainer.new()
+ shell_margin.add_theme_constant_override("margin_left", 20)
+ shell_margin.add_theme_constant_override("margin_top", 18)
+ shell_margin.add_theme_constant_override("margin_right", 20)
+ shell_margin.add_theme_constant_override("margin_bottom", 24)
+ laptop.add_child(shell_margin)
+
+ var screen := PanelContainer.new()
+ screen.add_theme_stylebox_override(
+ "panel", rounded_style(OCEAN_FIELD, 16)
+ )
+ shell_margin.add_child(screen)
+
+ var content_margin := MarginContainer.new()
+ content_margin.add_theme_constant_override("margin_left", 24)
+ content_margin.add_theme_constant_override("margin_top", 18)
+ content_margin.add_theme_constant_override("margin_right", 24)
+ content_margin.add_theme_constant_override("margin_bottom", 18)
+ screen.add_child(content_margin)
+
+ var base := ColorRect.new()
+ base.position = Vector2(
+ laptop_rect.position.x + 82.0,
+ laptop_rect.end.y - 6.0,
+ )
+ base.size = Vector2(laptop_rect.size.x - 164.0, 14.0)
+ base.color = OCEAN_PANEL_MID
+ base.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ root.add_child(base)
+ return content_margin
+
+
+static func panel_style() -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = OCEAN_PANEL_DEEP
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(16)
+ style.content_margin_left = 26
+ style.content_margin_right = 26
+ style.content_margin_top = 20
+ style.content_margin_bottom = 20
+ return style
+
+
+static func row_style(selected: bool = false) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = OCEAN_SELECTED if selected else OCEAN_PANEL_MID
+ style.set_border_width_all(0)
+ style.set_corner_radius_all(8)
+ style.content_margin_left = 12
+ style.content_margin_right = 12
+ style.content_margin_top = 8
+ style.content_margin_bottom = 8
+ return style
+
+
+static func button_style(color: Color) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = color
+ style.border_color = Color(0.76, 0.86, 0.78, 0.72)
+ style.set_border_width_all(2)
+ style.set_corner_radius_all(7)
+ style.content_margin_left = 14
+ style.content_margin_right = 14
+ style.content_margin_top = 8
+ style.content_margin_bottom = 8
+ return style
+
+
+static func apply_button(button: BaseButton) -> void:
+ button.add_theme_font_override("font", TuffyFont)
+ button.add_theme_color_override("font_color", LIGHT_TEXT)
+ button.add_theme_color_override("font_hover_color", Color.WHITE)
+ button.add_theme_color_override("font_pressed_color", Color.WHITE)
+ button.add_theme_color_override("font_focus_color", Color.WHITE)
+ button.add_theme_color_override("font_disabled_color", DISABLED_TEXT)
+ button.add_theme_stylebox_override("normal", button_style(NAVY))
+ button.add_theme_stylebox_override("hover", button_style(NAVY_HOVER))
+ button.add_theme_stylebox_override("pressed", button_style(GREEN))
+ button.add_theme_stylebox_override("focus", button_style(NAVY_HOVER))
+ button.add_theme_stylebox_override(
+ "disabled", button_style(Color(0.37, 0.42, 0.40, 0.55))
+ )
+ button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, 40.0)
+
+
+static func ocean_button_style(color: Color) -> StyleBoxFlat:
+ var style := button_style(color)
+ style.set_border_width_all(0)
+ return style
+
+
+static func apply_ocean_button(button: BaseButton) -> void:
+ button.add_theme_font_override("font", TuffyFont)
+ button.add_theme_color_override("font_color", OCEAN_TEXT_PRIMARY)
+ button.add_theme_color_override("font_hover_color", Color.WHITE)
+ button.add_theme_color_override("font_pressed_color", Color.WHITE)
+ button.add_theme_color_override("font_focus_color", Color.WHITE)
+ button.add_theme_color_override(
+ "font_disabled_color",
+ Color(OCEAN_TEXT_SECONDARY, 0.52),
+ )
+ button.add_theme_stylebox_override(
+ "normal", ocean_button_style(OCEAN_BUTTON)
+ )
+ button.add_theme_stylebox_override(
+ "hover", ocean_button_style(OCEAN_BUTTON_HOVER)
+ )
+ button.add_theme_stylebox_override(
+ "pressed", ocean_button_style(OCEAN_SELECTED)
+ )
+ button.add_theme_stylebox_override(
+ "focus", ocean_button_style(OCEAN_SELECTED)
+ )
+ button.add_theme_stylebox_override(
+ "disabled", ocean_button_style(OCEAN_DISABLED)
+ )
+ button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, 40.0)
+
+
+static func apply_compact_ocean_button(button: BaseButton) -> void:
+ apply_ocean_button(button)
+ for state: StringName in [
+ &"normal", &"hover", &"pressed", &"focus", &"disabled",
+ ]:
+ var style := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat
+ if style == null:
+ continue
+ style.content_margin_left = 10.0
+ style.content_margin_right = 10.0
+ style.content_margin_top = 4.0
+ style.content_margin_bottom = 4.0
+ button.add_theme_stylebox_override(state, style)
+ button.custom_minimum_size.y = 34.0
+
+
+static func apply_line_edit(edit: LineEdit) -> void:
+ edit.add_theme_font_override("font", TuffyFont)
+ edit.add_theme_color_override("font_color", LIGHT_TEXT)
+ edit.add_theme_color_override(
+ "font_placeholder_color", Color(0.82, 0.80, 0.71, 0.84)
+ )
+ edit.add_theme_color_override("font_selected_color", Color.WHITE)
+ edit.add_theme_color_override("caret_color", Color("fff0b6"))
+ edit.add_theme_color_override("selection_color", Color("2f7186"))
+ edit.add_theme_color_override("font_uneditable_color", Color("c9c2ae"))
+ edit.add_theme_stylebox_override("normal", button_style(NAVY))
+ edit.add_theme_stylebox_override("focus", button_style(NAVY_HOVER))
+ edit.add_theme_stylebox_override(
+ "read_only", button_style(Color(NAVY, 0.82))
+ )
+
+
+static func apply_ocean_line_edit(edit: LineEdit) -> void:
+ edit.add_theme_font_override("font", TuffyFont)
+ edit.add_theme_color_override("font_color", OCEAN_TEXT_PRIMARY)
+ edit.add_theme_color_override(
+ "font_placeholder_color", Color(OCEAN_TEXT_SECONDARY, 0.78)
+ )
+ edit.add_theme_color_override("font_selected_color", Color.WHITE)
+ edit.add_theme_color_override("caret_color", OCEAN_TEXT_PRIMARY)
+ edit.add_theme_color_override("selection_color", OCEAN_SELECTED)
+ edit.add_theme_color_override("font_uneditable_color", OCEAN_TEXT_SECONDARY)
+ edit.add_theme_stylebox_override(
+ "normal", ocean_button_style(OCEAN_FIELD)
+ )
+ edit.add_theme_stylebox_override(
+ "focus", ocean_button_style(OCEAN_SELECTED)
+ )
+ edit.add_theme_stylebox_override(
+ "read_only", ocean_button_style(Color(OCEAN_FIELD, 0.82))
+ )
+
+
+static func apply_text_edit(edit: TextEdit) -> void:
+ edit.add_theme_font_override("font", TuffyFont)
+ edit.add_theme_color_override("font_color", OCEAN_TEXT_PRIMARY)
+ edit.add_theme_color_override(
+ "font_placeholder_color", Color(OCEAN_TEXT_SECONDARY, 0.78)
+ )
+ edit.add_theme_color_override("font_selected_color", Color.WHITE)
+ edit.add_theme_color_override("caret_color", OCEAN_TEXT_PRIMARY)
+ edit.add_theme_color_override("selection_color", OCEAN_SELECTED)
+ edit.add_theme_stylebox_override("normal", ocean_button_style(OCEAN_FIELD))
+ edit.add_theme_stylebox_override("focus", ocean_button_style(OCEAN_SELECTED))
+
+
+static func animate_in(control: Control) -> void:
+ _cancel_motion(control)
+ control.pivot_offset = control.size * 0.5
+ control.scale = Vector2.ONE * UIMotion.UTILITY_ENTER_SCALE
+ control.modulate.a = 0.0
+ control.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ var tween := control.create_tween()
+ control.set_meta(MOTION_TWEEN_META, tween)
+ tween.set_parallel(true)
+ tween.tween_property(
+ control, "scale", Vector2.ONE, UIMotion.UTILITY_ENTER_DURATION
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ tween.tween_property(
+ control, "modulate:a", 1.0, UIMotion.UTILITY_ENTER_DURATION
+ )
+ tween.finished.connect(func() -> void:
+ if is_instance_valid(control):
+ control.mouse_filter = Control.MOUSE_FILTER_PASS
+ control.remove_meta(MOTION_TWEEN_META)
+ )
+
+
+static func animate_out(control: Control, completed: Callable) -> void:
+ _cancel_motion(control)
+ control.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ control.pivot_offset = control.size * 0.5
+ var tween := control.create_tween()
+ control.set_meta(MOTION_TWEEN_META, tween)
+ tween.set_parallel(true)
+ tween.tween_property(
+ control,
+ "scale",
+ Vector2.ONE * UIMotion.UTILITY_ENTER_SCALE,
+ UIMotion.UTILITY_EXIT_DURATION
+ ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ tween.tween_property(
+ control, "modulate:a", 0.0, UIMotion.UTILITY_EXIT_DURATION
+ )
+ if completed.is_valid():
+ tween.finished.connect(completed, CONNECT_ONE_SHOT)
+ tween.finished.connect(func() -> void:
+ if is_instance_valid(control):
+ control.remove_meta(MOTION_TWEEN_META)
+ )
+
+
+static func _cancel_motion(control: Control) -> void:
+ if not control.has_meta(MOTION_TWEEN_META):
+ return
+ var existing := control.get_meta(MOTION_TWEEN_META) as Tween
+ if existing != null and existing.is_valid():
+ existing.kill()
+ control.remove_meta(MOTION_TWEEN_META)
diff --git a/ui/utility_page_style.gd.uid b/ui/utility_page_style.gd.uid
new file mode 100644
index 0000000..a255b79
--- /dev/null
+++ b/ui/utility_page_style.gd.uid
@@ -0,0 +1 @@
+uid://bmfqwan211x0j
diff --git a/ui/weather_icon.gd b/ui/weather_icon.gd
new file mode 100644
index 0000000..b44bc9b
--- /dev/null
+++ b/ui/weather_icon.gd
@@ -0,0 +1,81 @@
+class_name WeatherIcon
+extends Control
+
+const CLEAR_DAY_TEXTURE: Texture2D = preload(
+ "res://ui/icons/weather/weather_clear_day.png"
+)
+const CLEAR_NIGHT_TEXTURE: Texture2D = preload(
+ "res://ui/icons/weather/weather_clear_night_full.png"
+)
+const CLOUDY_TEXTURE: Texture2D = preload(
+ "res://ui/icons/weather/weather_cloudy.png"
+)
+const FOG_TEXTURE: Texture2D = preload(
+ "res://ui/icons/weather/weather_fog.png"
+)
+const RAIN_TEXTURE: Texture2D = preload(
+ "res://ui/icons/weather/weather_rain.png"
+)
+
+var _weather: WorldWeatherService.Weather = WorldWeatherService.Weather.SUNNY
+var _is_nighttime: bool = false
+
+
+func _ready() -> void:
+ mouse_filter = Control.MOUSE_FILTER_PASS
+ focus_mode = Control.FOCUS_NONE
+ texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
+ custom_minimum_size = Vector2(51.0, 51.0)
+ _update_tooltip()
+ queue_redraw()
+
+
+func set_weather(weather: WorldWeatherService.Weather) -> void:
+ if _weather == weather:
+ _update_tooltip()
+ return
+ _weather = weather
+ _update_tooltip()
+ queue_redraw()
+
+
+func set_nighttime(nighttime: bool) -> void:
+ if _is_nighttime == nighttime:
+ return
+ _is_nighttime = nighttime
+ queue_redraw()
+
+
+func is_nighttime() -> bool:
+ return _is_nighttime
+
+
+func get_weather() -> WorldWeatherService.Weather:
+ return _weather
+
+
+func _draw() -> void:
+ var icon_size := Vector2.ONE * minf(size.x, size.y)
+ var icon_rect := Rect2((size - icon_size) * 0.5, icon_size)
+ draw_texture_rect(_get_weather_texture(), icon_rect, false)
+
+
+func _get_weather_texture() -> Texture2D:
+ match _weather:
+ WorldWeatherService.Weather.SUNNY:
+ return CLEAR_NIGHT_TEXTURE if _is_nighttime else CLEAR_DAY_TEXTURE
+ WorldWeatherService.Weather.CLOUDY:
+ return CLOUDY_TEXTURE
+ WorldWeatherService.Weather.RAINY:
+ return RAIN_TEXTURE
+ WorldWeatherService.Weather.FOGGY:
+ return FOG_TEXTURE
+ return CLEAR_DAY_TEXTURE
+
+
+func _update_tooltip() -> void:
+ tooltip_text = (
+ "clear"
+ if _weather == WorldWeatherService.Weather.SUNNY
+ else WorldWeatherService.weather_name(_weather)
+ )
diff --git a/ui/weather_icon.gd.uid b/ui/weather_icon.gd.uid
new file mode 100644
index 0000000..0be0e28
--- /dev/null
+++ b/ui/weather_icon.gd.uid
@@ -0,0 +1 @@
+uid://crcognj0s8ntq
diff --git a/world/README-WORLD-GRAYBOX.md b/world/README-WORLD-GRAYBOX.md
deleted file mode 100644
index 3cee61e..0000000
--- a/world/README-WORLD-GRAYBOX.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# NETFISHING test world
-
-`world/test_world.tscn` remains the active world composition. It currently
-instances `world/regions/starter_island_region.tscn`, a Godot-owned gameplay
-wrapper around the human-authored starter-island GLB.
-
-## Starter-island ownership
-
-```text
-TestWorld
-├── Environment
-├── Regions
-│ └── StarterIslandRegion
-│ ├── Visuals
-│ │ ├── StarterIslandModel
-│ │ ├── OceanWater
-│ │ └── PondWater
-│ ├── StaticCollision
-│ ├── FishingWater
-│ ├── WaterRecovery
-│ ├── SafeRespawns
-│ ├── PlayerSpawn
-│ └── Interactables
-├── Safety
-└── WorldBounds
-```
-
-The imported GLB is visual source content and remains at identity scale. The
-wrapper creates concave static collision from its explicit mesh reference and
-owns all water, fishing, recovery, spawn, and service placement.
-
-Move the complete `StarterIslandRegion` root to keep visuals and gameplay
-volumes synchronized. When editing inside the wrapper, keep each water mesh
-aligned with its matching `FishingWater` and `WaterRecovery` shapes. Safe
-respawns and `PlayerSpawn` must remain on broad upper terrain.
-
-Rename-sensitive paths:
-
-- `TestWorld/Regions`
-- `TestWorld/Regions/StarterIslandRegion`
-- the NodePaths exported by `StarterIslandRegion`
-- each region's `FishingWater`, `WaterRecovery`, and `SafeRespawns` roots
-
-## Reverting to the modular graybox
-
-The previous region scenes remain unchanged under `world/regions/`. To restore
-the prior holdover world, restore `world/test_world.tscn` and
-`world/test_world.gd` from commit `633bbd9`, then remove the player-spawn
-composition line added to `main/main.gd`. That composition instances:
-
-- `village_region.tscn`
-- `starter_pond_region.tscn`
-- `river_region.tscn`
-- `lake_region.tscn`
-- `marsh_region.tscn`
-- `coastal_dock_region.tscn`
-
-No region asset needs to be reconstructed or recovered from deleted content.
-
-## Water and recovery
-
-The pond and surrounding ocean remain separate fishable regions using the
-existing test pool. Ocean fishing and recovery use four collision shapes so
-the raised island interior is not classified as ocean. Regional recovery is
-supplemented by `TestWorld/Safety/BelowWorldFailsafe`.
-
-The pond surface is at `y=2.10`. The ocean is at `y=-0.45`, between the
-authored upper beach and submerged shelf rings. Fishable regions explicitly
-provide their surface heights to the shared fishing presentation.
-
-The Fishing Shop remains a complete instanced interactable. The Pelican perch
-remains convenience signage only; Pelican selling is available through the
-Cooler from anywhere.
diff --git a/world/below_world_failsafe.gd b/world/below_world_failsafe.gd
new file mode 100644
index 0000000..2c6bea0
--- /dev/null
+++ b/world/below_world_failsafe.gd
@@ -0,0 +1,24 @@
+@tool
+class_name BelowWorldFailsafe
+extends PlayerWaterTrigger
+
+@export_group("Failsafe Authoring")
+## Saved coverage volume that catches players below the playable world.
+@export_node_path("CollisionShape3D")
+var coverage_shape_path: NodePath = ^"Coverage"
+
+
+func _ready() -> void:
+ super()
+ if Engine.is_editor_hint():
+ update_configuration_warnings()
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings := PackedStringArray()
+ var coverage := get_node_or_null(coverage_shape_path) as CollisionShape3D
+ if coverage == null:
+ warnings.append("Add the saved Coverage collision shape.")
+ elif coverage.shape == null:
+ warnings.append("Assign a shape to the failsafe Coverage node.")
+ return warnings
diff --git a/world/below_world_failsafe.gd.uid b/world/below_world_failsafe.gd.uid
new file mode 100644
index 0000000..1c2e4d7
--- /dev/null
+++ b/world/below_world_failsafe.gd.uid
@@ -0,0 +1 @@
+uid://b71576njgcgyw
diff --git a/world/environment/local_storm_cloud.gdshader b/world/environment/local_storm_cloud.gdshader
new file mode 100644
index 0000000..b5ec03b
--- /dev/null
+++ b/world/environment/local_storm_cloud.gdshader
@@ -0,0 +1,37 @@
+shader_type spatial;
+render_mode blend_mix, depth_prepass_alpha, cull_disabled, unshaded, fog_disabled, shadows_disabled;
+
+uniform vec4 cloud_color : source_color = vec4(0.07, 0.10, 0.12, 1.0);
+uniform float weather_opacity : hint_range(0.0, 1.0, 0.01) = 0.0;
+uniform float maximum_opacity : hint_range(0.0, 1.0, 0.01) = 0.5;
+uniform float distance_fade_start : hint_range(50.0, 500.0, 1.0) = 240.0;
+uniform float distance_fade_end : hint_range(50.0, 500.0, 1.0) = 300.0;
+
+varying float world_normal_height;
+varying float camera_distance;
+
+
+void vertex() {
+ world_normal_height = normalize(MODEL_NORMAL_MATRIX * NORMAL).y;
+ vec3 world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+ camera_distance = distance(
+ world_position.xz,
+ CAMERA_POSITION_WORLD.xz
+ );
+}
+
+
+void fragment() {
+ float facet_light = mix(
+ 0.68,
+ 1.06,
+ clamp(world_normal_height * 0.5 + 0.5, 0.0, 1.0)
+ );
+ ALBEDO = cloud_color.rgb * facet_light;
+ float distance_fade = 1.0 - smoothstep(
+ distance_fade_start,
+ distance_fade_end,
+ camera_distance
+ );
+ ALPHA = weather_opacity * maximum_opacity * distance_fade * cloud_color.a;
+}
diff --git a/world/environment/local_storm_cloud.gdshader.uid b/world/environment/local_storm_cloud.gdshader.uid
new file mode 100644
index 0000000..b07fd92
--- /dev/null
+++ b/world/environment/local_storm_cloud.gdshader.uid
@@ -0,0 +1 @@
+uid://c20wlon7hc48g
diff --git a/world/environment/local_storm_cloud_layer.gd b/world/environment/local_storm_cloud_layer.gd
new file mode 100644
index 0000000..2ec0008
--- /dev/null
+++ b/world/environment/local_storm_cloud_layer.gd
@@ -0,0 +1,228 @@
+class_name LocalStormCloudLayer
+extends Node3D
+
+const CLOUD_SHADER: Shader = preload(
+ "res://world/environment/local_storm_cloud.gdshader"
+)
+const MIN_CLOUD_ALTITUDE: float = 15.0
+const MAX_CLOUD_ALTITUDE: float = 23.0
+const GRID_AXIS_COUNT: int = 9
+const GRID_SPACING: float = 70.0
+const FIELD_HALF_EXTENT: float = GRID_AXIS_COUNT * GRID_SPACING * 0.5
+const DISTANCE_FADE_START: float = 240.0
+const DISTANCE_FADE_END: float = 300.0
+const WRAP_RADIUS: float = FIELD_HALF_EXTENT
+const MAXIMUM_OPACITY: float = 1.0
+const BASE_VELOCITY := Vector2(0.825, -0.45)
+const BODY_OUTLINE: Array[Vector2] = [
+ Vector2(-6.9, -0.8),
+ Vector2(-6.6, -2.0),
+ Vector2(-5.7, -3.0),
+ Vector2(-4.5, -3.5),
+ Vector2(-3.0, -3.7),
+ Vector2(-1.8, -4.2),
+ Vector2(-0.5, -4.3),
+ Vector2(0.7, -4.0),
+ Vector2(2.0, -4.1),
+ Vector2(3.0, -3.5),
+ Vector2(4.6, -3.3),
+ Vector2(5.8, -2.5),
+ Vector2(6.6, -1.5),
+ Vector2(7.0, -0.2),
+ Vector2(6.8, 1.0),
+ Vector2(6.1, 1.9),
+ Vector2(6.0, 2.9),
+ Vector2(5.1, 3.7),
+ Vector2(3.8, 4.0),
+ Vector2(2.7, 4.7),
+ Vector2(1.4, 4.9),
+ Vector2(0.2, 5.3),
+ Vector2(-1.2, 5.0),
+ Vector2(-2.1, 4.4),
+ Vector2(-3.6, 4.2),
+ Vector2(-4.9, 3.5),
+ Vector2(-5.4, 2.5),
+ Vector2(-6.3, 1.7),
+ Vector2(-6.8, 0.6),
+]
+
+var _target: Node3D
+var _material: ShaderMaterial
+var _multimesh: MultiMesh
+var _cloud_mesh: MultiMeshInstance3D
+var _drift_offset := Vector2.ZERO
+var _storm_amount: float = 0.0
+
+
+func setup(target: Node3D) -> void:
+ _target = target
+ _build_cloud_field()
+ _update_cloud_transforms()
+ set_process(true)
+
+
+func set_storm_amount(amount: float, color: Color) -> void:
+ _storm_amount = clampf(amount, 0.0, 1.0)
+ var should_be_visible: bool = _storm_amount > 0.001
+ if should_be_visible and not visible:
+ _update_cloud_transforms()
+ visible = should_be_visible
+ if _material == null:
+ return
+ _material.set_shader_parameter("weather_opacity", _storm_amount)
+ _material.set_shader_parameter("cloud_color", color)
+
+
+func get_storm_amount() -> float:
+ return _storm_amount
+
+
+func get_patch_count() -> int:
+ return GRID_AXIS_COUNT * GRID_AXIS_COUNT
+
+
+func _process(delta: float) -> void:
+ if not visible or _target == null or not is_instance_valid(_target):
+ return
+ _drift_offset += BASE_VELOCITY * delta
+ _drift_offset = Vector2(
+ _wrap_axis(_drift_offset.x),
+ _wrap_axis(_drift_offset.y),
+ )
+ _update_cloud_transforms()
+
+
+func _build_cloud_field() -> void:
+ _material = ShaderMaterial.new()
+ _material.shader = CLOUD_SHADER
+ _material.set_shader_parameter("weather_opacity", 0.0)
+ _material.set_shader_parameter("maximum_opacity", MAXIMUM_OPACITY)
+ _material.set_shader_parameter("distance_fade_start", DISTANCE_FADE_START)
+ _material.set_shader_parameter("distance_fade_end", DISTANCE_FADE_END)
+
+ var cloud_body_mesh: ArrayMesh = _build_cloud_body_mesh()
+ cloud_body_mesh.surface_set_material(0, _material)
+
+ _multimesh = MultiMesh.new()
+ _multimesh.transform_format = MultiMesh.TRANSFORM_3D
+ _multimesh.mesh = cloud_body_mesh
+ _multimesh.instance_count = get_patch_count()
+ _multimesh.custom_aabb = AABB(
+ Vector3(
+ -FIELD_HALF_EXTENT - 60.0,
+ MIN_CLOUD_ALTITUDE - 5.0,
+ -FIELD_HALF_EXTENT - 60.0,
+ ),
+ Vector3(
+ (FIELD_HALF_EXTENT + 60.0) * 2.0,
+ MAX_CLOUD_ALTITUDE - MIN_CLOUD_ALTITUDE + 10.0,
+ (FIELD_HALF_EXTENT + 60.0) * 2.0,
+ ),
+ )
+
+ _cloud_mesh = MultiMeshInstance3D.new()
+ _cloud_mesh.name = "CloudField"
+ _cloud_mesh.multimesh = _multimesh
+ _cloud_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
+ add_child(_cloud_mesh)
+ visible = false
+
+
+func _build_cloud_body_mesh() -> ArrayMesh:
+ var outline := PackedVector2Array(BODY_OUTLINE)
+ var surface_tool := SurfaceTool.new()
+ surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES)
+ var face_indices: PackedInt32Array = Geometry2D.triangulate_polygon(
+ outline
+ )
+ for triangle_offset: int in range(0, face_indices.size(), 3):
+ var point_a: Vector2 = outline[face_indices[triangle_offset]]
+ var point_b: Vector2 = outline[face_indices[triangle_offset + 1]]
+ var point_c: Vector2 = outline[face_indices[triangle_offset + 2]]
+ _add_oriented_triangle(
+ surface_tool,
+ _to_cloud_vertex(point_a),
+ _to_cloud_vertex(point_b),
+ _to_cloud_vertex(point_c),
+ Vector3.DOWN,
+ )
+ surface_tool.index()
+ return surface_tool.commit()
+
+
+func _to_cloud_vertex(point: Vector2) -> Vector3:
+ return Vector3(point.x, 0.0, point.y)
+
+
+func _add_oriented_triangle(
+ surface_tool: SurfaceTool,
+ point_a: Vector3,
+ point_b: Vector3,
+ point_c: Vector3,
+ expected_normal: Vector3,
+) -> void:
+ if (point_b - point_a).cross(point_c - point_a).dot(expected_normal) < 0.0:
+ var swap: Vector3 = point_b
+ point_b = point_c
+ point_c = swap
+ for point: Vector3 in [point_a, point_b, point_c]:
+ surface_tool.set_normal(expected_normal)
+ surface_tool.add_vertex(point)
+
+
+func _update_cloud_transforms() -> void:
+ if _target == null or _multimesh == null:
+ return
+ global_position = _target.global_position
+ var target_xz := Vector2(
+ _target.global_position.x,
+ _target.global_position.z,
+ )
+ var grid_center: float = float(GRID_AXIS_COUNT - 1) * 0.5
+ var instance_index: int = 0
+ for grid_z: int in GRID_AXIS_COUNT:
+ for grid_x: int in GRID_AXIS_COUNT:
+ var patch_index: int = grid_z * GRID_AXIS_COUNT + grid_x
+ var world_offset := Vector2(
+ (float(grid_x) - grid_center) * GRID_SPACING,
+ (float(grid_z) - grid_center) * GRID_SPACING,
+ ) + _drift_offset
+ var relative := Vector2(
+ _wrap_axis(world_offset.x - target_xz.x),
+ _wrap_axis(world_offset.y - target_xz.y),
+ )
+ var altitude: float = lerpf(
+ MIN_CLOUD_ALTITUDE,
+ MAX_CLOUD_ALTITUDE,
+ float(patch_index % 4) / 3.0,
+ )
+ var patch_scale: float = 2.20 + float(
+ (patch_index * 7) % 10
+ ) * 0.10
+ var width_variation: float = 0.90 + float(
+ (patch_index * 5) % 7
+ ) * 0.04
+ var depth_variation: float = 0.90 + float(
+ (patch_index * 3 + 2) % 7
+ ) * 0.04
+ var patch_basis := Basis(
+ Vector3.UP,
+ deg_to_rad(float(patch_index * 17 % 41) - 20.0),
+ ).scaled(Vector3(
+ patch_scale * 2.35 * width_variation,
+ patch_scale * 0.34,
+ patch_scale * 2.65 * depth_variation,
+ ))
+ _multimesh.set_instance_transform(
+ instance_index,
+ Transform3D(
+ patch_basis,
+ Vector3(relative.x, altitude, relative.y),
+ ),
+ )
+ instance_index += 1
+
+
+func _wrap_axis(value: float) -> float:
+ return fposmod(value + FIELD_HALF_EXTENT, FIELD_HALF_EXTENT * 2.0) \
+ - FIELD_HALF_EXTENT
diff --git a/world/environment/local_storm_cloud_layer.gd.uid b/world/environment/local_storm_cloud_layer.gd.uid
new file mode 100644
index 0000000..d274b64
--- /dev/null
+++ b/world/environment/local_storm_cloud_layer.gd.uid
@@ -0,0 +1 @@
+uid://krinusur24lh
diff --git a/world/environment/netfishing_environment.tres b/world/environment/netfishing_environment.tres
new file mode 100644
index 0000000..4f625e4
--- /dev/null
+++ b/world/environment/netfishing_environment.tres
@@ -0,0 +1,25 @@
+[gd_resource type="Environment" load_steps=2 format=3]
+
+[ext_resource type="Sky" path="res://world/environment/netfishing_sky.tres" id="1_sky"]
+
+[resource]
+background_mode = 2
+sky = ExtResource("1_sky")
+background_energy_multiplier = 0.85
+ambient_light_source = 2
+ambient_light_color = Color(0.82, 0.9, 0.92, 1)
+ambient_light_energy = 1.08
+reflected_light_source = 2
+fog_enabled = false
+fog_mode = 1
+fog_light_color = Color(0.54902, 0.74902, 0.764706, 1)
+fog_light_energy = 0.82
+fog_aerial_perspective = 0.35
+fog_sky_affect = 0.35
+fog_depth_curve = 1.6
+fog_depth_begin = 42.0
+fog_depth_end = 170.0
+adjustment_enabled = true
+adjustment_brightness = 0.98
+adjustment_contrast = 0.85
+adjustment_saturation = 0.91
diff --git a/world/environment/netfishing_sky.gdshader b/world/environment/netfishing_sky.gdshader
new file mode 100644
index 0000000..07d3c53
--- /dev/null
+++ b/world/environment/netfishing_sky.gdshader
@@ -0,0 +1,101 @@
+shader_type sky;
+
+#include "res://world/environment/netfishing_sky_clouds.gdshaderinc"
+
+uniform vec4 sky_top_color : source_color = vec4(0.204, 0.498, 0.643, 1.0);
+uniform vec4 sky_horizon_color : source_color = vec4(0.663, 0.843, 0.847, 1.0);
+uniform vec4 ground_bottom_color : source_color = vec4(0.157, 0.361, 0.439, 1.0);
+uniform vec4 ground_horizon_color : source_color = vec4(0.549, 0.749, 0.765, 1.0);
+uniform float horizon_blend_width : hint_range(0.005, 0.15, 0.005) = 0.01;
+uniform vec4 fog_horizon_color : source_color = vec4(0.13, 0.21, 0.32, 1.0);
+uniform float fog_horizon_occlusion : hint_range(0.0, 1.0) = 0.0;
+
+uniform vec3 sun_direction = vec3(0.0, 1.0, 0.0);
+uniform vec4 sun_color : source_color = vec4(1.0, 0.86, 0.46, 1.0);
+uniform float sun_visibility : hint_range(0.0, 1.0) = 1.0;
+uniform float sun_radius : hint_range(0.005, 0.15) = 0.055;
+uniform float sun_edge_softness : hint_range(0.001, 0.03) = 0.003;
+uniform float sun_halo_radius : hint_range(0.02, 0.35) = 0.14;
+uniform float sun_halo_strength : hint_range(0.0, 1.0) = 0.16;
+
+uniform vec3 moon_direction = vec3(0.0, -1.0, 0.0);
+uniform vec4 moon_color : source_color = vec4(0.78, 0.88, 1.0, 1.0);
+uniform float moon_visibility : hint_range(0.0, 1.0) = 0.0;
+uniform float moon_radius : hint_range(0.005, 0.15) = 0.045;
+uniform float moon_edge_softness : hint_range(0.001, 0.03) = 0.003;
+uniform float moon_halo_radius : hint_range(0.02, 0.35) = 0.11;
+uniform float moon_halo_strength : hint_range(0.0, 1.0) = 0.12;
+
+
+void sky() {
+ vec3 view_direction = normalize(EYEDIR);
+ float above_horizon = max(view_direction.y, 0.0);
+ float below_horizon = max(-view_direction.y, 0.0);
+ vec3 sky_color = mix(
+ sky_horizon_color.rgb,
+ sky_top_color.rgb,
+ pow(above_horizon, 0.72)
+ );
+ vec3 ground_color = mix(
+ ground_horizon_color.rgb,
+ ground_bottom_color.rgb,
+ pow(below_horizon, 0.16)
+ );
+ vec3 base_color = mix(
+ ground_color,
+ sky_color,
+ smoothstep(
+ -horizon_blend_width,
+ horizon_blend_width,
+ view_direction.y
+ )
+ );
+
+ float alignment = dot(view_direction, normalize(sun_direction));
+ float disc_outer = cos(sun_radius + sun_edge_softness);
+ float disc_inner = cos(sun_radius);
+ float halo_outer = cos(sun_halo_radius);
+ float sun_disc = smoothstep(disc_outer, disc_inner, alignment);
+ float sun_halo = smoothstep(halo_outer, disc_outer, alignment);
+ float horizon_visibility = smoothstep(-0.035, 0.02, sun_direction.y);
+ float visible_strength = sun_visibility * horizon_visibility;
+ base_color += sun_color.rgb * sun_halo * sun_halo_strength * visible_strength;
+ base_color = mix(base_color, sun_color.rgb, sun_disc * visible_strength);
+
+ float moon_alignment = dot(view_direction, normalize(moon_direction));
+ float moon_disc_outer = cos(moon_radius + moon_edge_softness);
+ float moon_disc_inner = cos(moon_radius);
+ float moon_halo_outer = cos(moon_halo_radius);
+ float moon_disc = smoothstep(
+ moon_disc_outer, moon_disc_inner, moon_alignment
+ );
+ float moon_halo = smoothstep(
+ moon_halo_outer, moon_disc_outer, moon_alignment
+ );
+ float moon_horizon_visibility = smoothstep(
+ -0.035, 0.02, moon_direction.y
+ );
+ float moon_visible_strength = (
+ moon_visibility * moon_horizon_visibility
+ );
+ base_color += (
+ moon_color.rgb
+ * moon_halo
+ * moon_halo_strength
+ * moon_visible_strength
+ );
+ base_color = mix(
+ base_color, moon_color.rgb, moon_disc * moon_visible_strength
+ );
+ base_color = apply_weather_clouds(base_color, view_direction);
+ // Compatibility fog does not fully erase the color split between a custom
+ // sky's upper and lower hemispheres. Dense low-light fog supplies one
+ // uniform sky color here so that split cannot masquerade as an ocean edge.
+ base_color = mix(
+ base_color,
+ fog_horizon_color.rgb,
+ fog_horizon_occlusion
+ );
+
+ COLOR = base_color;
+}
diff --git a/world/environment/netfishing_sky.gdshader.uid b/world/environment/netfishing_sky.gdshader.uid
new file mode 100644
index 0000000..784558b
--- /dev/null
+++ b/world/environment/netfishing_sky.gdshader.uid
@@ -0,0 +1 @@
+uid://chbcdlhui3ro4
diff --git a/world/environment/netfishing_sky.tres b/world/environment/netfishing_sky.tres
new file mode 100644
index 0000000..f94a6e4
--- /dev/null
+++ b/world/environment/netfishing_sky.tres
@@ -0,0 +1,36 @@
+[gd_resource type="Sky" format=3 uid="uid://dersyay71dyat"]
+
+[ext_resource type="Shader" uid="uid://chbcdlhui3ro4" path="res://world/environment/netfishing_sky.gdshader" id="1_sky_shader"]
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_netfishing"]
+shader = ExtResource("1_sky_shader")
+shader_parameter/cloud_coverage = 0.0
+shader_parameter/cloud_opacity = 0.0
+shader_parameter/cloud_light_color = Color(0.7, 0.76, 0.77, 1)
+shader_parameter/cloud_shadow_color = Color(0.31, 0.38, 0.4, 1)
+shader_parameter/cloud_wind = Vector2(0.01, -0.006)
+shader_parameter/cloud_scale = 0.72
+shader_parameter/sky_top_color = Color(0.204, 0.498, 0.643, 1)
+shader_parameter/sky_horizon_color = Color(0.663, 0.843, 0.847, 1)
+shader_parameter/ground_bottom_color = Color(0.157, 0.361, 0.439, 1)
+shader_parameter/ground_horizon_color = Color(0.549, 0.749, 0.765, 1)
+shader_parameter/horizon_blend_width = 0.01
+shader_parameter/fog_horizon_color = Color(0.13, 0.21, 0.32, 1)
+shader_parameter/fog_horizon_occlusion = 0.0
+shader_parameter/sun_direction = Vector3(0, 1, 0)
+shader_parameter/sun_color = Color(1, 0.86, 0.46, 1)
+shader_parameter/sun_visibility = 1.0
+shader_parameter/sun_radius = 0.055
+shader_parameter/sun_edge_softness = 0.003
+shader_parameter/sun_halo_radius = 0.14
+shader_parameter/sun_halo_strength = 0.16
+shader_parameter/moon_direction = Vector3(0, -1, 0)
+shader_parameter/moon_color = Color(0.78, 0.88, 1, 1)
+shader_parameter/moon_visibility = 0.0
+shader_parameter/moon_radius = 0.045
+shader_parameter/moon_edge_softness = 0.003
+shader_parameter/moon_halo_radius = 0.11
+shader_parameter/moon_halo_strength = 0.12
+
+[resource]
+sky_material = SubResource("ShaderMaterial_netfishing")
diff --git a/world/environment/netfishing_sky_clouds.gdshaderinc b/world/environment/netfishing_sky_clouds.gdshaderinc
new file mode 100644
index 0000000..d0cbbfb
--- /dev/null
+++ b/world/environment/netfishing_sky_clouds.gdshaderinc
@@ -0,0 +1,70 @@
+uniform float cloud_coverage : hint_range(0.0, 1.0, 0.01) = 0.0;
+uniform float cloud_opacity : hint_range(0.0, 1.0, 0.01) = 0.0;
+uniform vec4 cloud_light_color : source_color = vec4(0.70, 0.76, 0.77, 1.0);
+uniform vec4 cloud_shadow_color : source_color = vec4(0.31, 0.38, 0.40, 1.0);
+uniform vec2 cloud_wind = vec2(0.010, -0.006);
+uniform float cloud_scale : hint_range(0.1, 3.0, 0.05) = 0.72;
+
+
+float cloud_hash(vec2 point) {
+ return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+
+float cloud_value_noise(vec2 point) {
+ vec2 cell = floor(point);
+ vec2 local = fract(point);
+ vec2 blend = local * local * (3.0 - 2.0 * local);
+ float bottom = mix(
+ cloud_hash(cell),
+ cloud_hash(cell + vec2(1.0, 0.0)),
+ blend.x
+ );
+ float top = mix(
+ cloud_hash(cell + vec2(0.0, 1.0)),
+ cloud_hash(cell + vec2(1.0, 1.0)),
+ blend.x
+ );
+ return mix(bottom, top, blend.y);
+}
+
+
+float cloud_shape(vec2 point) {
+ vec2 broad_point = floor(point * 5.0) / 5.0;
+ vec2 detail_point = floor(point * 13.0) / 13.0;
+ float broad = cloud_value_noise(broad_point * 0.62);
+ float detail = cloud_value_noise(detail_point * 1.55 + vec2(17.3, 9.1));
+ float combined = broad * 0.72 + detail * 0.28;
+ return floor(combined * 7.0 + 0.5) / 7.0;
+}
+
+
+vec3 apply_weather_clouds(vec3 base_color, vec3 view_direction) {
+ if (cloud_opacity <= 0.001 || view_direction.y <= 0.0) {
+ return base_color;
+ }
+ float safe_height = max(view_direction.y, 0.08);
+ vec2 cloud_plane = view_direction.xz / safe_height;
+ vec2 cloud_point = (
+ cloud_plane + cloud_wind * TIME
+ ) * cloud_scale;
+ float shape = cloud_shape(cloud_point);
+ float threshold = mix(0.82, 0.27, cloud_coverage);
+ float mask = smoothstep(threshold - 0.08, threshold + 0.08, shape);
+ mask = floor(mask * 4.0 + 0.5) / 4.0;
+ mask *= smoothstep(0.025, 0.14, view_direction.y);
+ mask *= cloud_opacity;
+ float body_depth = smoothstep(threshold, 1.0, shape);
+ float underside = clamp(
+ (1.0 - smoothstep(0.08, 0.72, view_direction.y)) * 0.35
+ + body_depth * 0.65,
+ 0.0,
+ 1.0
+ );
+ vec3 cloud_color = mix(
+ cloud_light_color.rgb,
+ cloud_shadow_color.rgb,
+ underside
+ );
+ return mix(base_color, cloud_color, mask);
+}
diff --git a/world/environment/netfishing_sky_clouds.gdshaderinc.uid b/world/environment/netfishing_sky_clouds.gdshaderinc.uid
new file mode 100644
index 0000000..cf79ca0
--- /dev/null
+++ b/world/environment/netfishing_sky_clouds.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://cuqo54hgbjcrf
diff --git a/world/fishable_water_region.gd b/world/fishable_water_region.gd
index e3fe2e8..1887f2a 100644
--- a/world/fishable_water_region.gd
+++ b/world/fishable_water_region.gd
@@ -1,9 +1,39 @@
+@tool
class_name FishableWaterRegion
extends Area3D
const FishPoolType = preload("res://fish/fish_pool.gd")
+enum SurfaceHeightMode {
+ EXPLICIT,
+ PARENT_GLOBAL_Y,
+}
+
@export var location_tags: Array[StringName] = []
+@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER
@export var fish_pool: FishPoolType
@export var selection_priority: int = 0
+@export_group("Surface")
+@export var surface_height_mode: SurfaceHeightMode = SurfaceHeightMode.EXPLICIT:
+ set(value):
+ surface_height_mode = value
+ if Engine.is_editor_hint():
+ notify_property_list_changed()
+## Legacy explicit height used when Surface Height Mode is Explicit.
@export var surface_height: float = 0.3
+
+
+func get_surface_height() -> float:
+ if surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y:
+ var surface_owner := get_parent() as Node3D
+ if surface_owner != null:
+ return surface_owner.global_position.y
+ return surface_height
+
+
+func _validate_property(property: Dictionary) -> void:
+ if (
+ property.name == "surface_height"
+ and surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y
+ ):
+ property.usage = int(property.usage) & ~PROPERTY_USAGE_EDITOR
diff --git a/world/fishing_shop_interaction.gd b/world/fishing_shop_interaction.gd
index dcf7422..d912eca 100644
--- a/world/fishing_shop_interaction.gd
+++ b/world/fishing_shop_interaction.gd
@@ -3,17 +3,25 @@ extends Area3D
signal local_player_range_changed(in_range: bool)
+@export_node_path("Node3D") var look_character_path: NodePath = ^"../Shopkeeper"
+
var _local_player: Player
var _local_player_in_range: bool = false
+var _look_character: WorldCharacterDisplay
func _ready() -> void:
+ _look_character = get_node_or_null(
+ look_character_path
+ ) as WorldCharacterDisplay
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func setup_local_player(player: Player) -> void:
_local_player = player
+ if _look_character != null:
+ _look_character.set_head_look_target(player)
_refresh_overlaps()
@@ -21,6 +29,20 @@ func is_local_player_in_range() -> bool:
return _local_player_in_range
+func is_avatar_in_range(avatar: Player) -> bool:
+ return (
+ avatar != null
+ and is_instance_valid(avatar)
+ and avatar in get_overlapping_bodies()
+ )
+
+
+func get_prompt_anchor_position() -> Vector3:
+ if _look_character != null:
+ return _look_character.get_head_anchor_position()
+ return global_position + Vector3.UP * 1.25
+
+
func _refresh_overlaps() -> void:
var is_overlapping: bool = (
_local_player != null
@@ -41,6 +63,8 @@ func _on_body_exited(body: Node3D) -> void:
func _set_local_player_in_range(in_range: bool) -> void:
+ if _look_character != null:
+ _look_character.set_head_look_active(in_range)
if _local_player_in_range == in_range:
return
_local_player_in_range = in_range
diff --git a/world/generated/shorelines/starter_ocean_shoreline.tres b/world/generated/shorelines/starter_ocean_shoreline.tres
new file mode 100644
index 0000000..627b08e
--- /dev/null
+++ b/world/generated/shorelines/starter_ocean_shoreline.tres
@@ -0,0 +1,14 @@
+[gd_resource type="ArrayMesh" format=4 uid="uid://dmgps6rhbdjlw"]
+
+[resource]
+_surfaces = [{
+"aabb": AABB(-27.86245, -0.432, -26.431866, 54.402885, 1e-05, 51.728455),
+"attribute_data": PackedByteArray("AAAAAAAAAAAAAAAAAACAP0HfIz4AAAAAQd8jPgAAgD8S36M+AAAAABLfoz4AAIA/s871PgAAAACzzvU+AACAPyrfIz8AAAAAKt8jPwAAgD/61kw/AAAAAPrWTD8AAIA/s851PwAAAACzznU/AACAP0Jjjz8AAAAAQmOPPwAAgD/F3qM/AAAAAMXeoz8AAIA/uFq4PwAAAAC4Wrg/AACAP5TWzD8AAAAAlNbMPwAAgD97UuE/AAAAAHtS4T8AAIA/Y871PwAAAABjzvU/AACAPyUlBUAAAAAAJSUFQAAAgD8CYw9AAAAAAAJjD0AAAIA/+aAZQAAAAAD5oBlAAACAP+TeI0AAAAAA5N4jQAAAgD/bHC5AAAAAANscLkAAAIA/0Vo4QAAAAADRWjhAAACAP72YQkAAAAAAvZhCQAAAgD+z1kxAAAAAALPWTEAAAIA/PBRXQAAAAAA8FFdAAACAPytSYUAAAAAAK1JhQAAAgD8ekGtAAAAAAB6Qa0AAAIA/Dc51QAAAAAANznVAAACAPwEGgEAAAAAAAQaAQAAAgD/4JIVAAAAAAPgkhUAAAIA/tEOKQAAAAAC0Q4pAAACAP65ij0AAAAAArmKPQAAAgD+mgZRAAAAAAKaBlEAAAIA/oaCZQAAAAAChoJlAAACAP5i/nkAAAAAAmL+eQAAAgD9K3qNAAAAAAEreo0AAAIA/Q/2oQAAAAABD/ahAAACAPzscrkAAAAAAOxyuQAAAgD83O7NAAAAAADc7s0AAAIA/L1q4QAAAAAAvWrhAAACAPxh5vUAAAAAAGHm9QAAAgD8QmMJAAAAAABCYwkAAAIA/C7fHQAAAAAALt8dAAACAPwPWzEAAAAAAA9bMQAAAgD/99NFAAAAAAP300UAAAIA/ahPXQAAAAABqE9dAAACAP2My3EAAAAAAYzLcQAAAgD9cUeFAAAAAAFxR4UAAAIA/VnDmQAAAAABWcOZAAACAP06P60AAAAAATo/rQAAAgD+CrfBAAAAAAIKt8EAAAIA/e8z1QAAAAAB7zPVAAACAP3Tr+kAAAAAAdOv6QAAAgD83BQBBAAAAADcFAEEAAIA/jpQCQQAAAACOlAJBAACAPwskBUEAAAAACyQFQQAAgD+IswdBAAAAAIizB0EAAIA/BUMKQQAAAAAFQwpBAACAP17SDEEAAAAAXtIMQQAAgD/aYQ9BAAAAANphD0EAAIA/WPERQQAAAABY8RFBAACAP9SAFEEAAAAA1IAUQQAAgD9FEBdBAAAAAEUQF0EAAIA/wZ8ZQQAAAADBnxlBAACAPz8vHEEAAAAAPy8cQQAAgD+7vh5BAAAAALu+HkEAAIA/OE4hQQAAAAA4TiFBAACAP5vdI0EAAAAAm90jQQAAgD8XbSZBAAAAABdtJkEAAIA/lPwoQQAAAACU/ChBAACAPxCMK0EAAAAAEIwrQQAAgD97Gy5BAAAAAHsbLkEAAIA/+KowQQAAAAD4qjBBAACAP3Q6M0EAAAAAdDozQQAAgD/xyTVBAAAAAPHJNUEAAIA/bVk4QQAAAABtWThBAACAP9LoOkEAAAAA0ug6QQAAgD9PeD1BAAAAAE94PUEAAIA/ywdAQQAAAADLB0BBAACAP0iXQkEAAAAASJdCQQAAgD/FJkVBAAAAAMUmRUEAAIA/N7ZHQQAAAAA3tkdBAACAP7RFSkEAAAAAtEVKQQAAgD8w1UxBAAAAADDVTEEAAIA/rWRPQQAAAACtZE9BAACAPyb0UUEAAAAAJvRRQQAAgD+jg1RBAAAAAKODVEEAAIA/IBNXQQAAAAAgE1dBAACAP5yiWUEAAAAAnKJZQQAAgD8ZMlxBAAAAABkyXEEAAIA/ksFeQQAAAACSwV5BAACAPw5RYUEAAAAADlFhQQAAgD+L4GNBAAAAAIvgY0EAAIA/CHBmQQAAAAAIcGZBAACAP4T/aEEAAAAAhP9oQQAAgD/3jmtBAAAAAPeOa0EAAIA/dB5uQQAAAAB0Hm5BAACAP/GtcEEAAAAA8a1wQQAAgD9tPXNBAAAAAG09c0EAAIA/6sx1QQAAAADqzHVBAACAP1hceEEAAAAAWFx4QQAAgD/V63pBAAAAANXrekEAAIA/Unt9QQAAAABSe31BAACAP2cFgEEAAAAAZwWAQQAAgD8lTYFBAAAAACVNgUEAAIA/4JSCQQAAAADglIJBAACAP57cg0EAAAAAntyDQQAAgD9cJIVBAAAAAFwkhUEAAIA/G2yGQQAAAAAbbIZBAACAP9Kzh0EAAAAA0rOHQQAAgD+Q+4hBAAAAAJD7iEEAAIA/TkOKQQAAAABOQ4pBAACAPw2Li0EAAAAADYuLQQAAgD/L0oxBAAAAAMvSjEEAAIA/hBqOQQAAAACEGo5BAACAP0Nij0EAAAAAQ2KPQQAAgD8BqpBBAAAAAAGqkEEAAIA/v/GRQQAAAAC/8ZFBAACAP3Q5k0EAAAAAdDmTQQAAgD8ygZRBAAAAADKBlEEAAIA/8MiVQQAAAADwyJVBAACAP68Ql0EAAAAArxCXQQAAgD9nWJhBAAAAAGdYmEEAAIA/JaCZQQAAAAAloJlBAACAP+PnmkEAAAAA4+eaQQAAgD+iL5xBAAAAAKIvnEEAAIA/YHedQQAAAABgd51BAACAPx6/nkEAAAAAHr+eQQAAgD/dBqBBAAAAAN0GoEEAAIA/m06hQQAAAACbTqFBAACAP1mWokEAAAAAWZaiQQAAgD8Y3qNBAAAAABjeo0EAAIA/1iWlQQAAAADWJaVBAACAP5RtpkEAAAAAlG2mQQAAgD9TtadBAAAAAFO1p0EAAIA/Ef2oQQAAAAAR/ahBAACAP89EqkEAAAAAz0SqQQAAgD+OjKtBAAAAAI6Mq0EAAIA/TNSsQQAAAABM1KxBAACAPwocrkEAAAAAChyuQQAAgD/JY69BAAAAAMljr0EAAIA//KqwQQAAAAD8qrBBAACAP7nxsUEAAAAAufGxQQAAgD93ObNBAAAAAHc5s0EAAIA/NYG0QQAAAAA1gbRBAACAP/TItUEAAAAA9Mi1QQAAgD+yELdBAAAAALIQt0EAAIA/cFi4QQAAAABwWLhBAACAPy+guUEAAAAAL6C5QQAAgD/t57pBAAAAAO3nukEAAIA/qy+8QQAAAACrL7xBAACAP2l3vUEAAAAAaXe9QQAAgD8ov75BAAAAACi/vkEAAIA/5gbAQQAAAADmBsBBAACAP6ROwUEAAAAApE7BQQAAgD9ilsJBAAAAAGKWwkEAAIA/F97DQQAAAAAX3sNBAACAP8slxUEAAAAAyyXFQQAAgD98bcZBAAAAAHxtxkEAAIA/LbXHQQAAAAAttcdBAACAP9z8yEEAAAAA3PzIQQAAgD+JRMpBAAAAAIlEykEAAIA/OIzLQQAAAAA4jMtBAACAP+fTzEEAAAAA59PMQQAAgD+UG85BAAAAAJQbzkEAAIA/QWPPQQAAAABBY89BAACAP/Kq0EEAAAAA8qrQQQAAgD+j8tFBAAAAAKPy0UEAAIA/VTrTQQAAAABVOtNBAACAPwiC1EEAAAAACILUQQAAgD/CydVBAAAAAMLJ1UEAAIA/gBHXQQAAAACAEddBAACAPz5Z2EEAAAAAPlnYQQAAgD/9oNlBAAAAAP2g2UEAAIA/u+jaQQAAAAC76NpBAACAP3kw3EEAAAAAeTDcQQAAgD83eN1BAAAAADd43UEAAIA/9r/eQQAAAAD2v95BAACAP7QH4EEAAAAAtAfgQQAAgD9vT+FBAAAAAG9P4UEAAIA/LZfiQQAAAAAtl+JBAACAP+fe40EAAAAA597jQQAAgD+lJuVBAAAAAKUm5UEAAIA/XW7mQQAAAABdbuZBAACAPxu250EAAAAAG7bnQQAAgD/Z/ehBAAAAANn96EEAAIA/lkXqQQAAAACWRepBAACAP1SN60EAAAAAVI3rQQAAgD8S1exBAAAAABLV7EEAAIA/0BzuQQAAAADQHO5BAACAP45k70EAAAAAjmTvQQAAgD9NrPBBAAAAAE2s8EEAAIA/CfTxQQAAAAAJ9PFBAACAP8g780EAAAAAyDvzQQAAgD+Gg/RBAAAAAIaD9EEAAIA/RMv1QQAAAABEy/VBAACAPwIT90EAAAAAAhP3QQAAgD/BWvhBAAAAAMFa+EEAAIA/f6L5QQAAAAB/ovlBAACAPzzq+kEAAAAAPOr6QQAAgD/7MfxBAAAAAPsx/EEAAIA/uXn9QQAAAAC5ef1BAACAP3fB/kEAAAAAd8H+QQAAgD+YBABCAAAAAJgEAEIAAIA/d6gAQgAAAAB3qABCAACAP1ZMAUIAAAAAVkwBQgAAgD828AFCAAAAADbwAUIAAIA/EpQCQgAAAAASlAJCAACAP/E3A0IAAAAA8TcDQgAAgD/Q2wNCAAAAANDbA0IAAIA/r38EQgAAAACvfwRCAACAP4kjBUIAAAAAiSMFQgAAgD9oxwVCAAAAAGjHBUIAAIA/R2sGQgAAAABHawZCAACAPyYPB0IAAAAAJg8HQgAAgD//sgdCAAAAAP+yB0IAAIA/3lYIQgAAAADeVghCAACAP736CEIAAAAAvfoIQgAAgD+bnglCAAAAAJueCUIAAIA/e0IKQgAAAAB7QgpCAACAP1rmCkIAAAAAWuYKQgAAgD85igtCAAAAADmKC0IAAIA/Dy4MQgAAAAAPLgxCAACAP+7RDEIAAAAA7tEMQgAAgD/NdQ1CAAAAAM11DUIAAIA/pBkOQgAAAACkGQ5CAACAP4O9DkIAAAAAg70OQgAAgD9jYQ9CAAAAAGNhD0IAAIA/PwUQQgAAAAA/BRBCAACAPx6pEEIAAAAAHqkQQgAAgD/9TBFCAAAAAP1MEUIAAIA/3fARQgAAAADd8BFCAACAP7yUEkIAAAAAvJQSQgAAgD+bOBNCAAAAAJs4E0IAAIA/etwTQgAAAAB63BNCAACAP1mAFEIAAAAAWYAUQgAAgD84JBVCAAAAADgkFUIAAIA/GMgVQgAAAAAYyBVCAACAP/drFkIAAAAA92sWQgAAgD/WDxdCAAAAANYPF0IAAIA/tbMXQgAAAAC1sxdCAACAP5RXGEIAAAAAlFcYQgAAgD9z+xhCAAAAAHP7GEIAAIA/UZ8ZQgAAAABRnxlCAACAPypDGkIAAAAAKkMaQgAAgD8C5xpCAAAAAALnGkIAAIA/2oobQgAAAADaihtCAACAP7EuHEIAAAAAsS4cQgAAgD+H0hxCAAAAAIfSHEIAAIA/XXYdQgAAAABddh1CAACAPzMaHkIAAAAAMxoeQgAAgD8Jvh5CAAAAAAm+HkIAAIA/32EfQgAAAADfYR9CAACAP7cFIEIAAAAAtwUgQgAAgD+PqSBCAAAAAI+pIEIAAIA/aE0hQgAAAABoTSFCAACAP0PxIUIAAAAAQ/EhQgAAgD8ilSJCAAAAACKVIkIAAIA/ATkjQgAAAAABOSNCAACAP+DcI0IAAAAA4NwjQgAAgD+/gCRCAAAAAL+AJEIAAIA/niQlQgAAAACeJCVCAACAP33IJUIAAAAAfcglQgAAgD9cbCZCAAAAAFxsJkIAAIA/PBAnQgAAAAA8ECdCAACAPxu0J0IAAAAAG7QnQgAAgD/6VyhCAAAAAPpXKEIAAIA/2fsoQgAAAADZ+yhCAACAP7ifKUIAAAAAuJ8pQgAAgD+YQypCAAAAAJhDKkIAAIA/d+cqQgAAAAB35ypCAACAP1aLK0IAAAAAVosrQgAAgD81LyxCAAAAADUvLEIAAIA/FNMsQgAAAAAU0yxCAACAP/N2LUIAAAAA83YtQgAAgD/SGi5CAAAAANIaLkIAAIA/Tb0uQgAAAABNvS5CAACAPy1hL0IAAAAALWEvQgAAgD8MBTBCAAAAAAwFMEIAAIA/66gwQgAAAADrqDBCAACAP8pMMUIAAAAAykwxQgAAgD+p8DFCAAAAAKnwMUIAAIA/iJQyQgAAAACIlDJCAACAP2g4M0IAAAAAaDgzQgAAgD9H3DNCAAAAAEfcM0IAAIA/JoA0QgAAAAAmgDRCAACAPwUkNUIAAAAABSQ1QgAAgD/kxzVCAAAAAOTHNUIAAIA/w2s2QgAAAADDazZCAACAP6IPN0IAAAAAog83QgAAgD+CszdCAAAAAIKzN0IAAIA/TVc4QgAAAABNVzhCAACAPyz7OEIAAAAALPs4QgAAgD8MnzlCAAAAAAyfOUIAAIA/5EI6QgAAAADkQjpCAACAP8TmOkIAAAAAxOY6QgAAgD+jijtCAAAAAKOKO0IAAIA/gi48QgAAAACCLjxCAACAP1XSPEIAAAAAVdI8QgAAgD80dj1CAAAAADR2PUIAAIA/FBo+QgAAAAAUGj5CAACAP/O9PkIAAAAA870+QgAAgD/JYT9CAAAAAMlhP0IAAIA/qAVAQgAAAACoBUBCAACAP4epQEIAAAAAh6lAQgAAgD9mTUFCAAAAAGZNQUIAAIA/P/FBQgAAAAA/8UFCAACAPx6VQkIAAAAAHpVCQgAAgD/9OENCAAAAAP04Q0IAAIA/3NxDQgAAAADc3ENCAACAP7yAREIAAAAAvIBEQgAAgD+ZJEVCAAAAAJkkRUIAAIA/eMhFQgAAAAB4yEVCAACAP1dsRkIAAAAAV2xGQgAAgD82EEdCAAAAADYQR0IAAIA/ErRHQgAAAAAStEdCAACAP/FXSEIAAAAA8VdIQgAAgD/R+0hCAAAAANH7SEIAAIA/sJ9JQgAAAACwn0lCAACAP49DSkIAAAAAj0NKQgAAgD9r50pCAAAAAGvnSkIAAIA/SotLQgAAAABKi0tCAACAPyovTEIAAAAAKi9MQgAAgD8J00xCAAAAAAnTTEIAAIA/6HZNQgAAAADodk1CAACAP8caTkIAAAAAxxpOQgAAgD+mvk5CAAAAAKa+TkIAAIA/hWJPQgAAAACFYk9CAACAP2UGUEIAAAAAZQZQQgAAgD9EqlBCAAAAAESqUEIAAIA/I05RQgAAAAAjTlFCAACAPwLyUUIAAAAAAvJRQgAAgD/hlVJCAAAAAOGVUkIAAIA/wDlTQgAAAADAOVNCAACAP6DdU0IAAAAAoN1TQgAAgD9/gVRCAAAAAH+BVEIAAIA/XiVVQgAAAABeJVVCAACAPz3JVUIAAAAAPclVQgAAgD8cbVZCAAAAABxtVkIAAIA/+xBXQgAAAAD7EFdCAACAP9q0V0IAAAAA2rRXQgAAgD+6WFhCAAAAALpYWEIAAIA/mfxYQgAAAACZ/FhCAACAP3igWUIAAAAAeKBZQgAAgD9BRFpCAAAAAEFEWkIAAIA/8OdaQgAAAADw51pCAACAP5uLW0IAAAAAm4tbQgAAgD8ZL1xCAAAAABkvXEIAAIA/+NJcQgAAAAD40lxCAACAP9h2XUIAAAAA2HZdQgAAgD+3Gl5CAAAAALcaXkIAAIA/lr5eQgAAAACWvl5CAACAP3ViX0IAAAAAdWJfQgAAgD9UBmBCAAAAAFQGYEIAAIA/M6pgQgAAAAAzqmBCAACAPxNOYUIAAAAAE05hQgAAgD/y8WFCAAAAAPLxYUIAAIA/0ZViQgAAAADRlWJCAACAP7A5Y0IAAAAAsDljQgAAgD+P3WNCAAAAAI/dY0IAAIA/boFkQgAAAABugWRCAACAP04lZUIAAAAATiVlQgAAgD8tyWVCAAAAAC3JZUIAAIA/DG1mQgAAAAAMbWZCAACAP+sQZ0IAAAAA6xBnQgAAgD/KtGdCAAAAAMq0Z0IAAIA/qVhoQgAAAACpWGhCAACAP4n8aEIAAAAAifxoQgAAgD9ooGlCAAAAAGigaUIAAIA/R0RqQgAAAABHRGpCAACAPyboakIAAAAAJuhqQgAAgD8FjGtCAAAAAAWMa0IAAIA/5C9sQgAAAADkL2xCAACAP8TTbEIAAAAAxNNsQgAAgD+jd21CAAAAAKN3bUIAAIA/ghtuQgAAAACCG25CAACAP2G/bkIAAAAAYb9uQgAAgD9AY29CAAAAAEBjb0IAAIA/HwdwQgAAAAAfB3BCAACAP/+qcEIAAAAA/6pwQgAAgD/eTnFCAAAAAN5OcUIAAIA/vfJxQgAAAAC98nFCAACAP5yWckIAAAAAnJZyQgAAgD97OnNCAAAAAHs6c0IAAIA/Wt5zQgAAAABa3nNCAACAPzqCdEIAAAAAOoJ0QgAAgD8ZJnVCAAAAABkmdUIAAIA/+Ml1QgAAAAD4yXVCAACAP9dtdkIAAAAA1212QgAAgD+2EXdCAAAAALYRd0IAAIA/lbV3QgAAAACVtXdCAACAP3VZeEIAAAAAdVl4QgAAgD9U/XhCAAAAAFT9eEIAAIA/M6F5QgAAAAAzoXlCAACAPxJFekIAAAAAEkV6QgAAgD/x6HpCAAAAAPHoekIAAIA/0Ix7QgAAAADQjHtCAACAP7AwfEIAAAAAsDB8QgAAgD+P1HxCAAAAAI/UfEIAAIA/bnh9QgAAAABueH1CAACAP00cfkIAAAAATRx+QgAAgD8swH5CAAAAACzAfkIAAIA/DGR/QgAAAAAMZH9CAACAP/UDgEIAAAAA9QOAQgAAgD/lVYBCAAAAAOVVgEIAAIA/1KeAQgAAAADUp4BCAACAP8T5gEIAAAAAxPmAQgAAgD+0S4FCAAAAALRLgUIAAIA/o52BQgAAAACjnYFCAACAP5PvgUIAAAAAk++BQgAAgD+CQYJCAAAAAIJBgkIAAIA/cpOCQgAAAAByk4JCAACAP2LlgkIAAAAAYuWCQgAAgD9RN4NCAAAAAFE3g0IAAIA/QYmDQgAAAABBiYNCAACAPzDbg0IAAAAAMNuDQgAAgD8gLYRCAAAAACAthEIAAIA/D3+EQgAAAAAPf4RCAACAP//QhEIAAAAA/9CEQgAAgD/vIoVCAAAAAO8ihUIAAIA/3nSFQgAAAADedIVCAACAP87GhUIAAAAAzsaFQgAAgD+9GIZCAAAAAL0YhkIAAIA/rWqGQgAAAACtaoZCAACAP528hkIAAAAAnbyGQgAAgD+MDodCAAAAAIwOh0IAAIA/fGCHQgAAAAB8YIdCAACAP2uyh0IAAAAAa7KHQgAAgD9bBIhCAAAAAFsEiEIAAIA/S1aIQgAAAABLVohCAACAPzqoiEIAAAAAOqiIQgAAgD8q+ohCAAAAACr6iEIAAIA/GUyJQgAAAAAZTIlCAACAPwmeiUIAAAAACZ6JQgAAgD/474lCAAAAAPjviUIAAIA/6EGKQgAAAADoQYpCAACAP9iTikIAAAAA2JOKQgAAgD/H5YpCAAAAAMflikIAAIA/tzeLQgAAAAC3N4tCAACAP6aJi0IAAAAApomLQgAAgD+W24tCAAAAAJbbi0IAAIA/hi2MQgAAAACGLYxCAACAP3V/jEIAAAAAdX+MQgAAgD9l0YxCAAAAAGXRjEIAAIA/VCONQgAAAABUI41CAACAP0R1jUIAAAAARHWNQgAAgD8zx41CAAAAADPHjUIAAIA/IxmOQgAAAAAjGY5CAACAPxNrjkIAAAAAE2uOQgAAgD8CvY5CAAAAAAK9jkIAAIA/8g6PQgAAAADyDo9CAACAP+Fgj0IAAAAA4WCPQgAAgD/Rso9CAAAAANGyj0IAAIA/wASQQgAAAADABJBCAACAP7BWkEIAAAAAsFaQQgAAgD+gqJBCAAAAAKCokEIAAIA/j/qQQgAAAACP+pBCAACAP39MkUIAAAAAf0yRQgAAgD9vnpFCAAAAAG+ekUIAAIA/XvCRQgAAAABe8JFCAACAP05CkkIAAAAATkKSQgAAgD89lJJCAAAAAD2UkkIAAIA/LeaSQgAAAAAt5pJCAACAPxw4k0IAAAAAHDiTQgAAgD8MipNCAAAAAAyKk0IAAIA//NuTQgAAAAD825NCAACAP+stlEIAAAAA6y2UQgAAgD/bf5RCAAAAANt/lEIAAIA/ytGUQgAAAADK0ZRCAACAP7ojlUIAAAAAuiOVQgAAgD+pdZVCAAAAAKl1lUIAAIA/mceVQgAAAACZx5VCAACAP4kZlkIAAAAAiRmWQgAAgD94a5ZCAAAAAHhrlkIAAIA/aL2WQgAAAABovZZCAACAP1cPl0IAAAAAVw+XQgAAgD9HYZdCAAAAAEdhl0IAAIA/N7OXQgAAAAA3s5dCAACAPyYFmEIAAAAAJgWYQgAAgD8WV5hCAAAAABZXmEIAAIA/BamYQgAAAAAFqZhCAACAP/X6mEIAAAAA9fqYQgAAgD/kTJlCAAAAAORMmUIAAIA/1J6ZQgAAAADUnplCAACAP8TwmUIAAAAAxPCZQgAAgD+zQppCAAAAALNCmkIAAIA/o5SaQgAAAACjlJpCAACAP5LmmkIAAAAAkuaaQgAAgD+COJtCAAAAAII4m0IAAIA/coqbQgAAAAByiptCAACAP2Hcm0IAAAAAYdybQgAAgD9RLpxCAAAAAFEunEIAAIA/M4CcQgAAAAAzgJxCAACAPx7SnEIAAAAAHtKcQgAAgD8JJJ1CAAAAAAkknUIAAIA/83WdQgAAAADzdZ1CAACAP93HnUIAAAAA3cedQgAAgD/HGZ5CAAAAAMcZnkIAAIA/sWueQgAAAACxa55CAACAP5y9nkIAAAAAnL2eQgAAgD+GD59CAAAAAIYPn0IAAIA/cWGfQgAAAABxYZ9CAACAP2Gzn0IAAAAAYbOfQgAAgD9QBaBCAAAAAFAFoEIAAIA/QFegQgAAAABAV6BCAACAPzCpoEIAAAAAMKmgQgAAgD8f+6BCAAAAAB/7oEIAAIA/D02hQgAAAAAPTaFCAACAP/6eoUIAAAAA/p6hQgAAgD/u8KFCAAAAAO7woUIAAIA/3kKiQgAAAADeQqJCAACAP82UokIAAAAAzZSiQgAAgD+95qJCAAAAAL3mokIAAIA/rDijQgAAAACsOKNCAACAP5yKo0IAAAAAnIqjQgAAgD+M3KNCAAAAAIzco0IAAIA/ey6kQgAAAAB7LqRCAACAP2uApEIAAAAAa4CkQgAAgD9a0qRCAAAAAFrSpEIAAIA/SiSlQgAAAABKJKVCAACAPzl2pUIAAAAAOXalQgAAgD8pyKVCAAAAACnIpUIAAIA/GBqmQgAAAAAYGqZCAACAPwhspkIAAAAACGymQgAAgD/4vaZCAAAAAPi9pkIAAIA/5w+nQgAAAADnD6dCAACAP9dhp0IAAAAA12GnQgAAgD/Gs6dCAAAAAMazp0IAAIA/tgWoQgAAAAC2BahCAACAP6ZXqEIAAAAApleoQgAAgD+VqahCAAAAAJWpqEIAAIA/hfuoQgAAAACF+6hCAACAP3RNqUIAAAAAdE2pQgAAgD9kn6lCAAAAAGSfqUIAAIA/VPGpQgAAAABU8alCAACAP0NDqkIAAAAAQ0OqQgAAgD8zlapCAAAAADOVqkIAAIA/IueqQgAAAAAi56pCAACAPxI5q0IAAAAAEjmrQgAAgD8Bi6tCAAAAAAGLq0IAAIA/8dyrQgAAAADx3KtCAACAP+EurEIAAAAA4S6sQgAAgD/QgKxCAAAAANCArEIAAIA/wNKsQgAAAADA0qxCAACAP68krUIAAAAAryStQgAAgD+fdq1CAAAAAJ92rUIAAIA/j8itQgAAAACPyK1CAACAP34arkIAAAAAfhquQgAAgD9ubK5CAAAAAG5srkIAAIA/Xb6uQgAAAABdvq5CAACAP00Qr0IAAAAATRCvQgAAgD89Yq9CAAAAAD1ir0IAAIA/LLSvQgAAAAAstK9CAACAPxwGsEIAAAAAHAawQgAAgD8LWLBCAAAAAAtYsEIAAIA/+6mwQgAAAAD7qbBCAACAP+r7sEIAAAAA6vuwQgAAgD/aTbFCAAAAANpNsUIAAIA/yp+xQgAAAADKn7FCAACAP7nxsUIAAAAAufGxQgAAgD+pQ7JCAAAAAKlDskIAAIA/mJWyQgAAAACYlbJCAACAP4jnskIAAAAAiOeyQgAAgD93ObNCAAAAAHc5s0IAAIA/Z4uzQgAAAABni7NCAACAP1fds0IAAAAAV92zQgAAgD9GL7RCAAAAAEYvtEIAAIA/NoG0QgAAAAA2gbRCAACAPyXTtEIAAAAAJdO0QgAAgD8VJbVCAAAAABUltUIAAIA/BXe1QgAAAAAFd7VCAACAP/TItUIAAAAA9Mi1QgAAgD/kGrZCAAAAAOQatkIAAIA/02y2QgAAAADTbLZCAACAP8O+tkIAAAAAw762QgAAgD+yELdCAAAAALIQt0IAAIA/f2K3QgAAAAB/YrdCAACAP2K0t0IAAAAAYrS3QgAAgD9DBrhCAAAAAEMGuEIAAIA/Jli4QgAAAAAmWLhCAACAP/WpuEIAAAAA9am4QgAAgD/l+7hCAAAAAOX7uEIAAIA/1E25QgAAAADUTblCAACAP8SfuUIAAAAAxJ+5QgAAgD+z8blCAAAAALPxuUIAAIA/o0O6QgAAAACjQ7pCAACAP5OVukIAAAAAk5W6QgAAgD+C57pCAAAAAILnukIAAIA/cjm7QgAAAAByObtCAACAP2GLu0IAAAAAYYu7QgAAgD9R3btCAAAAAFHdu0IAAIA/QC+8QgAAAABAL7xCAACAPzCBvEIAAAAAMIG8QgAAgD8f07xCAAAAAB/TvEIAAIA/DyW9QgAAAAAPJb1CAACAP/92vUIAAAAA/3a9QgAAgD/uyL1CAAAAAO7IvUIAAIA/3hq+QgAAAADeGr5CAACAP81svkIAAAAAzWy+QgAAgD+9vr5CAAAAAL2+vkIAAIA/rRC/QgAAAACtEL9CAACAP5xiv0IAAAAAnGK/QgAAgD+MtL9CAAAAAIy0v0IAAIA/ewbAQgAAAAB7BsBCAACAP2tYwEIAAAAAa1jAQgAAgD9bqsBCAAAAAFuqwEIAAIA/SvzAQgAAAABK/MBCAACAPzpOwUIAAAAAOk7BQgAAgD8poMFCAAAAACmgwUIAAIA/GfLBQgAAAAAZ8sFCAACAPwhEwkIAAAAACETCQgAAgD/4lcJCAAAAAPiVwkIAAIA/6OfCQgAAAADo58JCAACAP9c5w0IAAAAA1znDQgAAgD/Hi8NCAAAAAMeLw0IAAIA/tt3DQgAAAAC23cNCAACAP6YvxEIAAAAApi/EQgAAgD+VgcRCAAAAAJWBxEIAAIA/hdPEQgAAAACF08RCAACAP3UlxUIAAAAAdSXFQgAAgD9kd8VCAAAAAGR3xUIAAIA/VMnFQgAAAABUycVCAACAP0MbxkIAAAAAQxvGQgAAgD8ybcZCAAAAADJtxkIAAIA/Ir/GQgAAAAAiv8ZCAACAPxERx0IAAAAAERHHQgAAgD8BY8dCAAAAAAFjx0IAAIA/8LTHQgAAAADwtMdCAACAP+AGyEIAAAAA4AbIQgAAgD/QWMhCAAAAANBYyEIAAIA/v6rIQgAAAAC/qshCAACAP6/8yEIAAAAAr/zIQgAAgD+eTslCAAAAAJ5OyUIAAIA/jqDJQgAAAACOoMlCAACAP37yyUIAAAAAfvLJQgAAgD9sRMpCAAAAAGxEykIAAIA/W5bKQgAAAABblspCAACAP0voykIAAAAAS+jKQgAAgD86OstCAAAAADo6y0IAAIA/KozLQgAAAAAqjMtCAACAPxney0IAAAAAGd7LQgAAgD8IMMxCAAAAAAgwzEIAAIA/94HMQgAAAAD3gcxCAACAP+fTzEIAAAAA59PMQgAAgD/XJc1CAAAAANclzUIAAIA/xnfNQgAAAADGd81CAACAP7bJzUIAAAAAtsnNQgAAgD+kG85CAAAAAKQbzkIAAIA/k23OQgAAAACTbc5CAACAP4O/zkIAAAAAg7/OQgAAgD9yEc9CAAAAAHIRz0IAAIA/YmPPQgAAAABiY89CAACAP1G1z0IAAAAAUbXPQgAAgD8+B9BCAAAAAD4H0EIAAIA/LlnQQgAAAAAuWdBCAACAPx2r0EIAAAAAHavQQgAAgD8N/dBCAAAAAA390EIAAIA//U7RQgAAAAD9TtFCAACAP+qg0UIAAAAA6qDRQgAAgD/a8tFCAAAAANry0UIAAIA/yUTSQgAAAADJRNJCAACAP7mW0kIAAAAAuZbSQgAAgD+o6NJCAAAAAKjo0kIAAIA/ljrTQgAAAACWOtNCAACAP4aM00IAAAAAhozTQgAAgD913tNCAAAAAHXe00IAAIA/ZTDUQgAAAABlMNRCAACAP1WC1EIAAAAAVYLUQgAAgD9E1NRCAAAAAETU1EIAAIA/MSbVQgAAAAAxJtVCAACAPyF41UIAAAAAIXjVQgAAgD8QytVCAAAAABDK1UIAAIA/ABzWQgAAAAAAHNZCAACAP/Bt1kIAAAAA8G3WQgAAgD/cv9ZCAAAAANy/1kIAAIA/yxHXQgAAAADLEddCAACAP7tj10IAAAAAu2PXQgAAgD+qtddCAAAAAKq110IAAIA/mgfYQgAAAACaB9hCAACAP4pZ2EIAAAAAilnYQgAAgD93q9hCAAAAAHer2EIAAIA/Zv3YQgAAAABm/dhCAACAP1ZP2UIAAAAAVk/ZQgAAgD9FodlCAAAAAEWh2UIAAIA/NfPZQgAAAAA189lCAACAPyVF2kIAAAAAJUXaQgAAgD8Tl9pCAAAAABOX2kIAAIA/A+naQgAAAAAD6dpCAACAP/I620IAAAAA8jrbQgAAgD/ijNtCAAAAAOKM20IAAIA/0t7bQgAAAADS3ttCAACAP8Ew3EIAAAAAwTDcQgAAgD+wgtxCAAAAALCC3EIAAIA/oNTcQgAAAACg1NxCAACAP48m3UIAAAAAjybdQgAAgD9/eN1CAAAAAH943UIAAIA/bsrdQgAAAABuyt1CAACAP14c3kIAAAAAXhzeQgAAgD9Mbt5CAAAAAExu3kIAAIA/O8DeQgAAAAA7wN5CAACAPysS30IAAAAAKxLfQgAAgD8bZN9CAAAAABtk30IAAIA/CrbfQgAAAAAKtt9CAACAP/oH4EIAAAAA+gfgQgAAgD/oWeBCAAAAAOhZ4EIAAIA/16vgQgAAAADXq+BCAACAP8f94EIAAAAAx/3gQgAAgD+2T+FCAAAAALZP4UIAAIA/pqHhQgAAAACmoeFCAACAP5bz4UIAAAAAlvPhQgAAgD+FReJCAAAAAIVF4kIAAIA/dZfiQgAAAAB1l+JCAACAP2Tp4kIAAAAAZOniQgAAgD9UO+NCAAAAAFQ740IAAIA/Q43jQgAAAABDjeNCAACAPzPf40IAAAAAM9/jQgAAgD8jMeRCAAAAACMx5EIAAIA/EoPkQgAAAAASg+RCAACAPwLV5EIAAAAAAtXkQgAAgD/xJuVCAAAAAPEm5UIAAIA/4XjlQgAAAADheOVCAACAP9HK5UIAAAAA0crlQgAAgD/AHOZCAAAAAMAc5kIAAIA/sG7mQgAAAACwbuZCAACAP5/A5kIAAAAAn8DmQgAAgD+PEudCAAAAAI8S50IAAIA/fmTnQgAAAAB+ZOdCAACAP26250IAAAAAbrbnQgAAgD9eCOhCAAAAAF4I6EIAAIA/TVroQgAAAABNWuhCAACAPz2s6EIAAAAAPazoQgAAgD8s/uhCAAAAACz+6EIAAIA/HFDpQgAAAAAcUOlCAACAPwyi6UIAAAAADKLpQgAAgD/78+lCAAAAAPvz6UIAAIA/5kXqQgAAAADmRepCAACAP9SX6kIAAAAA1JfqQgAAgD/B6epCAAAAAMHp6kIAAIA/rzvrQgAAAACvO+tCAACAP5yN60IAAAAAnI3rQgAAgD+J3+tCAAAAAInf60IAAIA/djHsQgAAAAB2MexCAACAP2KD7EIAAAAAYoPsQgAAgD9O1exCAAAAAE7V7EIAAIA/OiftQgAAAAA6J+1CAACAPyd57UIAAAAAJ3ntQgAAgD8Ty+1CAAAAABPL7UIAAIA/AB3uQgAAAAAAHe5CAACAP+xu7kIAAAAA7G7uQgAAgD/ZwO5CAAAAANnA7kIAAIA/xhLvQgAAAADGEu9CAACAP7Rk70IAAAAAtGTvQgAAgD+htu9CAAAAAKG270IAAIA/jwjwQgAAAACPCPBCAACAP3pa8EIAAAAAelrwQgAAgD9prPBCAAAAAGms8EIAAIA/Wf7wQgAAAABZ/vBCAACAP0hQ8UIAAAAASFDxQgAAgD84ovFCAAAAADii8UIAAIA/KPTxQgAAAAAo9PFCAACAPxdG8kIAAAAAF0byQgAAgD8HmPJCAAAAAAeY8kIAAIA/9unyQgAAAAD26fJCAACAP+Y780IAAAAA5jvzQgAAgD/WjfNCAAAAANaN80IAAIA/xd/zQgAAAADF3/NCAACAP7Ux9EIAAAAAtTH0QgAAgD+kg/RCAAAAAKSD9EIAAIA/lNX0QgAAAACU1fRCAACAP4Qn9UIAAAAAhCf1QgAAgD9zefVCAAAAAHN59UIAAIA/Y8v1QgAAAABjy/VCAACAP1Id9kIAAAAAUh32QgAAgD9Cb/ZCAAAAAEJv9kIAAIA/McH2QgAAAAAxwfZCAACAPyET90IAAAAAIRP3QgAAgD8RZfdCAAAAABFl90IAAIA/ALf3QgAAAAAAt/dCAACAP/AI+EIAAAAA8Aj4QgAAgD/fWvhCAAAAAN9a+EIAAIA/z6z4QgAAAADPrPhCAACAP7/++EIAAAAAv/74QgAAgD+uUPlCAAAAAK5Q+UIAAIA/nqL5QgAAAACeovlCAACAP430+UIAAAAAjfT5QgAAgD98RvpCAAAAAHxG+kIAAIA/bJj6QgAAAABsmPpCAACAP1vq+kIAAAAAW+r6QgAAgD9LPPtCAAAAAEs8+0IAAIA/O477QgAAAAA7jvtCAACAPyrg+0IAAAAAKuD7QgAAgD8ZMvxCAAAAABky/EIAAIA/CYT8QgAAAAAJhPxCAACAP/jV/EIAAAAA+NX8QgAAgD/oJ/1CAAAAAOgn/UIAAIA/13n9QgAAAADXef1CAACAP8fL/UIAAAAAx8v9QgAAgD+2Hf5CAAAAALYd/kIAAIA/pW/+QgAAAAClb/5CAACAP5XB/kIAAAAAlcH+QgAAgD+EE/9CAAAAAIQT/0IAAIA/dGX/QgAAAAB0Zf9CAACAP2O3/0IAAAAAY7f/QgAAgD+pBABDAAAAAKkEAEMAAIA/oS0AQwAAAAChLQBDAACAP5lWAEMAAAAAmVYAQwAAgD+RfwBDAAAAAJF/AEMAAIA/iKgAQwAAAACIqABDAACAP4DRAEMAAAAAgNEAQwAAgD94+gBDAAAAAHj6AEMAAIA/byMBQwAAAABvIwFDAACAP2dMAUMAAAAAZ0wBQwAAgD9edQFDAAAAAF51AUMAAIA/Vp4BQwAAAABWngFDAACAP03HAUMAAAAATccBQwAAgD9F8AFDAAAAAEXwAUMAAIA/OxkCQwAAAAA7GQJDAACAPzNCAkMAAAAAM0ICQwAAgD8rawJDAAAAACtrAkMAAIA/I5QCQwAAAAAjlAJDAACAPxi9AkMAAAAAGL0CQwAAgD8Q5gJDAAAAABDmAkMAAIA/Bw8DQwAAAAAHDwNDAACAP/83A0MAAAAA/zcDQwAAgD/3YANDAAAAAPdgA0MAAIA/74kDQwAAAADviQNDAACAP+eyA0MAAAAA57IDQwAAgD/e2wNDAAAAAN7bA0MAAIA/1gQEQwAAAADWBARDAACAP84tBEMAAAAAzi0EQwAAgD/GVgRDAAAAAMZWBEMAAIA/vX8EQwAAAAC9fwRDAACAP7WoBEMAAAAAtagEQwAAgD+t0QRDAAAAAK3RBEMAAIA/pfoEQwAAAACl+gRDAACAP50jBUMAAAAAnSMFQwAAgD8aTAVDAAAAABpMBUMAAIA/EXUFQwAAAAARdQVDAACAPwmeBUMAAAAACZ4FQwAAgD8BxwVDAAAAAAHHBUMAAIA/+e8FQwAAAAD57wVDAACAP/AYBkMAAAAA8BgGQwAAgD/oQQZDAAAAAOhBBkMAAIA/4GoGQwAAAADgagZDAACAP9iTBkMAAAAA2JMGQwAAgD/QvAZDAAAAANC8BkMAAIA/x+UGQwAAAADH5QZDAACAP78OB0MAAAAAvw4HQwAAgD+2NwdDAAAAALY3B0MAAIA/rmAHQwAAAACuYAdDAACAP6aJB0MAAAAApokHQwAAgD+csgdDAAAAAJyyB0MAAIA/k9sHQwAAAACT2wdDAACAP4sECEMAAAAAiwQIQwAAgD+CLQhDAAAAAIItCEMAAIA/elYIQwAAAAB6VghDAACAP3B/CEMAAAAAcH8IQwAAgD9nqAhDAAAAAGeoCEMAAIA/X9EIQwAAAABf0QhDAACAP1X6CEMAAAAAVfoIQwAAgD9NIwlDAAAAAE0jCUMAAIA/Q0wJQwAAAABDTAlDAACAPzp1CUMAAAAAOnUJQwAAgD8ynglDAAAAADKeCUMAAIA/KMcJQwAAAAAoxwlDAACAPyDwCUMAAAAAIPAJQwAAgD8VGQpDAAAAABUZCkMAAIA/DUIKQwAAAAANQgpDAACAPwVrCkMAAAAABWsKQwAAgD/9kwpDAAAAAP2TCkMAAIA/9LwKQwAAAAD0vApDAACAP+rlCkMAAAAA6uUKQwAAgD/iDgtDAAAAAOIOC0MAAIA/2jcLQwAAAADaNwtDAACAP9FgC0MAAAAA0WALQwAAgD/JiQtDAAAAAMmJC0MAAIA/wLILQwAAAADAsgtDAACAP7fbC0MAAAAAt9sLQwAAgD+vBAxDAAAAAK8EDEMAAIA/pi0MQwAAAACmLQxDAACAP51WDEMAAAAAnVYMQwAAgD+VfwxDAAAAAJV/DEMAAIA/jKgMQwAAAACMqAxDAACAP4TRDEMAAAAAhNEMQwAAgD98+gxDAAAAAHz6DEMAAIA/ciMNQwAAAAByIw1DAACAP2pMDUMAAAAAakwNQwAAgD9idQ1DAAAAAGJ1DUMAAIA/WZ4NQwAAAABZng1DAACAP1HHDUMAAAAAUccNQwAAgD9J8A1DAAAAAEnwDUMAAIA/QBkOQwAAAABAGQ5DAACAPzhCDkMAAAAAOEIOQwAAgD8waw5DAAAAADBrDkMAAIA/KJQOQwAAAAAolA5DAACAPyC9DkMAAAAAIL0OQwAAgD8X5g5DAAAAABfmDkMAAIA/Dw8PQwAAAAAPDw9DAACAPwc4D0MAAAAABzgPQwAAgD//YA9DAAAAAP9gD0MAAIA/9okPQwAAAAD2iQ9DAACAP+6yD0MAAAAA7rIPQwAAgD/m2w9DAAAAAObbD0MAAIA/1wQQQwAAAADXBBBDAACAP8ktEEMAAAAAyS0QQwAAgD+7VhBDAAAAALtWEEMAAIA/rH8QQwAAAACsfxBDAACAP56oEEMAAAAAnqgQQwAAgD+S0RBDAAAAAJLREEMAAIA/ivoQQwAAAACK+hBDAACAP4EjEUMAAAAAgSMRQwAAgD95TBFDAAAAAHlMEUMAAIA/cXURQwAAAABxdRFDAACAP2meEUMAAAAAaZ4RQwAAgD9gxxFDAAAAAGDHEUMAAIA/WPARQwAAAABY8BFDAACAP1AZEkMAAAAAUBkSQwAAgD9IQhJDAAAAAEhCEkMAAIA/QGsSQwAAAABAaxJDAACAPzeUEkMAAAAAN5QSQwAAgD8vvRJDAAAAAC+9EkMAAIA/JOYSQwAAAAAk5hJDAACAPxkPE0MAAAAAGQ8TQwAAgD8OOBNDAAAAAA44E0MAAIA/A2ETQwAAAAADYRNDAACAP/iJE0MAAAAA+IkTQwAAgD/sshNDAAAAAOyyE0MAAIA/4dsTQwAAAADh2xNDAACAP9YEFEMAAAAA1gQUQwAAgD/LLRRDAAAAAMstFEMAAIA/wVYUQwAAAADBVhRDAACAP7h/FEMAAAAAuH8UQwAAgD+vqBRDAAAAAK+oFEMAAIA/ptEUQwAAAACm0RRDAACAP536FEMAAAAAnfoUQwAAgD+TIxVDAAAAAJMjFUMAAIA/iUwVQwAAAACJTBVDAACAP351FUMAAAAAfnUVQwAAgD9znhVDAAAAAHOeFUMAAIA/aMcVQwAAAABoxxVDAACAP13wFUMAAAAAXfAVQwAAgD9SGRZDAAAAAFIZFkMAAIA/R0IWQwAAAABHQhZDAACAPzxrFkMAAAAAPGsWQwAAgD8xlBZDAAAAADGUFkMAAIA/KL0WQwAAAAAovRZDAACAPyDmFkMAAAAAIOYWQwAAgD8YDxdDAAAAABgPF0MAAIA/DzgXQwAAAAAPOBdDAACAPwdhF0MAAAAAB2EXQwAAgD//iRdDAAAAAP+JF0MAAIA/97IXQwAAAAD3shdDAACAP+/bF0MAAAAA79sXQwAAgD/mBBhDAAAAAOYEGEMAAIA/3i0YQwAAAADeLRhDAACAP9ZWGEMAAAAA1lYYQwAAgD/OfxhDAAAAAM5/GEMAAIA/xqgYQwAAAADGqBhDAACAP73RGEMAAAAAvdEYQwAAgD+1+hhDAAAAALX6GEMAAIA/nSMZQwAAAACdIxlDAACAP4ZMGUMAAAAAhkwZQwAAgD9vdRlDAAAAAG91GUMAAIA/Zp4ZQwAAAABmnhlDAACAP17HGUMAAAAAXscZQwAAgD9W8BlDAAAAAFbwGUMAAIA/TRkaQwAAAABNGRpDAACAP0VCGkMAAAAARUIaQwAAgD89axpDAAAAAD1rGkMAAIA/NZQaQwAAAAA1lBpDAACAPy29GkMAAAAALb0aQwAAgD8k5hpDAAAAACTmGkMAAIA/HA8bQwAAAAAcDxtDAACAPxQ4G0MAAAAAFDgbQwAAgD8MYRtDAAAAAAxhG0MAAIA/BIobQwAAAAAEihtDAACAP/uyG0MAAAAA+7IbQwAAgD/z2xtDAAAAAPPbG0MAAIA/6wQcQwAAAADrBBxDAACAP+MtHEMAAAAA4y0cQwAAgD/bVhxDAAAAANtWHEMAAIA/0n8cQwAAAADSfxxDAACAP8qoHEMAAAAAyqgcQwAAgD+/0RxDAAAAAL/RHEMAAIA/tPocQwAAAAC0+hxDAACAP6kjHUMAAAAAqSMdQwAAgD+eTB1DAAAAAJ5MHUMAAIA/k3UdQwAAAACTdR1DAACAP4ieHUMAAAAAiJ4dQwAAgD98xx1DAAAAAHzHHUMAAIA/cfAdQwAAAABx8B1DAACAP2cZHkMAAAAAZxkeQwAAgD9aQh5DAAAAAFpCHkMAAIA/UWseQwAAAABRax5DAACAP0mUHkMAAAAASZQeQwAAgD9BvR5DAAAAAEG9HkMAAIA/OeYeQwAAAAA55h5DAACAPzEPH0MAAAAAMQ8fQwAAgD8oOB9DAAAAACg4H0MAAIA/IGEfQwAAAAAgYR9DAACAPxiKH0MAAAAAGIofQwAAgD8Qsx9DAAAAABCzH0MAAIA/CNwfQwAAAAAI3B9DAACAP/8EIEMAAAAA/wQgQwAAgD/3LSBDAAAAAPctIEMAAIA/71YgQwAAAADvViBDAACAP+d/IEMAAAAA538gQwAAgD/fqCBDAAAAAN+oIEMAAIA/1dEgQwAAAADV0SBDAACAP836IEMAAAAAzfogQwAAgD/FIyFDAAAAAMUjIUMAAIA/vUwhQwAAAAC9TCFDAACAP7R1IUMAAAAAtHUhQwAAgD+sniFDAAAAAKyeIUMAAIA/pMchQwAAAACkxyFDAACAP5rwIUMAAAAAmvAhQwAAgD+SGSJDAAAAAJIZIkMAAIA/ikIiQwAAAACKQiJDAACAP4JrIkMAAAAAgmsiQwAAgD95lCJDAAAAAHmUIkMAAIA/cb0iQwAAAABxvSJDAACAP2nmIkMAAAAAaeYiQwAAgD9gDyNDAAAAAGAPI0MAAIA/WDgjQwAAAABYOCNDAACAP09hI0MAAAAAT2EjQwAAgD9HiiNDAAAAAEeKI0MAAIA/PrMjQwAAAAA+syNDAACAPzbcI0MAAAAANtwjQwAAgD8uBSRDAAAAAC4FJEMAAIA/Ji4kQwAAAAAmLiRDAACAPx1XJEMAAAAAHVckQwAAgD8VgCRDAAAAABWAJEMAAIA/DakkQwAAAAANqSRDAACAPwTSJEMAAAAABNIkQwAAgD/8+iRDAAAAAPz6JEMAAIA/9CMlQwAAAAD0IyVDAACAP+xMJUMAAAAA7EwlQwAAgD/kdSVDAAAAAOR1JUMAAIA/254lQwAAAADbniVDAACAP9PHJUMAAAAA08clQwAAgD/L8CVDAAAAAMvwJUMAAIA/wxkmQwAAAADDGSZDAACAP7pCJkMAAAAAukImQwAAgD+yayZDAAAAALJrJkMAAIA/qpQmQwAAAACqlCZDAACAP6K9JkMAAAAAor0mQwAAgD+a5iZDAAAAAJrmJkMAAIA/kQ8nQwAAAACRDydDAACAP4k4J0MAAAAAiTgnQwAAgD+BYSdDAAAAAIFhJ0MAAIA/eYonQwAAAAB5iidDAACAP86yJ0MAAAAAzrInQwAAgD/F2ydDAAAAAMXbJ0MAAIA/vQQoQwAAAAC9BChDAACAP7UtKEMAAAAAtS0oQwAAgD+tVihDAAAAAK1WKEMAAIA/pH8oQwAAAACkfyhDAACAP5yoKEMAAAAAnKgoQwAAgD+U0ShDAAAAAJTRKEMAAIA/jPooQwAAAACM+ihDAACAP4QjKUMAAAAAhCMpQwAAgD97TClDAAAAAHtMKUMAAIA/c3UpQwAAAABzdSlDAACAP2ueKUMAAAAAa54pQwAAgD9jxylDAAAAAGPHKUMAAIA/V/ApQwAAAABX8ClDAACAP08ZKkMAAAAATxkqQwAAgD9HQipDAAAAAEdCKkMAAIA/PmsqQwAAAAA+aypDAACAPzCUKkMAAAAAMJQqQwAAgD8ovSpDAAAAACi9KkMAAIA/IOYqQwAAAAAg5ipDAACAPxcPK0MAAAAAFw8rQwAAgD8MOCtDAAAAAAw4K0MAAIA/BGErQwAAAAAEYStDAACAP/yJK0MAAAAA/IkrQwAAgD/zsitDAAAAAPOyK0MAAIA/69srQwAAAADr2ytDAACAP+EELEMAAAAA4QQsQwAAgD/YLSxDAAAAANgtLEMAAIA/0FYsQwAAAADQVixDAACAP8h/LEMAAAAAyH8sQwAAgD/AqCxDAAAAAMCoLEMAAIA/ttEsQwAAAAC20SxDAACAP636LEMAAAAArfosQwAAgD+lIy1DAAAAAKUjLUMAAIA/nUwtQwAAAACdTC1DAACAP5V1LUMAAAAAlXUtQwAAgD+Nni1DAAAAAI2eLUMAAIA/hMctQwAAAACExy1DAACAP3vwLUMAAAAAe/AtQwAAgD9zGS5DAAAAAHMZLkMAAIA/a0IuQwAAAABrQi5DAACAP2NrLkMAAAAAY2suQwAAgD9blC5DAAAAAFuULkMAAIA/Ur0uQwAAAABSvS5DAACAP0nmLkMAAAAASeYuQwAAgD9BDy9DAAAAAEEPL0MAAIA/OTgvQwAAAAA5OC9DAACAPzFhL0MAAAAAMWEvQwAAgD8pii9DAAAAACmKL0MAAIA/"),
+"format": 34359742487,
+"index_count": 6588,
+"index_data": PackedByteArray("AAACAAEAAQACAAMAAgAEAAMAAwAEAAUABAAGAAUABQAGAAcABgAIAAcABwAIAAkACAAKAAkACQAKAAsACgAMAAsACwAMAA0ADAAOAA0ADQAOAA8ADgAQAA8ADwAQABEAEAASABEAEQASABMAEgAUABMAEwAUABUAFAAWABUAFQAWABcAFgAYABcAFwAYABkAGAAaABkAGQAaABsAGgAcABsAGwAcAB0AHAAeAB0AHQAeAB8AHgAgAB8AHwAgACEAIAAiACEAIQAiACMAIgAkACMAIwAkACUAJAAmACUAJQAmACcAJgAoACcAJwAoACkAKAAqACkAKQAqACsAKgAsACsAKwAsAC0ALAAuAC0ALQAuAC8ALgAwAC8ALwAwADEAMAAyADEAMQAyADMAMgA0ADMAMwA0ADUANAA2ADUANQA2ADcANgA4ADcANwA4ADkAOAA6ADkAOQA6ADsAOgA8ADsAOwA8AD0APAA+AD0APQA+AD8APgBAAD8APwBAAEEAQABCAEEAQQBCAEMAQgBEAEMAQwBEAEUARABGAEUARQBGAEcARgBIAEcARwBIAEkASABKAEkASQBKAEsASgBMAEsASwBMAE0ATABOAE0ATQBOAE8ATgBQAE8ATwBQAFEAUABSAFEAUQBSAFMAUgBUAFMAUwBUAFUAVABWAFUAVQBWAFcAVgBYAFcAVwBYAFkAWABaAFkAWQBaAFsAWgBcAFsAWwBcAF0AXABeAF0AXQBeAF8AXgBgAF8AXwBgAGEAYABiAGEAYQBiAGMAYgBkAGMAYwBkAGUAZABmAGUAZQBmAGcAZgBoAGcAZwBoAGkAaABqAGkAaQBqAGsAagBsAGsAawBsAG0AbABuAG0AbQBuAG8AbgBwAG8AbwBwAHEAcAByAHEAcQByAHMAcgB0AHMAcwB0AHUAdAB2AHUAdQB2AHcAdgB4AHcAdwB4AHkAeAB6AHkAeQB6AHsAegB8AHsAewB8AH0AfAB+AH0AfQB+AH8AfgCAAH8AfwCAAIEAgACCAIEAgQCCAIMAggCEAIMAgwCEAIUAhACGAIUAhQCGAIcAhgCIAIcAhwCIAIkAiACKAIkAiQCKAIsAigCMAIsAiwCMAI0AjACOAI0AjQCOAI8AjgCQAI8AjwCQAJEAkACSAJEAkQCSAJMAkgCUAJMAkwCUAJUAlACWAJUAlQCWAJcAlgCYAJcAlwCYAJkAmACaAJkAmQCaAJsAmgCcAJsAmwCcAJ0AnACeAJ0AnQCeAJ8AngCgAJ8AnwCgAKEAoACiAKEAoQCiAKMAogCkAKMAowCkAKUApACmAKUApQCmAKcApgCoAKcApwCoAKkAqACqAKkAqQCqAKsAqgCsAKsAqwCsAK0ArACuAK0ArQCuAK8ArgCwAK8ArwCwALEAsACyALEAsQCyALMAsgC0ALMAswC0ALUAtAC2ALUAtQC2ALcAtgC4ALcAtwC4ALkAuAC6ALkAuQC6ALsAugC8ALsAuwC8AL0AvAC+AL0AvQC+AL8AvgDAAL8AvwDAAMEAwADCAMEAwQDCAMMAwgDEAMMAwwDEAMUAxADGAMUAxQDGAMcAxgDIAMcAxwDIAMkAyADKAMkAyQDKAMsAygDMAMsAywDMAM0AzADOAM0AzQDOAM8AzgDQAM8AzwDQANEA0ADSANEA0QDSANMA0gDUANMA0wDUANUA1ADWANUA1QDWANcA1gDYANcA1wDYANkA2ADaANkA2QDaANsA2gDcANsA2wDcAN0A3ADeAN0A3QDeAN8A3gDgAN8A3wDgAOEA4ADiAOEA4QDiAOMA4gDkAOMA4wDkAOUA5ADmAOUA5QDmAOcA5gDoAOcA5wDoAOkA6ADqAOkA6QDqAOsA6gDsAOsA6wDsAO0A7ADuAO0A7QDuAO8A7gDwAO8A7wDwAPEA8ADyAPEA8QDyAPMA8gD0APMA8wD0APUA9AD2APUA9QD2APcA9gD4APcA9wD4APkA+AD6APkA+QD6APsA+gD8APsA+wD8AP0A/AD+AP0A/QD+AP8A/gAAAf8A/wAAAQEBAAECAQEBAQECAQMBAgEEAQMBAwEEAQUBBAEGAQUBBQEGAQcBBgEIAQcBBwEIAQkBCAEKAQkBCQEKAQsBCgEMAQsBCwEMAQ0BDAEOAQ0BDQEOAQ8BDgEQAQ8BDwEQAREBEAESAREBEQESARMBEgEUARMBEwEUARUBFAEWARUBFQEWARcBFgEYARcBFwEYARkBGAEaARkBGQEaARsBGgEcARsBGwEcAR0BHAEeAR0BHQEeAR8BHgEgAR8BHwEgASEBIAEiASEBIQEiASMBIgEkASMBIwEkASUBJAEmASUBJQEmAScBJgEoAScBJwEoASkBKAEqASkBKQEqASsBKgEsASsBKwEsAS0BLAEuAS0BLQEuAS8BLgEwAS8BLwEwATEBMAEyATEBMQEyATMBMgE0ATMBMwE0ATUBNAE2ATUBNQE2ATcBNgE4ATcBNwE4ATkBOAE6ATkBOQE6ATsBOgE8ATsBOwE8AT0BPAE+AT0BPQE+AT8BPgFAAT8BPwFAAUEBQAFCAUEBQQFCAUMBQgFEAUMBQwFEAUUBRAFGAUUBRQFGAUcBRgFIAUcBRwFIAUkBSAFKAUkBSQFKAUsBSgFMAUsBSwFMAU0BTAFOAU0BTQFOAU8BTgFQAU8BTwFQAVEBUAFSAVEBUQFSAVMBUgFUAVMBUwFUAVUBVAFWAVUBVQFWAVcBVgFYAVcBVwFYAVkBWAFaAVkBWQFaAVsBWgFcAVsBWwFcAV0BXAFeAV0BXQFeAV8BXgFgAV8BXwFgAWEBYAFiAWEBYQFiAWMBYgFkAWMBYwFkAWUBZAFmAWUBZQFmAWcBZgFoAWcBZwFoAWkBaAFqAWkBaQFqAWsBagFsAWsBawFsAW0BbAFuAW0BbQFuAW8BbgFwAW8BbwFwAXEBcAFyAXEBcQFyAXMBcgF0AXMBcwF0AXUBdAF2AXUBdQF2AXcBdgF4AXcBdwF4AXkBeAF6AXkBeQF6AXsBegF8AXsBewF8AX0BfAF+AX0BfQF+AX8BfgGAAX8BfwGAAYEBgAGCAYEBgQGCAYMBggGEAYMBgwGEAYUBhAGGAYUBhQGGAYcBhgGIAYcBhwGIAYkBiAGKAYkBiQGKAYsBigGMAYsBiwGMAY0BjAGOAY0BjQGOAY8BjgGQAY8BjwGQAZEBkAGSAZEBkQGSAZMBkgGUAZMBkwGUAZUBlAGWAZUBlQGWAZcBlgGYAZcBlwGYAZkBmAGaAZkBmQGaAZsBmgGcAZsBmwGcAZ0BnAGeAZ0BnQGeAZ8BngGgAZ8BnwGgAaEBoAGiAaEBoQGiAaMBogGkAaMBowGkAaUBpAGmAaUBpQGmAacBpgGoAacBpwGoAakBqAGqAakBqQGqAasBqgGsAasBqwGsAa0BrAGuAa0BrQGuAa8BrgGwAa8BrwGwAbEBsAGyAbEBsQGyAbMBsgG0AbMBswG0AbUBtAG2AbUBtQG2AbcBtgG4AbcBtwG4AbkBuAG6AbkBuQG6AbsBugG8AbsBuwG8Ab0BvAG+Ab0BvQG+Ab8BvgHAAb8BvwHAAcEBwAHCAcEBwQHCAcMBwgHEAcMBwwHEAcUBxAHGAcUBxQHGAccBxgHIAccBxwHIAckByAHKAckByQHKAcsBygHMAcsBywHMAc0BzAHOAc0BzQHOAc8BzgHQAc8BzwHQAdEB0AHSAdEB0QHSAdMB0gHUAdMB0wHUAdUB1AHWAdUB1QHWAdcB1gHYAdcB1wHYAdkB2AHaAdkB2QHaAdsB2gHcAdsB2wHcAd0B3AHeAd0B3QHeAd8B3gHgAd8B3wHgAeEB4AHiAeEB4QHiAeMB4gHkAeMB4wHkAeUB5AHmAeUB5QHmAecB5gHoAecB5wHoAekB6AHqAekB6QHqAesB6gHsAesB6wHsAe0B7AHuAe0B7QHuAe8B7gHwAe8B7wHwAfEB8AHyAfEB8QHyAfMB8gH0AfMB8wH0AfUB9AH2AfUB9QH2AfcB9gH4AfcB9wH4AfkB+AH6AfkB+QH6AfsB+gH8AfsB+wH8Af0B/AH+Af0B/QH+Af8B/gEAAv8B/wEAAgECAAICAgECAQICAgMCAgIEAgMCAwIEAgUCBAIGAgUCBQIGAgcCBgIIAgcCBwIIAgkCCAIKAgkCCQIKAgsCCgIMAgsCCwIMAg0CDAIOAg0CDQIOAg8CDgIQAg8CDwIQAhECEAISAhECEQISAhMCEgIUAhMCEwIUAhUCFAIWAhUCFQIWAhcCFgIYAhcCFwIYAhkCGAIaAhkCGQIaAhsCGgIcAhsCGwIcAh0CHAIeAh0CHQIeAh8CHgIgAh8CHwIgAiECIAIiAiECIQIiAiMCIgIkAiMCIwIkAiUCJAImAiUCJQImAicCJgIoAicCJwIoAikCKAIqAikCKQIqAisCKgIsAisCKwIsAi0CLAIuAi0CLQIuAi8CLgIwAi8CLwIwAjECMAIyAjECMQIyAjMCMgI0AjMCMwI0AjUCNAI2AjUCNQI2AjcCNgI4AjcCNwI4AjkCOAI6AjkCOQI6AjsCOgI8AjsCOwI8Aj0CPAI+Aj0CPQI+Aj8CPgJAAj8CPwJAAkECQAJCAkECQQJCAkMCQgJEAkMCQwJEAkUCRAJGAkUCRQJGAkcCRgJIAkcCRwJIAkkCSAJKAkkCSQJKAksCSgJMAksCSwJMAk0CTAJOAk0CTQJOAk8CTgJQAk8CTwJQAlECUAJSAlECUQJSAlMCUgJUAlMCUwJUAlUCVAJWAlUCVQJWAlcCVgJYAlcCVwJYAlkCWAJaAlkCWQJaAlsCWgJcAlsCWwJcAl0CXAJeAl0CXQJeAl8CXgJgAl8CXwJgAmECYAJiAmECYQJiAmMCYgJkAmMCYwJkAmUCZAJmAmUCZQJmAmcCZgJoAmcCZwJoAmkCaAJqAmkCaQJqAmsCagJsAmsCawJsAm0CbAJuAm0CbQJuAm8CbgJwAm8CbwJwAnECcAJyAnECcQJyAnMCcgJ0AnMCcwJ0AnUCdAJ2AnUCdQJ2AncCdgJ4AncCdwJ4AnkCeAJ6AnkCeQJ6AnsCegJ8AnsCewJ8An0CfAJ+An0CfQJ+An8CfgKAAn8CfwKAAoECgAKCAoECgQKCAoMCggKEAoMCgwKEAoUChAKGAoUChQKGAocChgKIAocChwKIAokCiAKKAokCiQKKAosCigKMAosCiwKMAo0CjAKOAo0CjQKOAo8CjgKQAo8CjwKQApECkAKSApECkQKSApMCkgKUApMCkwKUApUClAKWApUClQKWApcClgKYApcClwKYApkCmAKaApkCmQKaApsCmgKcApsCmwKcAp0CnAKeAp0CnQKeAp8CngKgAp8CnwKgAqECoAKiAqECoQKiAqMCogKkAqMCowKkAqUCpAKmAqUCpQKmAqcCpgKoAqcCpwKoAqkCqAKqAqkCqQKqAqsCqgKsAqsCqwKsAq0CrAKuAq0CrQKuAq8CrgKwAq8CrwKwArECsAKyArECsQKyArMCsgK0ArMCswK0ArUCtAK2ArUCtQK2ArcCtgK4ArcCtwK4ArkCuAK6ArkCuQK6ArsCugK8ArsCuwK8Ar0CvAK+Ar0CvQK+Ar8CvgLAAr8CvwLAAsECwALCAsECwQLCAsMCwgLEAsMCwwLEAsUCxALGAsUCxQLGAscCxgLIAscCxwLIAskCyALKAskCyQLKAssCygLMAssCywLMAs0CzALOAs0CzQLOAs8CzgLQAs8CzwLQAtEC0ALSAtEC0QLSAtMC0gLUAtMC0wLUAtUC1ALWAtUC1QLWAtcC1gLYAtcC1wLYAtkC2ALaAtkC2QLaAtsC2gLcAtsC2wLcAt0C3ALeAt0C3QLeAt8C3gLgAt8C3wLgAuEC4ALiAuEC4QLiAuMC4gLkAuMC4wLkAuUC5ALmAuUC5QLmAucC5gLoAucC5wLoAukC6ALqAukC6QLqAusC6gLsAusC6wLsAu0C7ALuAu0C7QLuAu8C7gLwAu8C7wLwAvEC8ALyAvEC8QLyAvMC8gL0AvMC8wL0AvUC9AL2AvUC9QL2AvcC9gL4AvcC9wL4AvkC+AL6AvkC+QL6AvsC+gL8AvsC+wL8Av0C/AL+Av0C/QL+Av8C/gIAA/8C/wIAAwEDAAMCAwEDAQMCAwMDAgMEAwMDAwMEAwUDBAMGAwUDBQMGAwcDBgMIAwcDBwMIAwkDCAMKAwkDCQMKAwsDCgMMAwsDCwMMAw0DDAMOAw0DDQMOAw8DDgMQAw8DDwMQAxEDEAMSAxEDEQMSAxMDEgMUAxMDEwMUAxUDFAMWAxUDFQMWAxcDFgMYAxcDFwMYAxkDGAMaAxkDGQMaAxsDGgMcAxsDGwMcAx0DHAMeAx0DHQMeAx8DHgMgAx8DHwMgAyEDIAMiAyEDIQMiAyMDIgMkAyMDIwMkAyUDJAMmAyUDJQMmAycDJgMoAycDJwMoAykDKAMqAykDKQMqAysDKgMsAysDKwMsAy0DLAMuAy0DLQMuAy8DLgMwAy8DLwMwAzEDMAMyAzEDMQMyAzMDMgM0AzMDMwM0AzUDNAM2AzUDNQM2AzcDNgM4AzcDNwM4AzkDOAM6AzkDOQM6AzsDOgM8AzsDOwM8Az0DPAM+Az0DPQM+Az8DPgNAAz8DPwNAA0EDQANCA0EDQQNCA0MDQgNEA0MDQwNEA0UDRANGA0UDRQNGA0cDRgNIA0cDRwNIA0kDSANKA0kDSQNKA0sDSgNMA0sDSwNMA00DTANOA00DTQNOA08DTgNQA08DTwNQA1EDUANSA1EDUQNSA1MDUgNUA1MDUwNUA1UDVANWA1UDVQNWA1cDVgNYA1cDVwNYA1kDWANaA1kDWQNaA1sDWgNcA1sDWwNcA10DXANeA10DXQNeA18DXgNgA18DXwNgA2EDYANiA2EDYQNiA2MDYgNkA2MDYwNkA2UDZANmA2UDZQNmA2cDZgNoA2cDZwNoA2kDaANqA2kDaQNqA2sDagNsA2sDawNsA20DbANuA20DbQNuA28DbgNwA28DbwNwA3EDcANyA3EDcQNyA3MDcgN0A3MDcwN0A3UDdAN2A3UDdQN2A3cDdgN4A3cDdwN4A3kDeAN6A3kDeQN6A3sDegN8A3sDewN8A30DfAN+A30DfQN+A38DfgOAA38DfwOAA4EDgAOCA4EDgQOCA4MDggOEA4MDgwOEA4UDhAOGA4UDhQOGA4cDhgOIA4cDhwOIA4kDiAOKA4kDiQOKA4sDigOMA4sDiwOMA40DjAOOA40DjQOOA48DjgOQA48DjwOQA5EDkAOSA5EDkQOSA5MDkgOUA5MDkwOUA5UDlAOWA5UDlQOWA5cDlgOYA5cDlwOYA5kDmAOaA5kDmQOaA5sDmgOcA5sDmwOcA50DnAOeA50DnQOeA58DngOgA58DnwOgA6EDoAOiA6EDoQOiA6MDogOkA6MDowOkA6UDpAOmA6UDpQOmA6cDpgOoA6cDpwOoA6kDqAOqA6kDqQOqA6sDqgOsA6sDqwOsA60DrAOuA60DrQOuA68DrgOwA68DrwOwA7EDsAOyA7EDsQOyA7MDsgO0A7MDswO0A7UDtAO2A7UDtQO2A7cDtgO4A7cDtwO4A7kDuAO6A7kDuQO6A7sDugO8A7sDuwO8A70DvAO+A70DvQO+A78DvgPAA78DvwPAA8EDwAPCA8EDwQPCA8MDwgPEA8MDwwPEA8UDxAPGA8UDxQPGA8cDxgPIA8cDxwPIA8kDyAPKA8kDyQPKA8sDygPMA8sDywPMA80DzAPOA80DzQPOA88DzgPQA88DzwPQA9ED0APSA9ED0QPSA9MD0gPUA9MD0wPUA9UD1APWA9UD1QPWA9cD1gPYA9cD1wPYA9kD2APaA9kD2QPaA9sD2gPcA9sD2wPcA90D3APeA90D3QPeA98D3gPgA98D3wPgA+ED4APiA+ED4QPiA+MD4gPkA+MD4wPkA+UD5APmA+UD5QPmA+cD5gPoA+cD5wPoA+kD6APqA+kD6QPqA+sD6gPsA+sD6wPsA+0D7APuA+0D7QPuA+8D7gPwA+8D7wPwA/ED8APyA/ED8QPyA/MD8gP0A/MD8wP0A/UD9AP2A/UD9QP2A/cD9gP4A/cD9wP4A/kD+AP6A/kD+QP6A/sD+gP8A/sD+wP8A/0D/AP+A/0D/QP+A/8D/gMABP8D/wMABAEEAAQCBAEEAQQCBAMEAgQEBAMEAwQEBAUEBAQGBAUEBQQGBAcEBgQIBAcEBwQIBAkECAQKBAkECQQKBAsECgQMBAsECwQMBA0EDAQOBA0EDQQOBA8EDgQQBA8EDwQQBBEEEAQSBBEEEQQSBBMEEgQUBBMEEwQUBBUEFAQWBBUEFQQWBBcEFgQYBBcEFwQYBBkEGAQaBBkEGQQaBBsEGgQcBBsEGwQcBB0EHAQeBB0EHQQeBB8EHgQgBB8EHwQgBCEEIAQiBCEEIQQiBCMEIgQkBCMEIwQkBCUEJAQmBCUEJQQmBCcEJgQoBCcEJwQoBCkEKAQqBCkEKQQqBCsEKgQsBCsEKwQsBC0ELAQuBC0ELQQuBC8ELgQwBC8ELwQwBDEEMAQyBDEEMQQyBDMEMgQ0BDMEMwQ0BDUENAQ2BDUENQQ2BDcENgQ4BDcENwQ4BDkEOAQ6BDkEOQQ6BDsEOgQ8BDsEOwQ8BD0EPAQ+BD0EPQQ+BD8EPgRABD8EPwRABEEEQARCBEEEQQRCBEMEQgREBEMEQwREBEUERARGBEUERQRGBEcERgRIBEcERwRIBEkESARKBEkESQRKBEsESgRMBEsESwRMBE0ETAROBE0ETQROBE8ETgRQBE8ETwRQBFEEUARSBFEEUQRSBFMEUgRUBFMEUwRUBFUEVARWBFUEVQRWBFcEVgRYBFcEVwRYBFkEWARaBFkEWQRaBFsEWgRcBFsEWwRcBF0EXAReBF0EXQReBF8EXgRgBF8EXwRgBGEEYARiBGEEYQRiBGMEYgRkBGMEYwRkBGUEZARmBGUEZQRmBGcEZgRoBGcEZwRoBGkEaARqBGkEaQRqBGsEagRsBGsEawRsBG0EbARuBG0EbQRuBG8EbgRwBG8EbwRwBHEEcARyBHEEcQRyBHMEcgR0BHMEcwR0BHUEdAR2BHUEdQR2BHcEdgR4BHcEdwR4BHkEeAR6BHkEeQR6BHsEegR8BHsEewR8BH0EfAR+BH0EfQR+BH8EfgSABH8EfwSABIEEgASCBIEEgQSCBIMEggSEBIMEgwSEBIUEhASGBIUEhQSGBIcEhgSIBIcEhwSIBIkEiASKBIkEiQSKBIsEigSMBIsEiwSMBI0EjASOBI0EjQSOBI8EjgSQBI8EjwSQBJEEkASSBJEEkQSSBJMEkgSUBJMEkwSUBJUElASWBJUElQSWBJcElgSYBJcElwSYBJkEmASaBJkEmQSaBJsEmgScBJsEmwScBJ0EnASeBJ0EnQSeBJ8EngSgBJ8EnwSgBKEEoASiBKEEoQSiBKMEogSkBKMEowSkBKUEpASmBKUEpQSmBKcEpgSoBKcEpwSoBKkEqASqBKkEqQSqBKsEqgSsBKsEqwSsBK0ErASuBK0ErQSuBK8ErgSwBK8ErwSwBLEEsASyBLEEsQSyBLMEsgS0BLMEswS0BLUEtAS2BLUEtQS2BLcEtgS4BLcEtwS4BLkEuAS6BLkEuQS6BLsEugS8BLsEuwS8BL0EvAS+BL0EvQS+BL8EvgTABL8EvwTABMEEwATCBMEEwQTCBMMEwgTEBMMEwwTEBMUExATGBMUExQTGBMcExgTIBMcExwTIBMkEyATKBMkEyQTKBMsEygTMBMsEywTMBM0EzATOBM0EzQTOBM8EzgTQBM8EzwTQBNEE0ATSBNEE0QTSBNME0gTUBNME0wTUBNUE1ATWBNUE1QTWBNcE1gTYBNcE1wTYBNkE2ATaBNkE2QTaBNsE2gTcBNsE2wTcBN0E3ATeBN0E3QTeBN8E3gTgBN8E3wTgBOEE4ATiBOEE4QTiBOME4gTkBOME4wTkBOUE5ATmBOUE5QTmBOcE5gToBOcE5wToBOkE6ATqBOkE6QTqBOsE6gTsBOsE6wTsBO0E7ATuBO0E7QTuBO8E7gTwBO8E7wTwBPEE8ATyBPEE8QTyBPME8gT0BPME8wT0BPUE9AT2BPUE9QT2BPcE9gT4BPcE9wT4BPkE+AT6BPkE+QT6BPsE+gT8BPsE+wT8BP0E/AT+BP0E/QT+BP8E/gQABf8E/wQABQEFAAUCBQEFAQUCBQMFAgUEBQMFAwUEBQUFBAUGBQUFBQUGBQcFBgUIBQcFBwUIBQkFCAUKBQkFCQUKBQsFCgUMBQsFCwUMBQ0FDAUOBQ0FDQUOBQ8FDgUQBQ8FDwUQBREFEAUSBREFEQUSBRMFEgUUBRMFEwUUBRUFFAUWBRUFFQUWBRcFFgUYBRcFFwUYBRkFGAUaBRkFGQUaBRsFGgUcBRsFGwUcBR0FHAUeBR0FHQUeBR8FHgUgBR8FHwUgBSEFIAUiBSEFIQUiBSMFIgUkBSMFIwUkBSUFJAUmBSUFJQUmBScFJgUoBScFJwUoBSkFKAUqBSkFKQUqBSsFKgUsBSsFKwUsBS0FLAUuBS0FLQUuBS8FLgUwBS8FLwUwBTEFMAUyBTEFMQUyBTMFMgU0BTMFMwU0BTUFNAU2BTUFNQU2BTcFNgU4BTcFNwU4BTkFOAU6BTkFOQU6BTsFOgU8BTsFOwU8BT0FPAU+BT0FPQU+BT8FPgVABT8FPwVABUEFQAVCBUEFQQVCBUMFQgVEBUMFQwVEBUUFRAVGBUUFRQVGBUcFRgVIBUcFRwVIBUkFSAVKBUkFSQVKBUsFSgVMBUsFSwVMBU0FTAVOBU0FTQVOBU8FTgVQBU8FTwVQBVEFUAVSBVEFUQVSBVMFUgVUBVMFUwVUBVUFVAVWBVUFVQVWBVcFVgVYBVcFVwVYBVkFWAVaBVkFWQVaBVsFWgVcBVsFWwVcBV0FXAVeBV0FXQVeBV8FXgVgBV8FXwVgBWEFYAViBWEFYQViBWMFYgVkBWMFYwVkBWUFZAVmBWUFZQVmBWcFZgVoBWcFZwVoBWkFaAVqBWkFaQVqBWsFagVsBWsFawVsBW0FbAVuBW0FbQVuBW8FbgVwBW8FbwVwBXEFcAVyBXEFcQVyBXMFcgV0BXMFcwV0BXUFdAV2BXUFdQV2BXcFdgV4BXcFdwV4BXkFeAV6BXkFeQV6BXsFegV8BXsFewV8BX0FfAV+BX0FfQV+BX8FfgWABX8FfwWABYEFgAWCBYEFgQWCBYMFggWEBYMFgwWEBYUFhAWGBYUFhQWGBYcFhgWIBYcFhwWIBYkFiAWKBYkFiQWKBYsFigWMBYsFiwWMBY0FjAWOBY0FjQWOBY8FjgWQBY8FjwWQBZEFkAWSBZEFkQWSBZMFkgWUBZMFkwWUBZUFlAWWBZUFlQWWBZcFlgWYBZcFlwWYBZkFmAWaBZkFmQWaBZsFmgWcBZsFmwWcBZ0FnAWeBZ0FnQWeBZ8FngWgBZ8FnwWgBaEFoAWiBaEFoQWiBaMFogWkBaMFowWkBaUFpAWmBaUFpQWmBacFpgWoBacFpwWoBakFqAWqBakFqQWqBasFqgWsBasFqwWsBa0FrAWuBa0FrQWuBa8FrgWwBa8FrwWwBbEFsAWyBbEFsQWyBbMFsgW0BbMFswW0BbUFtAW2BbUFtQW2BbcFtgW4BbcFtwW4BbkFuAW6BbkFuQW6BbsFugW8BbsFuwW8Bb0FvAW+Bb0FvQW+Bb8FvgXABb8FvwXABcEFwAXCBcEFwQXCBcMFwgXEBcMFwwXEBcUFxAXGBcUFxQXGBccFxgXIBccFxwXIBckFyAXKBckFyQXKBcsFygXMBcsFywXMBc0FzAXOBc0FzQXOBc8FzgXQBc8FzwXQBdEF0AXSBdEF0QXSBdMF0gXUBdMF0wXUBdUF1AXWBdUF1QXWBdcF1gXYBdcF1wXYBdkF2AXaBdkF2QXaBdsF2gXcBdsF2wXcBd0F3AXeBd0F3QXeBd8F3gXgBd8F3wXgBeEF4AXiBeEF4QXiBeMF4gXkBeMF4wXkBeUF5AXmBeUF5QXmBecF5gXoBecF5wXoBekF6AXqBekF6QXqBesF6gXsBesF6wXsBe0F7AXuBe0F7QXuBe8F7gXwBe8F7wXwBfEF8AXyBfEF8QXyBfMF8gX0BfMF8wX0BfUF9AX2BfUF9QX2BfcF9gX4BfcF9wX4BfkF+AX6BfkF+QX6BfsF+gX8BfsF+wX8Bf0F/AX+Bf0F/QX+Bf8F/gUABv8F/wUABgEGAAYCBgEGAQYCBgMGAgYEBgMGAwYEBgUGBAYGBgUGBQYGBgcGBgYIBgcGBwYIBgkGCAYKBgkGCQYKBgsGCgYMBgsGCwYMBg0GDAYOBg0GDQYOBg8GDgYQBg8GDwYQBhEGEAYSBhEGEQYSBhMGEgYUBhMGEwYUBhUGFAYWBhUGFQYWBhcGFgYYBhcGFwYYBhkGGAYaBhkGGQYaBhsGGgYcBhsGGwYcBh0GHAYeBh0GHQYeBh8GHgYgBh8GHwYgBiEGIAYiBiEGIQYiBiMGIgYkBiMGIwYkBiUGJAYmBiUGJQYmBicGJgYoBicGJwYoBikGKAYqBikGKQYqBisGKgYsBisGKwYsBi0GLAYuBi0GLQYuBi8GLgYwBi8GLwYwBjEGMAYyBjEGMQYyBjMGMgY0BjMGMwY0BjUGNAY2BjUGNQY2BjcGNgY4BjcGNwY4BjkGOAY6BjkGOQY6BjsGOgY8BjsGOwY8Bj0GPAY+Bj0GPQY+Bj8GPgZABj8GPwZABkEGQAZCBkEGQQZCBkMGQgZEBkMGQwZEBkUGRAZGBkUGRQZGBkcGRgZIBkcGRwZIBkkGSAZKBkkGSQZKBksGSgZMBksGSwZMBk0GTAZOBk0GTQZOBk8GTgZQBk8GTwZQBlEGUAZSBlEGUQZSBlMGUgZUBlMGUwZUBlUGVAZWBlUGVQZWBlcGVgZYBlcGVwZYBlkGWAZaBlkGWQZaBlsGWgZcBlsGWwZcBl0GXAZeBl0GXQZeBl8GXgZgBl8GXwZgBmEGYAZiBmEGYQZiBmMGYgZkBmMGYwZkBmUGZAZmBmUGZQZmBmcGZgZoBmcGZwZoBmkGaAZqBmkGaQZqBmsGagZsBmsGawZsBm0GbAZuBm0GbQZuBm8GbgZwBm8GbwZwBnEGcAZyBnEGcQZyBnMGcgZ0BnMGcwZ0BnUGdAZ2BnUGdQZ2BncGdgZ4BncGdwZ4BnkGeAZ6BnkGeQZ6BnsGegZ8BnsGewZ8Bn0GfAZ+Bn0GfQZ+Bn8GfgaABn8GfwaABoEGgAaCBoEGgQaCBoMGggaEBoMGgwaEBoUGhAaGBoUGhQaGBocGhgaIBocGhwaIBokGiAaKBokGiQaKBosGigaMBosGiwaMBo0GjAaOBo0GjQaOBo8GjgaQBo8GjwaQBpEGkAaSBpEGkQaSBpMGkgaUBpMGkwaUBpUGlAaWBpUGlQaWBpcGlgaYBpcGlwaYBpkGmAaaBpkGmQaaBpsGmgacBpsGmwacBp0GnAaeBp0GnQaeBp8GngagBp8GnwagBqEGoAaiBqEGoQaiBqMGogakBqMGowakBqUGpAamBqUGpQamBqcGpgaoBqcGpwaoBqkGqAaqBqkGqQaqBqsGqgasBqsGqwasBq0GrAauBq0GrQauBq8GrgawBq8GrwawBrEGsAayBrEGsQayBrMGsga0BrMGswa0BrUGtAa2BrUGtQa2BrcGtga4BrcGtwa4BrkGuAa6BrkGuQa6BrsGuga8BrsGuwa8Br0GvAa+Br0GvQa+Br8GvgbABr8GvwbABsEGwAbCBsEGwQbCBsMGwgbEBsMGwwbEBsUGxAbGBsUGxQbGBscGxgbIBscGxwbIBskGyAbKBskGyQbKBssGygbMBssGywbMBs0GzAbOBs0GzQbOBs8GzgbQBs8GzwbQBtEG0AbSBtEG0QbSBtMG0gbUBtMG0wbUBtUG1AbWBtUG1QbWBtcG1gbYBtcG1wbYBtkG2AbaBtkG2QbaBtsG2gbcBtsG2wbcBt0G3AbeBt0G3QbeBt8G3gbgBt8G3wbgBuEG4AbiBuEG4QbiBuMG4gbkBuMG4wbkBuUG5AbmBuUG5QbmBucG5gboBucG5wboBukG6AbqBukG6QbqBusG6gbsBusG6wbsBu0G7AbuBu0G7QbuBu8G7gbwBu8G7wbwBvEG8AbyBvEG8QbyBvMG8gb0BvMG8wb0BvUG9Ab2BvUG9Qb2BvcG9gb4BvcG9wb4BvkG+Ab6BvkG+Qb6BvsG+gb8BvsG+wb8Bv0G/Ab+Bv0G/Qb+Bv8G/gYAB/8G/wYABwEHAAcCBwEHAQcCBwMHAgcEBwMHAwcEBwUHBAcGBwUHBQcGBwcHBgcIBwcHBwcIBwkHCAcKBwkHCQcKBwsHCgcMBwsHCwcMBw0HDAcOBw0HDQcOBw8HDgcQBw8HDwcQBxEHEAcSBxEHEQcSBxMHEgcUBxMHEwcUBxUHFAcWBxUHFQcWBxcHFgcYBxcHFwcYBxkHGAcaBxkHGQcaBxsHGgccBxsHGwccBx0HHAceBx0HHQceBx8HHgcgBx8HHwcgByEHIAciByEHIQciByMHIgckByMHIwckByUHJAcmByUHJQcmBycHJgcoBycHJwcoBykHKAcqBykHKQcqBysHKgcsBysHKwcsBy0HLAcuBy0HLQcuBy8HLgcwBy8HLwcwBzEHMAcyBzEHMQcyBzMHMgc0BzMHMwc0BzUHNAc2BzUHNQc2BzcHNgc4BzcHNwc4BzkHOAc6BzkHOQc6BzsHOgc8BzsHOwc8Bz0HPAc+Bz0HPQc+Bz8HPgdABz8HPwdAB0EHQAdCB0EHQQdCB0MHQgdEB0MHQwdEB0UHRAdGB0UHRQdGB0cHRgdIB0cHRwdIB0kHSAdKB0kHSQdKB0sHSgdMB0sHSwdMB00HTAdOB00HTQdOB08HTgdQB08HTwdQB1EHUAdSB1EHUQdSB1MHUgdUB1MHUwdUB1UHVAdWB1UHVQdWB1cHVgdYB1cHVwdYB1kHWAdaB1kHWQdaB1sHWgdcB1sHWwdcB10HXAdeB10HXQdeB18HXgdgB18HXwdgB2EHYAdiB2EHYQdiB2MHYgdkB2MHYwdkB2UHZAdmB2UHZQdmB2cHZgdoB2cHZwdoB2kHaAdqB2kHaQdqB2sHagdsB2sHawdsB20HbAduB20HbQduB28HbgdwB28HbwdwB3EHcAdyB3EHcQdyB3MHcgd0B3MHcwd0B3UHdAd2B3UHdQd2B3cHdgd4B3cHdwd4B3kHeAd6B3kHeQd6B3sHegd8B3sHewd8B30HfAd+B30HfQd+B38HfgeAB38HfweAB4EHgAeCB4EHgQeCB4MHggeEB4MHgweEB4UHhAeGB4UHhQeGB4cHhgeIB4cHhweIB4kHiAeKB4kHiQeKB4sHigeMB4sHiweMB40HjAeOB40HjQeOB48HjgeQB48HjweQB5EHkAeSB5EHkQeSB5MHkgeUB5MHkweUB5UHlAeWB5UHlQeWB5cHlgeYB5cHlweYB5kHmAeaB5kHmQeaB5sHmgecB5sHmwecB50HnAeeB50HnQeeB58HngegB58HnwegB6EHoAeiB6EHoQeiB6MHogekB6MHowekB6UHpAemB6UHpQemB6cHpgeoB6cHpweoB6kHqAeqB6kHqQeqB6sHqgesB6sHqwesB60HrAeuB60HrQeuB68HrgewB68HrwewB7EHsAeyB7EHsQeyB7MHsge0B7MHswe0B7UHtAe2B7UHtQe2B7cHtge4B7cHtwe4B7kHuAe6B7kHuQe6B7sHuge8B7sHuwe8B70HvAe+B70HvQe+B78HvgfAB78HvwfAB8EHwAfCB8EHwQfCB8MHwgfEB8MHwwfEB8UHxAfGB8UHxQfGB8cHxgfIB8cHxwfIB8kHyAfKB8kHyQfKB8sHygfMB8sHywfMB80HzAfOB80HzQfOB88HzgfQB88HzwfQB9EH0AfSB9EH0QfSB9MH0gfUB9MH0wfUB9UH1AfWB9UH1QfWB9cH1gfYB9cH1wfYB9kH2AfaB9kH2QfaB9sH2gfcB9sH2wfcB90H3AfeB90H3QfeB98H3gfgB98H3wfgB+EH4AfiB+EH4QfiB+MH4gfkB+MH4wfkB+UH5AfmB+UH5QfmB+cH5gfoB+cH5wfoB+kH6AfqB+kH6QfqB+sH6gfsB+sH6wfsB+0H7AfuB+0H7QfuB+8H7gfwB+8H7wfwB/EH8AfyB/EH8QfyB/MH8gf0B/MH8wf0B/UH9Af2B/UH9Qf2B/cH9gf4B/cH9wf4B/kH+Af6B/kH+Qf6B/sH+gf8B/sH+wf8B/0H/Af+B/0H/Qf+B/8H/gcACP8H/wcACAEIAAgCCAEIAQgCCAMIAggECAMIAwgECAUIBAgGCAUIBQgGCAcIBggICAcIBwgICAkICAgKCAkICQgKCAsICggMCAsICwgMCA0IDAgOCA0IDQgOCA8IDggQCA8IDwgQCBEIEAgSCBEIEQgSCBMIEggUCBMIEwgUCBUIFAgWCBUIFQgWCBcIFggYCBcIFwgYCBkIGAgaCBkIGQgaCBsIGggcCBsIGwgcCB0IHAgeCB0IHQgeCB8IHgggCB8IHwggCCEIIAgiCCEIIQgiCCMIIggkCCMIIwgkCCUIJAgmCCUIJQgmCCcIJggoCCcIJwgoCCkIKAgqCCkIKQgqCCsIKggsCCsIKwgsCC0ILAguCC0ILQguCC8ILggwCC8ILwgwCDEIMAgyCDEIMQgyCDMIMgg0CDMIMwg0CDUINAg2CDUINQg2CDcINgg4CDcINwg4CDkIOAg6CDkIOQg6CDsIOgg8CDsIOwg8CD0IPAg+CD0IPQg+CD8IPghACD8IPwhACEEIQAhCCEEIQQhCCEMIQghECEMIQwhECEUIRAhGCEUIRQhGCEcIRghICEcIRwhICEkISAhKCEkISQhKCEsISghMCEsISwhMCE0ITAhOCE0ITQhOCE8ITghQCE8ITwhQCFEIUAhSCFEIUQhSCFMIUghUCFMIUwhUCFUIVAhWCFUIVQhWCFcIVghYCFcIVwhYCFkIWAhaCFkIWQhaCFsIWghcCFsIWwhcCF0IXAheCF0IXQheCF8IXghgCF8IXwhgCGEIYAhiCGEIYQhiCGMIYghkCGMIYwhkCGUIZAhmCGUIZQhmCGcIZghoCGcIZwhoCGkIaAhqCGkIaQhqCGsIaghsCGsIawhsCG0IbAhuCG0IbQhuCG8IbghwCG8IbwhwCHEIcAhyCHEIcQhyCHMIcgh0CHMIcwh0CHUIdAh2CHUIdQh2CHcIdgh4CHcIdwh4CHkIeAh6CHkIeQh6CHsIegh8CHsIewh8CH0IfAh+CH0IfQh+CH8IfgiACH8IfwiACIEIgAiCCIEIgQiCCIMIggiECIMIgwiECIUIhAiGCIUIhQiGCIcIhgiICIcIhwiICIkIiAiKCIkIiQiKCIsIigiMCIsIiwiMCI0IjAiOCI0IjQiOCI8IjgiQCI8IjwiQCJEIkAiSCJEIkQiSCJMIkggAAJMIkwgAAAEA"),
+"primitive": 3,
+"uv_scale": Vector4(0, 0, 0, 0),
+"vertex_count": 2196,
+"vertex_data": PackedByteArray("zGaeQRsv3b6BEqPBRTmmQRsv3b7dhKrBNkSfQRsv3b6dJKLB8yunQRsv3b5igKnBhCOgQRsv3b64NKHBQQuoQRsv3b5+kKjB0gKhQRsv3b7SRKDBkeqoQRsv3b6WoKfBIOKhQRsv3b7sVJ/B38mpQRsv3b6wsKbBbsGiQRsv3b4GZZ7BK6mqQRsv3b7MwKXBvKCjQRsv3b4hdZ3BeYirQRsv3b7m0KTB9n2kQRsv3b59h5zBFH2sQRsv3b7VyaPBrVSlQRsv3b7qlJvBCnCtQRsv3b6pt6LBECymQRsv3b7PnprBm0yuQRsv3b6mu6HB6AOnQRsv3b4uqJnBcSSvQRsv3b4HxaDBwdunQRsv3b6NsZjBTPyvQRsv3b5kzp/BmbOoQRsv3b7rupfBJNSwQRsv3b7C157BToupQRsv3b5zxJbBb62xQRsv3b56353B71+qQRsv3b6v0JXBTaGyQRsv3b5Tx5zBhCyrQRsv3b7U1ZTB84qzQRsv3b5tqZvBrfurQRsv3b7d15PBHFq0QRsv3b52q5rB18qsQRsv3b7l2ZLBRim1QRsv3b5+rZnBAZqtQRsv3b7t25HBcPi1QRsv3b6Gr5jBKmmuQRsv3b723ZDBmce2QRsv3b6PsZfBNDavQRsv3b6j4o/Bjay3QRsv3b6AmJbBRfuvQRsv3b7r4o7BF5O4QRsv3b60bZXBX7+wQRsv3b4k3o3BFGG5QRsv3b7cW5TBW4SxQRsv3b4z2IzBECa6QRsv3b7rVZPBVkmyQRsv3b5C0ovBC+u6QRsv3b76T5LBUg6zQRsv3b5RzIrBB7C7QRsv3b4JSpHB0dCzQRsv3b68yYnBdY68QRsv3b6lIZDBiom0QRsv3b7TwYjBQ2u9QRsv3b7R5o7BukG1QRsv3b5itIfBLCy+QRsv3b6xzI3BsPq1QRsv3b7SpYbBIuW+QRsv3b4hvozBprO2QRsv3b5Bl4XBGJ6/QRsv3b6Qr4vB42u3QRsv3b7BiYTBcV7AQRsv3b4jlorBeR64QRsv3b6pfoPBcTjBQRsv3b4ST4nBcsa4QRsv3b4wa4LByP7BQRsv3b71CojBHnG5QRsv3b5kU4HBdanCQRsv3b4n84bByhu6QRsv3b6XO4DBIVTDQRsv3b5a24XBWca6QRsv3b72R37B+P/DQRsv3b6lwYTBxmy7QRsv3b5+JHzBctDEQRsv3b5waIPB7AK8QRsv3b767XnBU43FQRsv3b6rBoLBhJy8QRsv3b7sqnfB6ybGQRsv3b4k5YDBGza9QRsv3b7fZ3XBgsDGQRsv3b48h3/Bz869QRsv3b40KHPBNmPHQRsv3b6DIX3BAWC+QRsv3b7773DBTh/IQRsv3b4+PXrBVeK+QRsv3b4JpW7B3MDIQRsv3b65anfBb2e/QRsv3b4LTmzB+EXJQRsv3b6yE3XBiey/QRsv3b4L92nBEsvJQRsv3b6yvHLBrG7AQRsv3b71rWfBjm7KQRsv3b4Z12/Bet3AQRsv3b6ZWmXBawXLQRsv3b52s2zBNEnBQRsv3b6H9GLBDHrLQRsv3b5wG2rBubXBQRsv3b4EimDBkebLQRsv3b7tsGfBKyHCQRsv3b7GJV7BIF7MQRsv3b4fBmXBo4PCQRsv3b7tv1vBF9PMQRsv3b4FLmLBCOLCQRsv3b5wUFnBKzjNQRsv3b4Fk1/BBEHDQRsv3b4W3VbBKJfNQRsv3b6jH13BaZ/DQRsv3b6ubVTBLvzNQRsv3b7Mg1rBtffDQRsv3b4+AVLBK2TOQRsv3b4zqFfBuEjEQRsv3b5xjE/BBr7OQRsv3b6H8FTBhJrEQRsv3b6yEU3B0g/PQRsv3b7IdVLBM+zEQRsv3b7fl0rB1GLPQRsv3b6p8U/BmTvFQRsv3b4eJkjBa7/PQRsv3b46FE3Bon/FQRsv3b4FrkXBcg7QQRsv3b5dOkrBpcTFQRsv3b46LUPBdVPQQRsv3b6SuUfBqAnGQRsv3b5wrEDBeJjQQRsv3b7IOEXB/03GQRsv3b4WMj7BXeTQQRsv3b7zdULBgonGQRsv3b4xtzvB2SnRQRsv3b7gkj/B7sHGQRsv3b5iNDnBFmXRQRsv3b6B8DzBmfrGQRsv3b7FrjbBwZ3RQRsv3b7kajrBHjPHQRsv3b7tKjTBANjRQRsv3b4i0zfBsmjHQRsv3b53rTHBtBXSQRsv3b6c8TTB+pTHQRsv3b5EKy/B00fSQRsv3b4iHzLBx8HHQRsv3b7voSzBoHTSQRsv3b7NlS/BlO7HQRsv3b6ZGCrBbaHSQRsv3b53DC3BCxvIQRsv3b5XlCfBrdHSQRsv3b4eTyrBj0DIQRsv3b7oESXB4vzSQRsv3b60bCfBzmHIQRsv3b5KiSLBMCDTQRsv3b4+vSTBOoPIQRsv3b45/R/BnUHTQRsv3b4tMSLBqKTIQRsv3b4ncR3BCmPTQRsv3b4apR/BvcXIQRsv3b5A7BrB9YfTQRsv3b6QzxzBRt3IQRsv3b6zZhjBIqPTQRsv3b5B7hnBzPPIQRsv3b6/2RXBC7rTQRsv3b5AVhfBWQrJQRsv3b7RSxPBmNDTQRsv3b5KyBTB4iDJQRsv3b4+vhDBRefTQRsv3b6yNhLBuTbJQRsv3b4IOA7Bv//TQRsv3b4HWQ/BvULJQRsv3b5rsAvBoQ3UQRsv3b7pfQzB607JQRsv3b5hIQnBzxnUQRsv3b7o7gnBGlvJQRsv3b5ZkgbB/iXUQRsv3b7gXwfBRWfJQRsv3b7ZAwTBRTLUQRsv3b5ZywTBp3LJQRsv3b6zegHBpz7UQRsv3b6T/wHB4nbJQRsv3b5R4f3AckPUQRsv3b7Zcf7AK3vJQRsv3b5zwvjAu0fUQRsv3b4LU/nAdH/JQRsv3b6Xo/PABEzUQRsv3b4fNPTAuoPJQRsv3b6zh+7AY1DUQRsv3b7S9u7ABYbJQRsv3b4zdenAz1LUQRsv3b4xWenAEoLJQRsv3b65X+TAq07UQRsv3b7w2ePAG37JQRsv3b7YQN/AtErUQRsv3b4Pu97AJHrJQRsv3b74IdrAvUbUQRsv3b4vnNnAM3bJQRsv3b5zCdXAhkLUQRsv3b4jPNTAFG7JQRsv3b7l98/AfTnUQRsv3b51mc7AkmHJQRsv3b5w4MrAVizUQRsv3b5WOMnAAFXJQRsv3b5twsXAxB/UQRsv3b5TGsTAb0jJQRsv3b5rpMDAMxPUQRsv3b5R/L7A/TvJQRsv3b7HkbvAVwXUQRsv3b7FabnACSjJQRsv3b7LgrbAPe/TQRsv3b6Aw7PAhxLJQRsv3b68aLHAWtnTQRsv3b4Akq7A/vzIQRsv3b6XTKzA0sPTQRsv3b7rdanAh+fIQRsv3b6RNKfAka3TQRsv3b6WL6TAhc/IQRsv3b5MJ6LAhZLTQRsv3b7lhJ7A4bDIQRsv3b4EGJ3AZXHTQRsv3b6VBpnABJLIQRsv3b7f/pfAiFLTQRsv3b5w7ZPAJ3PIQRsv3b675ZLAqjPTQRsv3b471I7AmFTIQRsv3b692I3AqBHTQRsv3b58PonADC7IQRsv3b6p0YjAbubSQRsv3b4NlIPAkQXIQRsv3b4nv4PAHr3SQRsv3b5EzXzAAt3HQRsv3b6DVH3Aj5TSQRsv3b54o3LArrTHQRsv3b7GOHPAsWnSQRsv3b6Q6WfA+IfHQRsv3b5gMmnADTfSQRsv3b4dkFzArFXHQRsv3b7uI1/AUgHSQRsv3b78zFHAEiPHQRsv3b5sBVXAuM7RQRsv3b56rkfAlfDGQRsv3b6Y7ErA+JrRQRsv3b61VT3AvrzGQRsv3b7k70DAyF/RQRsv3b4Q+jHAVYDGQRsv3b43+TbA1BzRQRsv3b7o5CbAVkPGQRsv3b4W6SzA1d/QQRsv3b6m1BzAVwbGQRsv3b7u2CLA1qLQQRsv3b6fxBLAWMnFQRsv3b64yBjA3WXQQRsv3b4ztQjAX4zFQRsv3b58uA7A5yjQQRsv3b5CS/2/Zk/FQRsv3b5OqATA7uvPQRsv3b7mKum/bRLFQRsv3b4wMPW/+K7PQRsv3b5CC9W/dtXEQRsv3b6lD+G/ltvOQRsv3b4qtMK/hJjEQRsv3b4W78y/NPfNQRsv3b7oj7C/k1vEQRsv3b6Zzri/zxLNQRsv3b7kap6/oh7EQRsv3b7oraS/cC7MQRsv3b46R4y/uuHDQRsv3b4gjZC/GUrLQRsv3b7oRXS/06TDQRsv3b602Hi/wGXKQRsv3b7I/E+/82fDQRsv3b6fllC/c4HJQRsv3b6vtCu/GyvDQRsv3b5CVCi/K53IQRsv3b7Kawe/Te7CQRsv3b5JEQC/8LjHQRsv3b4MRsa+jLHCQRsv3b4Sma++x9TGQRsv3b7Uc3u+YHTCQRsv3b7jgDy+U/LFQRsv3b5lNN29ND3CQRsv3b7gxC06bx3FQRsv3b5s1Je81ZzCQRsv3b5opzo+qALGQRsv3b4+S2c9dwLDQRsv3b5ErKw+kQTHQRsv3b4BwjQ+u2bDQRsv3b4wt/o+5wrIQRsv3b4+Pps+GsvDQRsv3b75XSQ/ZRHJQRsv3b6BH9w+hy/EQRsv3b7GXks/+RfKQRsv3b5ngQ4/AJTEQRsv3b7SXnI/lx7LQRsv3b4h8i4/gvjEQRsv3b6brow/UyXMQRsv3b4vaU8/6VzFQRsv3b6NK6A/YizNQRsv3b66BnA/6MDFQRsv3b6fqLM/cTPOQRsv3b57Zog/iiTGQRsv3b4wJ8c/UDrPQRsv3b4m1Jg/t4fGQRsv3b4tpto/OUHQQRsv3b4pW6k/LerGQRsv3b6VJO4/JTjRQRsv3b78Z7o/a0vHQRsv3b5D0ABAVp3RQRsv3b4Sr84/HqrHQRsv3b7kggpAsgbSQRsv3b5sSuQ/zgDIQRsv3b5KFBRAiHnSQRsv3b6h3v0/9ETIQRsv3b7Qpx1AYN/SQRsv3b7TPA1AAnjIQRsv3b6JUidAMyzTQRsv3b700RtAVJjIQRsv3b6ZCjFA4V3TQRsv3b6kySpAP6XIQRsv3b6xyTpA7nHTQRsv3b77/DlAIp7IQRsv3b54hkRAnmbTQRsv3b5hWUlAhILIQRsv3b6mNk5AhDrTQRsv3b6lylhA+lHIQRsv3b6F0VdADO3SQRsv3b6nIWhABA3IQRsv3b4DT2FAon/SQRsv3b4UMndALLTHQRsv3b7SpWpA5/PRQRsv3b5j8oJAV0jHQRsv3b5LzXNAukvRQRsv3b5dFopAEcrGQRsv3b4nv3xAO4rQQRsv3b7e8pBA/jrGQRsv3b5cvIJAj7PPQRsv3b5jf5dAWZzFQRsv3b6C+YZAssjOQRsv3b53xp1AduzEQRsv3b4OJItALObNQRsv3b4IK6NAnzXEQRsv3b6eWI9AZCDNQRsv3b73t6dAkXzDQRsv3b6bjZNAxGDMQRsv3b46E6xAdcLCQRsv3b7lwZdA86LLQRsv3b78XLBAwAfCQRsv3b6Y9ZtA8OXKQRsv3b7wnbRAsEzBQRsv3b6rKKBACynKQRsv3b6Q27hATJHAQRsv3b7zWqRA+mvJQRsv3b50F71AmdW/QRsv3b7YjKhAO6/IQRsv3b5YT8FAshy/QRsv3b5ErqxAstTHQRsv3b7iLMZADlm+QRsv3b4fr7BA6uzGQRsv3b4I78pA/JO9QRsv3b5Gs7RAPQ3GQRsv3b4OfM9Amce8QRsv3b5En7hAaCTFQRsv3b6c9dNAFfm7QRsv3b5EjrxAq0bEQRsv3b54LthA0ye7QRsv3b68bsBAqF7DQRsv3b7yetxAglK6QRsv3b5HSsRAmH7CQRsv3b6OiOBAsn25QRsv3b6VI8hAYZnBQRsv3b4YreRAqKS4QRsv3b5r7MtAkq3AQRsv3b5iyuhAjsm3QRsv3b7Suc9ARNC/QRsv3b6VoexAje+2QRsv3b5pgtNAvue+QRsv3b4uqvBA3hG2QRsv3b7PPNdAavq9QRsv3b5tqPRAoTK1QRsv3b7v+9pAMBq9QRsv3b7La/hAHFS0QRsv3b77t95AkTK8QRsv3b7DTvxAqHOzQRsv3b7ZaeJA6US7QRsv3b42HABBYZGyQRsv3b5ZG+ZAk166QRsv3b5z/QFB5a6xQRsv3b63zelAYnq5QRsv3b402gNB1sywQRsv3b7weu1AEo24QRsv3b4kyAVBSuivQRsv3b7CH/FA8J63QRsv3b43rgdB5AKvQRsv3b4SyPRAjLm2QRsv3b5cgglBxB2uQRsv3b5Ib/hAYtG1QRsv3b4pXAtBgTitQRsv3b4vEfxAW+K0QRsv3b7fQA1BXVGsQRsv3b7Xrf9AffSzQRsv3b60HA9BoWmrQRsv3b5zpgFBxAyzQRsv3b427BBBV4KqQRsv3b4YdQNBeSCyQRsv3b7WxBJBA5ypQRsv3b4rPQVBLh2xQRsv3b64xRRBu6+oQRsv3b7K+gZBrRiwQRsv3b6WsRZBTMGnQRsv3b6GvAhBPyqvQRsv3b5QcxhBYNOmQRsv3b5LfQpBjDauQRsv3b7yPhpBhualQRsv3b7/NgxBpimtQRsv3b7NMxxBevOkQRsv3b475Q1BDBysQRsv3b6rER5BEv6jQRsv3b60lw9BpCarQRsv3b4kxB9BswmjQRsv3b5YSBFBmSaqQRsv3b5ViSFBDhWiQRsv3b7H7xJBZw6pQRsv3b7wbSNBkBqhQRsv3b7GjRRB2funQRsv3b4qNCVB7R2gQRsv3b5YLxZBNv+mQRsv3b681SZBciOfQRsv3b5mzRdBheylQRsv3b5YmyhBtSWeQRsv3b7NXRlBOsekQRsv3b50aipB+SKdQRsv3b6Q6hpBTbWjQRsv3b6uDixBCx+cQRsv3b4seRxBl6+iQRsv3b4FoC1BgR6bQRsv3b5fAR5BmIOhQRsv3b5naS9BiRaaQRsv3b4Sdh9BYVGgQRsv3b74GjFB0QqZQRsv3b4v8CBBqkWfQRsv3b4UlTJBnwGYQRsv3b7HZiJBGyCeQRsv3b4zMzRBoPWWQRsv3b6DzSNBoOOcQRsv3b4d2zVB7+OVQRsv3b5aLiVB8L2bQRsv3b75VTdBttGUQRsv3b7pjyZBtp2aQRsv3b54yThB7cCTQRsv3b505idBlFeZQRsv3b4xYjpBNamSQRsv3b4LLClBLhiYQRsv3b7g1jtBPo6RQRsv3b5+dSpBCPqWQRsv3b4HJD1By3eQQRsv3b7ZtytB6qiVQRsv3b6WqD5B2FmPQRsv3b5R4CxBzFKUQRsv3b74DEBB6DaOQRsv3b4dDi5B3S+TQRsv3b7COkFB3hONQRsv3b4DPC9B7g2SQRsv3b6DZ0JB9/CLQRsv3b5xajBBMeyQQRsv3b66lENBIs6KQRsv3b7SmDFBt8mPQRsv3b6+wkRBPquJQRsv3b5yxzJBUqiOQRsv3b7Q70VBhoiIQRsv3b6e9jNBzIaNQRsv3b68HUdBxmWHQRsv3b7SJTVBpWWMQRsv3b5ES0hBLkOGQRsv3b7TVTZBD0WLQRsv3b4seUlBmCCFQRsv3b4QhjdB/CSKQRsv3b7IpkpBOv6DQRsv3b5atzhBnQWJQRsv3b7s1EtBy9uCQRsv3b4/6TlBKeiHQRsv3b6PAU1BhLmBQRsv3b53HTtB5s6GQRsv3b40LE5BEZeAQRsv3b7iVDxBaL6FQRsv3b5LUE9B5N9+QRsv3b7FmT1BvfCEQRsv3b6uL1BBiZ18QRsv3b6VCT9BH16EQRsv3b6r6lBBJXJ6QRsv3b5FmkBBys6DQRsv3b4uuVFB7l54QRsv3b6UTUJBwU2DQRsv3b4hjFJB1Wh2QRsv3b77I0RBytiCQRsv3b5EZ1NBo5J0QRsv3b5NG0ZB+W2CQRsv3b6RS1RBG+ByQRsv3b6LMkhBiw2CQRsv3b4qOVVBqlNxQRsv3b58ZkpBU7WBQRsv3b4nNFZBU+9vQRsv3b40tExBKGWBQRsv3b66PVdBRbRuQRsv3b6+F09BqRuBQRsv3b6kW1hB16FtQRsv3b53jVFBZNmAQRsv3b4CjFlBhbhsQRsv3b75EVRBe56AQRsv3b6n0lpBlPVrQRsv3b6colZBQGyAQRsv3b4yJFxBll1rQRsv3b77M1lB2zmAQRsv3b6g4l1BvNNqQRsv3b5PultBqfZ/QRsv3b7FLWBB6E1qQRsv3b74PV5BSHV/QRsv3b4WnGJB/MlpQRsv3b5nwWBBYfR+QRsv3b7PEGVB4EdpQRsv3b6zRGNBI3R+QRsv3b7kimdBTcZoQRsv3b7Mx2VBvvN9QRsv3b4fCGpBp0VoQRsv3b4IS2hBLHR9QRsv3b74hWxBY8VnQRsv3b46zmpBuvR8QRsv3b4MBW9BwEVnQRsv3b52UW1BunV8QRsv3b4ZhXFBQsZmQRsv3b671G9B3/Z7QRsv3b4kBXRBZkdmQRsv3b4RWHJBiXh7QRsv3b7ThXZBl8hlQRsv3b4s23RBuvl6QRsv3b7uCHlBx0llQRsv3b5FXndB6Hp6QRsv3b4PjHtB9spkQRsv3b5g4XlBF/x5QRsv3b4uD35BJUxkQRsv3b56ZHxBRX15QRsv3b4kSYBBVM1jQRsv3b6U535BdP54QRsv3b6zioFBgk5jQRsv3b5XtYBBoX94QRsv3b5CzIJBsc9iQRsv3b7j9oFBzAB4QRsv3b7WDYRB21BiQRsv3b5vOINB8YF3QRsv3b5vT4VBRtRhQRsv3b6RdIRB7Ot2QRsv3b5zyIZBBVxhQRsv3b7SfYVBjPR0QRsv3b7kCIpB7QpgQRsv3b7DXYZBR4NxQRsv3b6xt4xBcYleQRsv3b7UZodBrQFwQRsv3b7WwI1B8gddQRsv3b7mb4hBJYBuQRsv3b7uyY5BcIZbQRsv3b74eIlBn/5sQRsv3b4C049B7gRaQRsv3b4JgopBGn1rQRsv3b4W3JBBbINYQRsv3b4ai4tBl/tpQRsv3b4o5ZFB6gFXQRsv3b4slIxBEnpoQRsv3b487pJBZ4BVQRsv3b49nY1Bj/hmQRsv3b5N95NB5P5TQRsv3b5Ppo5BCndlQRsv3b5gAJVBYH1SQRsv3b5gr49Bg/VjQRsv3b5zCZZB3ftQQRsv3b5xuJBBAHRiQRsv3b6EEpdBWXpPQRsv3b6CwZFBfPJgQRsv3b6VG5hB1vhNQRsv3b6TypJB+XBfQRsv3b6mJJlBb3xMQRsv3b4w0JNBC7tdQRsv3b4ZUZpBKfJKQRsv3b48zJRBqMdbQRsv3b6hkJtByFRJQRsv3b5QxpVBOftZQRsv3b6Rp5xBM7RHQRsv3b5bwpZBkE9YQRsv3b5Kqp1BNBhGQRsv3b6SuJdBE1JWQRsv3b6H2Z5BvWNEQRsv3b4hpZhBz0RUQRsv3b5o959BYadCQRsv3b4RlplBdYhSQRsv3b5X6KBBfu5AQRsv3b4lhZpBh6hQQRsv3b5f7KFBgzA/QRsv3b4lbZtB+o5OQRsv3b7aA6NBXmI9QRsv3b7fUJxBC4xMQRsv3b7zAaRBiI87QRsv3b7xNp1BNblKQRsv3b4F6KRBfcA5QRsv3b4qG55Bhr9IQRsv3b4H4aVBeus3QRsv3b6M+J5B7ZVGQRsv3b5d5qZBawk2QRsv3b7k0p9B8olEQRsv3b7k06dBoyM0QRsv3b7vrqBBJ6RCQRsv3b7wr6hBKkAyQRsv3b7viaFBrqZAQRsv3b6llqlB0FswQRsv3b7VX6JB2nQ+QRsv3b6MjqpBk2ouQRsv3b60MKNBvU88QRsv3b59datBuHQsQRsv3b6KA6RB4lk6QRsv3b5TSKxB3n4qQRsv3b5g1qRBCGQ4QRsv3b4pG61BvI4oQRsv3b7YpqVBhjM2QRsv3b5CBq5BwpImQRsv3b5yb6ZB9e8zQRsv3b6J665BxI8kQRsv3b6eOadBjOYxQRsv3b46uK9BM4wiQRsv3b4EBKhBAeMvQRsv3b6egrBB6IkgQRsv3b7rzahBbdItQRsv3b4cUrFBhIkeQRsv3b5ilKlBCJErQRsv3b6KMbJBun4cQRsv3b5bVapBJFMpQRsv3b6aBbNBZW8aQRsv3b4HGKtBz0MnQRsv3b5GyLNBEWAYQRsv3b6y2qtBdTQlQRsv3b7zirRBIVIWQRsv3b7anKxB1BYjQRsv3b7gUrVBZkUUQRsv3b66W61Bfs4gQRsv3b5LJ7ZB0y8SQRsv3b7uFa5BwoweQRsv3b4S8bZBUxYQQRsv3b6F0a5BQnMcQRsv3b6prLdB0/wNQRsv3b4bja9BwlkaQRsv3b4/aLhBVOMLQRsv3b6ySLBBOUAYQRsv3b7ZI7lB6skJQRsv3b4+BLFBwiUWQRsv3b7D37lBVLAHQRsv3b6uv7FBMAsUQRsv3b6Lm7pBqJYFQRsv3b4le7JBhPERQRsv3b4CV7tB/HwDQRsv3b6cNrNB2dcPQRsv3b54ErxBX2MBQRsv3b4O8rNBor0NQRsv3b4gzrxBcZP+QBsv3b5orbRBmaILQRsv3b72ib1ByV/6QBsv3b6vaLVB/YcJQRsv3b6CRb5B/Cv2QBsv3b78I7ZBHG4HQRsv3b7NAL9BWvjxQBsv3b5C37ZBX1MFQRsv3b5mvL9BlsTtQBsv3b5kmrdBlTcDQRsv3b4yeMBBVpDpQBsv3b5xVbhBfxwBQRsv3b6UM8FB/lvlQBsv3b6BELlByQP+QBsv3b7L7sFB7SfhQBsv3b5yy7lBFMr5QBsv3b62qsJBAfPcQBsv3b4dhrpBapD1QBsv3b40ZsNBBr7YQBsv3b7MQLtBVVfxQBsv3b6ZIcRBzIjUQBsv3b4p+7tBJBntQBsv3b6C3cRBA1PQQBsv3b4ytbxBv9foQBsv3b6NmcVBmhzMQBsv3b7Abr1BSpDkQBsv3b4JVsZBsu3HQBsv3b4lJr5BNOnfQBsv3b7FIcdBQvPDQBsv3b6xzL5BOWrYQBsv3b6VT8hBSdm/QBsv3b70Pr9Bx7DNQBsv3b7+eclBsYq7QBsv3b4ig79B2PjBQBsv3b4iMcpByvG2QBsv3b4Mk79Br7W3QBsv3b5qX8pBvfmxQBsv3b4DjL9BKQuxQBsv3b4rWMpBxdysQBsv3b7whL9BFNurQBsv3b78UMpB2r+nQBsv3b7CfL9BpaumQBsv3b6ySMpBSaGiQBsv3b6TdL9BFI2hQBsv3b6DQMpBuoKdQBsv3b5jbL9Bc26cQBsv3b5TOMpBamSYQBsv3b4zZL9BTk2XQBsv3b4fMMpBdUaTQBsv3b7ZW79BsSiSQBsv3b66J8pBRiiOQBsv3b5FU79BrwaNQBsv3b4fH8pBwAmJQBsv3b6wSr9BKeiHQBsv3b6KFspBOuuDQBsv3b4bQr9Bo8mCQBsv3b71DcpBYpl9QBsv3b6GOb9BV1Z7QBsv3b5gBcpBVlxzQBsv3b7yML9BSxlxQBsv3b7M/MlBsB9pQBsv3b5dKL9BKthmQBsv3b409MlBQ+NeQBsv3b6oH79BBJVcQBsv3b5568lBd6ZUQBsv3b7gFr9B2VVSQBsv3b6v4slBdWlKQBsv3b4YDr9B1xhIQBsv3b7n2clBdyxAQBsv3b5QBb9Bt9s9QBsv3b4f0clBde81QBsv3b6H/L5BtZ4zQBsv3b5WyMlBcbIrQBsv3b6/875B02EpQBsv3b6Ov8lBb3UhQBsv3b736r5B0SQfQBsv3b7GtslBbTgXQBsv3b4v4r5Bz+cUQBsv3b7+rclBp/sMQBsv3b5n2b5BZqgKQBsv3b40pclBB78CQBsv3b6M0L5BsGcAQBsv3b5UnMlBYQTxPxsv3b6lx75BiFLsPxsv3b5sk8lBa4rcPxsv3b69vr5BktjXPxsv3b6EislBdRDIPxsv3b7Wtb5BnF7DPxsv3b6dgclBf5azPxsv3b7urL5BpuSuPxsv3b61eMlBiRyfPxsv3b4HpL5BsGqaPxsv3b7Ob8lBlKKKPxsv3b4fm75Bu/CFPxsv3b7mZslBL1FsPxsv3b44kr5BBe5iPxsv3b7/XclBT11DPxsv3b5Rib5Bnvk5Pxsv3b4YVclBY2kaPxsv3b5pgL5BsQURPxsv3b4wTMlBs+viPhsv3b6Cd75B4BvQPhsv3b5IQ8lB3wWRPhsv3b6Sbr5Bzj98Phsv3b5WOslBjH38PRsv3b6WZb5B166wPRsv3b5ZMclBe0IWvRsv3b6ZXL5B8++WvRsv3b5cKMlBN2BJvhsv3b6dU75BdkVvvhsv3b5gH8lBtZe2vhsv3b6hSr5BY4vJvhsv3b5kFslBtT8Evxsv3b6kQb5Bi7kNvxsv3b5nDclBmzMtvxsv3b6oOL5B66w2vxsv3b5rBMlBcydWvxsv3b6sL75Bw6Bfvxsv3b5v+8hBQRt/vxsv3b6wJr5BjEqEvxsv3b5z8shBjQeUvxsv3b6zHb5BecSYvxsv3b526chBf4Govxsv3b63FL5BJz6tvxsv3b564MhBZ/u8vxsv3b67C75BU7jBvxsv3b5+18hBU3XRvxsv3b6+Ar5BPzLWvxsv3b6BzshB7e7lvxsv3b7C+b1Biq/qvxsv3b6DxchBeGj6vxsv3b638L1BCS3/vxsv3b53vMhBMHEHwBsv3b6s571BeNMJwBsv3b5ss8hBIK4RwBsv3b6h3r1BihAUwBsv3b5hqshBE+sbwBsv3b6V1b1BfU0ewBsv3b5VochBCSgmwBsv3b6KzL1BUYoowBsv3b5KmMhB+WQwwBsv3b5/w71BY8cywBsv3b4/j8hB7KE6wBsv3b5zur1BVgQ9wBsv3b4zhshB495EwBsv3b5osb1BK0FHwBsv3b4ofchB0htPwBsv3b5dqL1BPH5RwBsv3b4ddMhBxVhZwBsv3b5Rn71BL7tbwBsv3b4Ra8hBuJVjwBsv3b5Glr1BIvhlwBsv3b4GYshBrNJtwBsv3b46jb1BFjVwwBsv3b76WMhBog94wBsv3b4vhL1B6nF6wBsv3b7vT8hBSSaBwBsv3b4ke71BfleCwBsv3b7kRshBvUSGwBsv3b4Ycr1BJXaHwBsv3b7XPchBJ2OLwBsv3b4Lab1BSJWMwBsv3b7INMhBkYGQwBsv3b70X71BSrSRwBsv3b6wK8hBC6CVwBsv3b7dVr1Bs9KWwBsv3b6ZIshBg76awBsv3b7HTb1BK/GbwBsv3b6DGchB/NyfwBsv3b6wRL1BpA+hwBsv3b5sEMhBdPukwBsv3b6aO71BHC6mwBsv3b5WB8hB6hmqwBsv3b6DMr1Bo0yrwBsv3b4//sdBZDivwBsv3b5sKb1BDGuwwBsv3b4o9cdB3Va0wBsv3b5WIL1BhYm1wBsv3b4S7MdBVXW5wBsv3b4/F71B/ae6wBsv3b774sdBzZO+wBsv3b4pDr1Bdca/wBsv3b7l2cdBRLLDwBsv3b4SBb1B/eTEwBsv3b7O0MdBvtDIwBsv3b77+7xBZgPKwBsv3b63x8dBNu/NwBsv3b7l8rxB3iHPwBsv3b6hvsdBrA3TwBsv3b7O6bxBZUDUwBsv3b6KtcdBJyzYwBsv3b634LxBz17ZwBsv3b5zrMdBn0rdwBsv3b6h17xBR33ewBsv3b5do8dBDWniwBsv3b6KzrxBLJzjwBsv3b5GmsdBbIfnwBsv3b5txbxBmbvowBsv3b4nkcdB1qXswBsv3b5GvLxBmtrtwBsv3b7/h8dBTMTxwBsv3b4gs7xBEPnywBsv3b7ZfsdBweL2wBsv3b75qbxBlhf4wBsv3b6ydcdBOQH8wBsv3b7SoLxB/TX9wBsv3b6LbMdB2I8AwRsv3b6sl7xBOioBwRsv3b5lY8dBEx8DwRsv3b6FjrxBdbkDwRsv3b4+WsdBTq4FwRsv3b5fhbxBsEgGwRsv3b4YUcdBij0IwRsv3b44fLxB7NcIwRsv3b7xR8dBxcwKwRsv3b4Sc7xBJ2cLwRsv3b7LPsdBAFwNwRsv3b7rabxBYvYNwRsv3b6kNcdBO+sPwRsv3b7FYLxBnYUQwRsv3b5+LMdBdnoSwRsv3b6eV7xB4RQTwRsv3b5XI8dBsgkVwRsv3b53TrxBFKQVwRsv3b4wGsdB7ZgXwRsv3b5RRbxBTzMYwRsv3b4KEcdBHSgawRsv3b4qPLxBBsMawRsv3b7hB8dBR7ccwRsv3b70MrxB4VIdwRsv3b6p/sZBfUYfwRsv3b64KbxBSeIfwRsv3b5s9cZBt9UhwRsv3b58ILxBg3EiwRsv3b4w7MZB8WQkwRsv3b5AF7xBtgAlwRsv3b704sZBK/QmwRsv3b4FDrxB8I8nwRsv3b652cZBZYMpwRsv3b7JBLxBMR8qwRsv3b590MZBnxIswRsv3b6N+7tBZK4swRsv3b5Bx8ZB2aEuwRsv3b5S8rtBnj0vwRsv3b4GvsZBEzExwRsv3b4W6btB38wxwRsv3b7KtMZBTcAzwRsv3b7a37tBGVw0wRsv3b6Oq8ZBh082wRsv3b6e1rtBTOs2wRsv3b5SosZBwt44wRsv3b5jzbtBh3o5wRsv3b4XmcZB9W07wRsv3b4oxLtBBQo8wRsv3b7aj8ZBGf09wRsv3b7kurtBHpo+wRsv3b6ThsZBQoxAwRsv3b6LsbtB+ClBwRsv3b44fcZBehtDwRsv3b4yqLtBMLlDwRsv3b7fc8ZBs6pFwRsv3b7ZnrtBaUhGwRsv3b6GasZB6zlIwRsv3b6AlbtBoddIwRsv3b4tYcZBI8lKwRsv3b4njLtB2WZLwRsv3b7UV8ZBXFhNwRsv3b7OgrtBEvZNwRsv3b57TsZBlOdPwRsv3b51ebtBSoVQwRsv3b4iRcZBzXZSwRsv3b4ccLtBgxRTwRsv3b7JO8ZBBQZVwRsv3b7DZrtBu6NVwRsv3b5wMsZBPZVXwRsv3b5qXbtB8zJYwRsv3b4XKcZBVSRawRsv3b4SVLtBfsNawRsv3b65H8ZBarNcwRsv3b6NSrtBB1RdwRsv3b4vFsZBoEJfwRsv3b4IQbtBNONfwRsv3b6qDMZB1tFhwRsv3b6EN7tBanJiwRsv3b4mA8ZBDGFkwRsv3b7/LbtBoAFlwRsv3b6h+cVBQvBmwRsv3b57JLtB1pBnwRsv3b4d8MVBeH9pwRsv3b72GrtBDCBqwRsv3b6Y5sVBrg5swRsv3b5yEbtBQq9swRsv3b4U3cVB5J1uwRsv3b7tB7tBeD5vwRsv3b6P08VB6CxxwRsv3b5q/rpBqc9xwRsv3b4EysVB5btzwRsv3b6j9LpB/WB0wRsv3b41wMVBEkt2wRsv3b7Z6rpBVPB2wRsv3b5ptsVBRNp4wRsv3b4N4bpBjn95wRsv3b6drMVBdml7wRsv3b5B17pBwA58wRsv3b7RosVBp/h9wRsv3b51zbpB8Z1+wRsv3b4FmcVB60OAwRsv3b6pw7pBnZaAwRsv3b45j8VBUIuBwRsv3b7bubpBSOCBwRsv3b5ahcVBstKCwRsv3b6Hr7pB3ymDwRsv3b70esVBSBqEwRsv3b4ypbpBcHGEwRsv3b6fcMVB3WGFwRsv3b7dmrpBBrmFwRsv3b5KZsVBQamGwRsv3b6JkLpBfgKHwRsv3b7lW8VBRPCHwRsv3b67hbpBw0+IwRsv3b7iUMVBbTeJwRsv3b7tebpBG5uJwRsv3b7uRMVBKHaKwRsv3b6WbrpBxTyLwRsv3b4+NMVB4qmLwRsv3b5STbpBBjqNwRsv3b4D/cRBTdqMwRsv3b4NFbpBvjKPwRsv3b7dn8RBEQaOwRsv3b7ixrlBGiGRwRsv3b7oHsRB1yqPwRsv3b4wY7lBewmTwRsv3b5reMNBtEaQwRsv3b6d6bhBSuiUwRsv3b5Rq8JBcliRwRsv3b6AWrhBIbWWwRsv3b54usFBvV6SwRsv3b4Dt7dB1WyYwRsv3b5nqMBBa1iTwRsv3b7N/7ZBvgmawRsv3b6+eb9BPEWUwRsv3b7ONrZB7IibwRsv3b6yNL5BuSKVwRsv3b5eX7VBg/ycwRsv3b4EyrxBQ/KVwRsv3b75b7RBWx6ewRsv3b6If7tBVMeWwRsv3b4weLNBYvqewRsv3b6of7pB/5uXwRsv3b5bf7JBEtKfwRsv3b5Ng7lBnHCYwRsv3b5rhrFBY6mgwRsv3b4vh7hBlESZwRsv3b7hjLBBvX+hwRsv3b7airdBxRiawRsv3b4Yk69B61OiwRsv3b4TkbZB4+yawRsv3b5kma5B0SijwRsv3b50lrVBusCbwRsv3b6Sn61BGP6jwRsv3b7xmrRBdJScwRsv3b6EpaxBetKkwRsv3b4coLNBPWidwRsv3b5kq6tBQ6alwRsv3b78pbJBBjyewRsv3b5EsapBDnqmwRsv3b7aq7FBvg+fwRsv3b44t6lBiU6nwRsv3b7nsLBBO+OfwRsv3b4CvahB6yKowRsv3b6jta9BwragwRsv3b6uwqdBkvaowRsv3b4pu65BS4qhwRsv3b5YyKZBGcqpwRsv3b7VwK1B1F2iwRsv3b4CzqVBpJ2qwRsv3b59xqxBWzGjwRsv3b6t06RBPXGrwRsv3b4TzKtB0QSkwRsv3b5j2aNBTEWswRsv3b4U0apBI9ikwRsv3b733qJBKBmtwRsv3b4D1qlBgKulwRsv3b585KFBh+ytwRsv3b6G26hB3X6mwRsv3b4B6qBB4r+uwRsv3b4N4adBO1KnwRsv3b6G759BQJOvwRsv3b6S5qZBmCWowRsv3b4L9Z5Bn2awwRsv3b4V7KVB8/iowRsv3b6S+p1BFjqxwRsv3b588aRBP8ypwRsv3b4dAJ1B0Q2ywRsv3b6C9qNBdZ+qwRsv3b6QBZxBXOGywRsv3b6R+6JBs3KrwRsv3b76CptBmrSzwRsv3b77AKJB8UWswRsv3b5kEJpB2Ie0wRsv3b5kBqFBLhmtwRsv3b7PFZlBFVu1wRsv3b7PC6BBbOytwRsv3b45G5hBUy62wRsv3b46EZ9Bqr+uwRsv3b6jIJdBkQG3wRsv3b6kFp5B5pKvwRsv3b4PJpZB6NS3wRsv3b7wG51BFGawwRsv3b58K5VBa6i4wRsv3b74IJxBNTmxwRsv3b7YMJRBxnu5wRsv3b4QJptBWwyywRsv3b4vNpNB7U66wRsv3b5kK5pBgN+ywRsv3b6FO5JBEiK7wRsv3b66MJlBprKzwRsv3b7aQJFBN/W7wRsv3b4SNphBzIW0wRsv3b4wRpBBXci8wRsv3b5oO5dB8li1wRsv3b6HS49BhJu9wRsv3b68QJZBFyy2wRsv3b7dUI5BqW6+wRsv3b4SRpVBOv+2wRsv3b41Vo1B6EG/wRsv3b5LS5RBT9K3wRsv3b6QW4xBZxXAwRsv3b4nUJNBUKW4wRsv3b7VYItBtejAwRsv3b4QVZJBWHi5wRsv3b4SZopBvbvBwRsv3b5NWpFBYEu6wRsv3b5Pa4lBxY7CwRsv3b6KX5BBaB67wRsv3b6McIhBzmHDwRsv3b7FZI9BcPG7wRsv3b7IdYdB2DTEwRsv3b7/aY5Bd8S8wRsv3b4Fe4ZB3gfFwRsv3b4+b41Be5e9wRsv3b5GgIVBDNvFwRsv3b5OdIxBa2q+wRsv3b6HhYRBiK7GwRsv3b7neItBQj2/wRsv3b6uioNBwoHHwRsv3b6YfYpBIhDAwRsv3b7Jj4JBoFTIwRsv3b62golBA+PAwRsv3b7llIFBgSfJwRsv3b7Sh4hB47XBwRsv3b4AmoBBY/rJwRsv3b7qjIdBw4jCwRsv3b43Pn9BQs3KwRsv3b4HkoZBm1vDwRsv3b6BSH1BdaDLwRsv3b7BloVBUS7EwRsv3b62UntB63PMwRsv3b72moRB8gDFwRsv3b6nXHlB8kbNwRsv3b51n4NBnNPFwRsv3b6DZndBnhnOwRsv3b5hpIJBRabGwRsv3b5ecHVBR+zOwRsv3b5OqYFB73jHwRsv3b46enNB777PwRsv3b4+roBBhkvIwRsv3b5AhHFBVJLQwRsv3b6ZZH9B3R3JwRsv3b73jW9ByGXRwRsv3b6na31BL/DJwRsv3b5dl21BajjSwRsv3b5OdHtBiMLKwRsv3b6yoGtBwQrTwRsv3b6mfXlB35TLwRsv3b4MqmlBON3TwRsv3b61hndBBmfMwRsv3b60s2dBLLHUwRsv3b4QjHVBtzjNwRsv3b6KvGVBh4TVwRsv3b7nkHNBjgrOwRsv3b4FxWNBXlbWwRsv3b5imXFBPtzOwRsv3b7fzWFByynXwRsv3b4Onm9BSa3PwRsv3b5A1l9BN/7XwRsv3b5Lnm1B+H3QwRsv3b6X3V1BDNHYwRsv3b50oGtB7E3RwRsv3b4g5ltBxanZwRsv3b6kk2lBURXSwRsv3b6k+llBQ7/awRsv3b4R4GZBLr/SwRsv3b66CVhBcQzcwRsv3b6CA2NB5UTTwRsv3b5q91VBJi7dwRsv3b4yjF5BMavTwRsv3b5dxlNB8QPewRsv3b6a91lBkO/TwRsv3b70g1FB1pXewRsv3b7ZG1VBCA7UwRsv3b5dLU9B6tXewRsv3b4Zd1BBKBPUwRsv3b56tUxBw9/ewRsv3b7K9kxBWBXUwRsv3b6UKEpBFeLewRsv3b7tTEpBcxfUwRsv3b44mkdBOeTewRsv3b4LskdBKhjUwRsv3b7IC0VB9eTewRsv3b7PF0VB4BjUwRsv3b5efEJBq+XewRsv3b6jh0JBfxnUwRsv3b557T9BTObewRsv3b4N8j9BahnUwRsv3b6EXj1BN+bewRsv3b4qXT1BVhnUwRsv3b4GzzpBI+bewRsv3b61zTpBQhnUwRsv3b6iPzhBD+bewRsv3b4yPThBDBnUwRsv3b6GsDVB2eXewRsv3b7mqTVBehjUwRsv3b5OITNBReXewRsv3b6eFzNB5xfUwRsv3b7SkTBBsuTewRsv3b4iiDBBVBfUwRsv3b5WAi5BH+TewRsv3b6m+C1BwRbUwRsv3b78citBjOPewRsv3b7IZytBABbUwRsv3b7A4yhByuLewRsv3b631ShBFxXUwRsv3b5iVCZB4eHewRsv3b4HRSZBLhTUwRsv3b7mxCNB+ODewRsv3b6LtSNBRRPUwRsv3b5qNSFBD+DewRsv3b4PJiFBXBLUwRsv3b7upR5BJt/ewRsv3b6Tlh5BcxHUwRsv3b6cFhxBPN7ewRsv3b5pBRxBUhDUwRsv3b5PhxlBGt3ewRsv3b4QdBlBKw/UwRsv3b7Z9xZB89vewRsv3b5g5BZBAw7UwRsv3b5eaBRBy9rewRsv3b7cVBRB2wzUwRsv3b7i2BFBo9newRsv3b5gxRFBswvUwRsv3b5nSQ9Be9jewRsv3b7lNQ9BiwrUwRsv3b74uQxBU9fewRsv3b7mpQxBUgnUwRsv3b6fKgpBGdbewRsv3b4BFQpB+wfUwRsv3b46mwdBwtTewRsv3b6fhAdBpAbUwRsv3b6/CwVBa9PewRsv3b4k9QRBTQXUwRsv3b5EfAJBFNLewRsv3b6pZQJB9gPUwRsv3b6S2f9AvdDewRsv3b5crP9AnwLUwRsv3b6buvpAZs/ewRsv3b5ljfpASAHUwRsv3b6lm/VAD87ewRsv3b5vbvVA9v/TwRsv3b5RhfBAiMzewRsv3b709u9A5/jTwRsv3b7ob+tABMXewRsv3b6NeepAZ/HTwRsv3b7hUeZAer3ewRsv3b69VOVA5+nTwRsv3b4/M+FA+rXewRsv3b4bNuBAZuLTwRsv3b6eFNxAea7ewRsv3b56F9tA5trTwRsv3b789dZA+abewRsv3b7Y+NVAZdPTwRsv3b5d19FAeJ/ewRsv3b4o2tBA8svTwRsv3b7cwMxAZ5fewRsv3b45aMtAHb/TwRsv3b7+rMdAjInewRsv3b5X48VA77DTwRsv3b4skcJAInvewRsv3b7pscBAu6LTwRsv3b5sc71A72zewRsv3b46lLtAiJTTwRsv3b6uVbhAvF7ewRsv3b58drZAVIbTwRsv3b7yN7NAh1DewRsv3b6vWLFAIHjTwRsv3b40Gq5AU0LewRsv3b7xOqxADWrTwRsv3b47B6lAxjLewRsv3b7MrqZA1VTTwRsv3b4C9qNAtRvewRsv3b5eIqFAVD/TwRsv3b4K2p5AKwbewRsv3b5cBJxA0ynTwRsv3b7ivZlAq/DdwRsv3b5E6JZAUhTTwRsv3b69oZRAKdvdwRsv3b4PzJFA0P7SwRsv3b6XhY9Ap8XdwRsv3b7pr4xAZ+nSwRsv3b7fbopAMq/dwRsv3b4eXIdAgtDSwRsv3b7SX4VAspPdwRsv3b5qxoFAK7PSwRsv3b6zTIBAwXTdwRsv3b6W03hAr5XSwRsv3b4XZnZARVfdwRsv3b4moG5AMnjSwRsv3b7GMmxAyDndwRsv3b7VbGRAtlrSwRsv3b51/2FATBzdwRsv3b6EOVpAST3SwRsv3b7N0VdAJf7cwRsv3b5BzE9APx7SwRsv3b5Buk1Aa9vcwRsv3b7cjURAUPjRwRsv3b6/okNAP7LcwRsv3b43jzlAF9LRwRsv3b6wdjlABozcwRsv3b4oYy9A3qvRwRsv3b6iSi9AzWXcwRsv3b4aNyVApYXRwRsv3b6UHiVAlD/cwRsv3b4MCxtAiV/RwRsv3b4r+hpALxjcwRsv3b6QkBBAOzfRwRsv3b6U7RBAqurbwRsv3b5aQAVAywfRwRsv3b7H4AZABbfbwRsv3b5gjPQ//NfQwRsv3b7Iffk/NIfbwRsv3b5YSOA/K6jQwRsv3b72OeU/ZVfbwRsv3b7IBMw/XHjQwRsv3b4q9tA/lifbwRsv3b79wLc/6UjQwRsv3b5T2Lw/EfTawRsv3b4W+KE/cRPQwRsv3b5L5Kg/O7fawRsv3b47PYs/X9nPwRsv3b5x1ZQ/AnrawRsv3b4QJGw/B5/PwRsv3b5XrYA/qD/awRsv3b5a00M/rWTPwRsv3b5uClk/TgXawRsv3b4agxs/fyrPwRsv3b6Q1zA/PMnZwRsv3b5gC+Q+jO7OwRsv3b44Igk/x4PZwRsv3b6WYog+U6nOwRsv3b4+E8M+bTbZwRsv3b5MQL49YWPOwRsv3b7SDWY+e/DYwRsv3b4I8YG9bx3OwRsv3b5Q6os9iarYwRsv3b4wEWG+YtfNwRsv3b6TwrS9q2XYwRsv3b5FWL++rJHNwRsv3b7dL32+4SvYwRsv3b6qGwG/qljNwRsv3b5HftC+mfzXwRsv3b62ciO/hSDNwRsv3b7bmxC/dMTXwRsv3b7vz0u/YOjMwRsv3b6f+Di/T4zXwRsv3b6zLHS/O7DMwRsv3b5iVWG/KlTXwRsv3b68RI6/bnfMwRsv3b7bGIW/xyLXwRsv3b5O5Z+/OEnMwRsv3b7Dr5m/p/zWwRsv3b4LCrG/Ax7MwRsv3b6XDK6/0NLWwRsv3b5UxMS/7PLLwRsv3b75WsK/uafWwRsv3b62Etm/1sfLwRsv3b5aqda/o3zWwRsv3b4XYe2/h5zLwRsv3b7MEuu/yVPWwRsv3b4LTQDAbXXLwRsv3b45s/+/tDLWwRsv3b7l/QjAWlbLwRsv3b5fIwrA8BbWwRsv3b5xQBLAkzfLwRsv3b64VRTAKPjVwRsv3b7schzAyxjLwRsv3b4UiB7AYNnVwRsv3b5IpSbABPrKwRsv3b5zuijAmrrVwRsv3b6F1zDA9drKwRsv3b58BjPAtZ7VwRsv3b69AjrAb8TKwRsv3b7VYD3AtovVwRsv3b5C1kLAK7HKwRsv3b5Oo0fAOHnVwRsv3b4is0zA953KwRsv3b693FHAA2bVwRsv3b6y7FbAw4rKwRsv3b4yFlzA0FLVwRsv3b4GJmHAkHfKwRsv3b6jT2bAnT/VwRsv3b53X2vAKmTKwRsv3b5Gp3DAYS7VwRsv3b5sY3TA5VrKwRsv3b65BHvAtibVwRsv3b7uUX3AjlLKwRsv3b4booLAdh7VwRsv3b5Su4PAOErKwRsv3b6nwIfAIBbVwRsv3b7e2YjA40HKwRsv3b4034zAyw3VwRsv3b5r+I3AjTnKwRsv3b7B/ZHAdQXVwRsv3b74FpPAJzHKwRsv3b5eKpfAyP3UwRsv3b5cpZfALzLKwRsv3b6SWJzA9f7UwRsv3b6rJ5zACjTKwRsv3b6+eKHAzADVwRsv3b4iOqHA5TXKwRsv3b6xl6bApwLVwRsv3b4VWabAwDfKwRsv3b6mtqvAggTVwRsv3b75d6vAnDnKwRsv3b6a1bDAXgbVwRsv3b7tlrDAfzvKwRsv3b7I/rXA8AfVwRsv3b4UTbXAQUTKwRsv3b6AK7vAwg/VwRsv3b672LnAsU/KwRsv3b7DTcDA0RrVwRsv3b4Tzb7AFlvKwRsv3b7wa8XANybVwRsv3b5R68PAfWbKwRsv3b4gisrAnTHVwRsv3b5vCcnA5HHKwRsv3b5OqM/ABD3VwRsv3b6dJ87AU33KwRsv3b6QytTAB0jVwRsv3b77G9PAmYvKwRsv3b6g9dnAlFTVwRsv3b47sNfAEqDKwRsv3b5kG9/AimfVwRsv3b4fbdzAabTKwRsv3b7ZN+TA4XvVwRsv3b6DieHAwMjKwRsv3b5LVOnAOJDVwRsv3b71pebAF93KwRsv3b68cO7Aj6TVwRsv3b53wuvAbfHKwRsv3b4ujfPA5bjVwRsv3b7p3vDAwwXLwRsv3b6EqfjAQc3VwRsv3b5//PXABhrLwRsv3b7Qxf3Ai+HVwRsv3b61GvvAPy7LwRsv3b4fcQHBx/XVwRsv3b7VGwDBekLLwRsv3b5c/wPBAgrWwRsv3b4SqgLBtFbLwRsv3b6YjQbBPB7WwRsv3b5WOAXB7mrLwRsv3b7UGwnBdjLWwRsv3b6TxgfBKH/LwRsv3b76qQvBt0bWwRsv3b6+VQrBQpPLwRsv3b4fOA7B21rWwRsv3b4j5QzBVqfLwRsv3b5bxhDB8W7WwRsv3b6Scw/Ba7vLwRsv3b6dVBPBBoPWwRsv3b7MARLBgM/LwRsv3b7f4hXBG5fWwRsv3b4OkBTBlOPLwRsv3b4BcRjBOavWwRsv3b6RHxfBfvfLwRsv3b4h/xrBML/WwRsv3b5grxnBXwvMwRsv3b5ijR3BFNPWwRsv3b7sPRzBQR/MwRsv3b6qGyDB9ubWwRsv3b40zB7BIjPMwRsv3b7SqSLB4vrWwRsv3b62WyHB2kbMwRsv3b7nNyXBrA7XwRsv3b5E7CPBcFrMwRsv3b4exifBSyLXwRsv3b6aeybBB27MwRsv3b5nVCrB5TXXwRsv3b43CinBkYHMwRsv3b5g4izBjEnXwRsv3b4UnCvBsJTMwRsv3b5ucC/BxlzXwRsv3b6xLS7Bz6fMwRsv3b6L/jHB+m/XwRsv3b6svjDBlLrMwRsv3b5SjDTB9ILXwRsv3b6RUzPB1czMwRsv3b6eGTfBkpXXwRsv3b4D7jXB7d3MwRsv3b6NojnBNKjXwRsv3b5ytjjBdujMwRsv3b62HzzBNLXXwRsv3b47/DvBH+LMwRsv3b4zlz7BeqzXwRsv3b6hfz/B3M3MwRsv3b7YDkHBBJDXwRsv3b6Z80LBv6rMwRsv3b5sgkPB2V3XwRsv3b6fckbBFXjMwRsv3b6k8EXBhRTXwRsv3b5i9knBjDXMwRsv3b54V0jBgbLWwRsv3b7Afk3BNOLLwRsv3b7/tErBUjbWwRsv3b7eBFHBOX7LwRsv3b6LB03B0Z/VwRsv3b4rg1TBIgnLwRsv3b6wTE/BGe7UwRsv3b4s9VfBN4PKwRsv3b71glHBgyLUwRsv3b72UVvB2uzJwRsv3b46qFPBTz3TwRsv3b4nl17BREbJwRsv3b74ulXB30DSwRsv3b7Gu2HBopDIwRsv3b4fulfB/C7RwRsv3b53vmTBPMzHwRsv3b4SpFnBMwrQwRsv3b5mmWfBn/rGwRsv3b7+eFvBltXOwRsv3b7NS2rBVxzGwRsv3b56N13B1ZLNwRsv3b6K1GzBuzLFwRsv3b6C4F7BQUbMwRsv3b7UMW/B6T7EwRsv3b6Sc2DBx+/KwRsv3b4saHHBpULDwRsv3b6f72HBjIjJwRsv3b7RhHPB3TrCwRsv3b7BU2PBbSnIwRsv3b6eYHXBvynBwRsv3b4EsWTBofbGwRsv3b5z6XbBeRXAwRsv3b5GD2bBStzFwRsv3b5pT3jBtgC/wRsv3b4ybWfBgsTEwRsv3b4nsXnBl+u9wRsv3b6GymjBzK3DwRsv3b58EHvBcNa8wRsv3b7kJ2rBVZfCwRsv3b6Cb3zBH8G7wRsv3b6XhGvBXIDBwRsv3b5Kzn3BpKu6wRsv3b5I4WzBjGrAwRsv3b5lK3/BKJa5wRsv3b74PW7BulS/wRsv3b5BRIDBsIC4wRsv3b5rmm/BNz6+wRsv3b4i84DBFWu3wRsv3b6T9nDB6Ce9wRsv3b6noYHBalW2wRsv3b7QUnLBPxK8wRsv3b7ET4LBwT+1wRsv3b4Kr3PBi/y6wRsv3b7o/YLBJCq0wRsv3b4tC3XBN+a5wRsv3b5trIPBbRSzwRsv3b4AZ3bB1M+4wRsv3b7CWoTBp/6xwRsv3b7lwnfBELq3wRsv3b6zCIXB4uiwwRsv3b7LHnnBS6S2wRsv3b6mtoXBHdOvwRsv3b6wenrBgo61wRsv3b6aZIbBYr2uwRsv3b6G1nvBQni0wRsv3b7ZEofBlKetwRsv3b4dMn3B8GGzwRsv3b73wIfBupGswRsv3b7EjX7BGEyywRsv3b7KbojB4XurwRsv3b5q6X/BPjaxwRsv3b6dHInBB2aqwRsv3b6IooDBYiCwwRsv3b5yyonBL1CpwRsv3b5aUIHBeQqvwRsv3b5OeIrBXDqowRsv3b4j/oHBQfStwRsv3b5WJovBeiSnwRsv3b7aq4LBCt6swRsv3b5C1IvBkQ6mwRsv3b6VWYPBIcirwRsv3b79gYzBqPikwRsv3b5QB4TBN7KqwRsv3b64L43BwOKjwRsv3b4KtYTBT5ypwRsv3b5y3Y3B18yiwRsv3b7FYoXBZ4aowRsv3b4ti47BxbehwRsv3b76D4bB42enwRsv3b5DPo/B/6GgwRsv3b6HuobBA0GmwRsv3b5S84/BRoqfwRsv3b7KY4fBpCGlwRsv3b47oZDB4HGewRsv3b53DYjBOAmkwRsv3b7rSpHBeFmdwRsv3b4jt4jB0PCiwRsv3b6X9JHBEEGcwRsv3b7QYInBbtihwRsv3b5BnpLBQCmbwRsv3b4iCorB/7mgwRsv3b6RS5PBqRGawRsv3b4ysYrBu46fwRsv3b5Z/pPBpveYwRsv3b6FVYvBnmeewRsv3b5bqpTBedyXwRsv3b6G+ovBcUydwRsv3b5cT5XBTMGWwRsv3b6In4zBRzGcwRsv3b5c9JXBH6aVwRsv3b6KRI3BFxabwRsv3b5gmZbBA4yUwRsv3b7s6I3B9++ZwRsv3b65RJfBPXGTwRsv3b7qiY7BTr6YwRsv3b6/8pfB8FOSwRsv3b7zKI/BJJaXwRsv3b7gl5jBrDWRwRsv3b6IyI/B3XeWwRsv3b52N5nBZxeQwRsv3b4caJDBmFmVwRsv3b4K15nBbPmOwRsv3b6HB5HBVziUwRsv3b5IeJrBHd2NwRsv3b4EpZHBMAGTwRsv3b5+JJvBfb2MwRsv3b4YPZLB48mRwRsv3b48yZvBwZuLwRsv3b5M1pLBJKiQwRsv3b5yYpzBBnqKwRsv3b5/b5PBaYaPwRsv3b6l+5zBoliJwRsv3b6FCJTBK2GOwRsv3b6zlp3BRDmIwRsv3b5Un5TB5yGNwRsv3b4MPp7BKBaHwRsv3b6wL5XBlOKLwRsv3b6a3J7BivCFwRsv3b5OwZXB87yKwRsv3b46bp/B7MqEwRsv3b7sUpbBWJeJwRsv3b7W/5/BFKeDwRsv3b6s45bBh1+IwRsv3b5kmqDBoIKCwRsv3b7lbpfBdxSHwRsv3b4COKHBTFqBwRsv3b6Z9pfBhNmFwRsv3b5TyKHBUTCAwRsv3b4Qf5jBiK+EwRsv3b7KUKLBTw9+wRsv3b7uBpnBC3iDwRsv3b5l36LBqsB7wRsv3b4DipnBeiGCwRsv3b4/d6PBt2h5wRsv3b4LBprB99SAwRsv3b77AKTB/Ap3wRsv3b5Mg5rBM0x/wRsv3b48fqTBfrJ0wRsv3b57/5rBurh8wRsv3b5qBqXB0FlywRsv3b6qc5vB+Ol5wRsv3b5xkaXBgPZvwRsv3b704ZvBi0t3wRsv3b6WCqbB741twRsv3b41UZzB+uJ0wRsv3b7XeabBZCVrwRsv3b51wJzBK3pywRsv3b4k6abB0rxowRsv3b6tL53BXxFwwRsv3b5mWKfBPVRmwRsv3b7nnp3BwqhtwRsv3b6hx6fBrutjwRsv3b4dDp7B1j9rwRsv3b7pNqjBFoNhwRsv3b5LfZ7B+9ZowRsv3b4jpqjBfxpfwRsv3b567J7BIW5mwRsv3b5eFanB57FcwRsv3b6fW5/BCQVkwRsv3b6ahKnBSUlawRsv3b6+yp/B9ZthwRsv3b7O86nBq+BXwRsv3b7VOaDBqDJfwRsv3b4EY6rBEHhVwRsv3b7cqKDB28hcwRsv3b5C0qrBcw9TwRsv3b7JF6HBVV5awRsv3b6HQavBEadQwRsv3b6EhqHBWvBXwRsv3b5EsavB/ZROwRsv3b6S6aHBjwlSwRsv3b7EkqzBx4dMwRsv3b5K4KHB5JpKwRsv3b4VoqzBXiFKwRsv3b44r6HBmMBGwRsv3b7/WazBSppHwRsv3b7Ze6HBLzZEwRsv3b5dJqzBHhNFwRsv3b5ZSKHBI61BwRsv3b638qvB6ItCwRsv3b69FKHB1iQ/wRsv3b4Fv6vBqwRAwRsv3b4a4aDB8Jw8wRsv3b5Ui6vBbn09wRsv3b5praDBLRU6wRsv3b6ZV6vBMPY6wRsv3b62eaDBgY03wRsv3b7dI6vB9W44wRsv3b74RaDB4QU1wRsv3b4W8KrBuOc1wRsv3b44EqDBWH4ywRsv3b5RvKrBf2AzwRsv3b5x3p/ByvYvwRsv3b6CiKrBa9owwRsv3b6/qp/Bf2MtwRsv3b7AU6rByFwuwRsv3b4vdp/B+H8qwRsv3b5rFqrBuOErwRsv3b4UN5/Bd6wnwRsv3b7nzqnBG2MpwRsv3b6r957BQv0kwRsv3b6OiqnBC+wmwRsv3b4Ys57BBB0iwRsv3b6DOqnBmHMkwRsv3b5fZ57BEWwfwRsv3b4u6KjBRP4hwRsv3b4SHJ7BLpscwRsv3b6BkajBCpAfwRsv3b45xp3B+MAZwRsv3b4rLajBeR0dwRsv3b6vbZ3B+h4XwRsv3b7lzafBwrIawRsv3b5rEp3BVkUUwRsv3b76YafBHUsYwRsv3b5LrZzBro0RwRsv3b4M8KbB6OMVwRsv3b5MSJzBjNEOwRsv3b65fKbBOYYTwRsv3b7O2ZvBNwMMwRsv3b4I+qXBFSYRwRsv3b4jZ5vBx2gJwRsv3b5dfKXBys0OwRsv3b728ZrB/J0GwRsv3b578KTB1noMwRsv3b5WcprBJvcDwRsv3b5GX6TBvigKwRsv3b758pnBdkcBwRsv3b5Ty6PBk+IHwRsv3b4faZnBUB39wBsv3b7VJqPBgJoFwRsv3b6825jBcgj4wBsv3b5IiaLBI1wDwRsv3b5JSpjBkqXywBsv3b5+2qHBYyIBwRsv3b6Br5fBtqXtwBsv3b76LKHBRdb9wBsv3b58FZfBsnLowBsv3b6ed6DBPoL5wBsv3b4FcJbBWFnjwBsv3b5dtZ/BcyX1wBsv3b4SyZXBvJ7ewBsv3b49AJ/Bq93wwBsv3b4JHpXBWaLZwBsv3b4EOZ7BRJvswBsv3b5pbJTBBAbVwBsv3b7leJ3BY17owBsv3b68u5PBCDHQwBsv3b7+rpzBxjrkwBsv3b5dApPBJGrLwBsv3b6E2ZvBOw/gwBsv3b6DRpLBBQzHwBsv3b6+FJvB8PLbwBsv3b4PipHBHFrCwBsv3b5BPZrBj+bXwBsv3b5qxpDBst69wBsv3b7sZJnBLdbTwBsv3b4LApDBRIy5wBsv3b78k5jBFNjPwBsv3b4HO4/BkQa1wBsv3b6NspfBEOHLwBsv3b68bo7BEMqwwBsv3b5k2JbBrerHwBsv3b6Zoo3BHIeswBsv3b60/JXBXgfEwBsv3b7a0ozBnCiowBsv3b5VE5XBlybAwBsv3b7o/ovB9BikwBsv3b5vNZTBJ0i8wBsv3b57K4vBUfGfwBsv3b4+UpPBSHy4wBsv3b6KVIrBS7WbwBsv3b6ZYpLBMrK0wBsv3b7VeYnB6cOXwBsv3b4Tf5HB3+mwwBsv3b6Kn4jBe8CTwBsv3b5cl5DBQDKtwBsv3b6vwofBhqKPwBsv3b7Moo/B/X6pwBsv3b4J4obB3sOLwBsv3b7qt47BrselwBsv3b5vAIbBrg6IwBsv3b7Q1o3BmQ+iwBsv3b7iHoXBClyEwBsv3b6O9ozB+FaewBsv3b6VPYTBsqaAwBsv3b4HFozBSZ6awBsv3b5CXIPBAuR5wBsv3b6mNYvBauSWwBsv3b4ce4LBW35ywBsv3b4qVorBJCqTwBsv3b40moHBGRJrwBsv3b48donBIW+PwBsv3b5AuYDBCq9jwBsv3b6Dl4jBVLKLwBsv3b6fsX/BAkdcwBsv3b4juYfBAvWHwBsv3b6Y8H3BbmRXwBsv3b6hhobBQDSEwBsv3b7fMHzBEqlTwBsv3b5cLoXB93GAwBsv3b7ccXrBCuhPwBsv3b4e1oPBEFR5wBsv3b5ss3jBtkFMwBsv3b6AgoLBvZhxwBsv3b7B8nbBb0hJwBsv3b6cRYHBlTxpwBsv3b5KNnXBYgdIwBsv3b5GQ4DBTtxfwBsv3b4NtHPBa81CwBsv3b7nSIDBWt9VwBsv3b5YenLB3R0/wBsv3b73AIDBy2NLwBsv3b4cjXHBzqk6wBsv3b6v7n/BPJRAwBsv3b7k7XDBRAk2wBsv3b5TFYDBT9o1wBsv3b7rnHDBfUYvwBsv3b7YqYDBz3QrwBsv3b65Z3DB7x8mwBsv3b63PYHB7CohwBsv3b4uO3DBbyccwBsv3b760oHB6+cWwBsv3b7PEnDB8vgRwBsv3b60aYLBY6cMwBsv3b4m7W/Bt9QHwBsv3b4Qv4LBkmcCwBsv3b5zyW/B+qr7vxsv3b6nrYLBl1Hwvxsv3b6qp2/BLY7nvxsv3b4TnYLBbNXbvxsv3b7ehm/B2l7Tvxsv3b7qjILBL1bHvxsv3b5cZ2/BMUm/vxsv3b56fYLBB9Kyvxsv3b6oSW/BVF6rvxsv3b4Ob4LBo0Wevxsv3b6kLm/BWL2Xvxsv3b4lYoLBTKmJvxsv3b7YF2/BjrGEvxsv3b6UV4LBLYBpvxsv3b6aCG/BgE1pvxsv3b4aUYLByb4+vxsv3b65F2/Bu+BSvxsv3b73U4LBx7cTvxsv3b4tWW/ByDFBvxsv3b5ZYYLBTBbSvhsv3b7UzW/BoYcuvxsv3b56MILBncZ7vhsv3b6jd3DBEIAZvxsv3b4LyIHBHICuvRsv3b6aWHHBfdoKvxsv3b6zGoLBaX+QPRsv3b5PbnLBC4Puvhsv3b5OC4LBxUNhPhsv3b4luHPBAyDhvhsv3b7/yYLB0H25Phsv3b5mMHXB9HDJvhsv3b74NIPBF1T+Phsv3b7A03bBTRa4vhsv3b5Zw4PBcUIfPxsv3b7jmHjBNDKXvhsv3b6+OITBrY49Pxsv3b7db3rBkIJevhsv3b5z1ITB7sJaPxsv3b41V3zB0PwPvhsv3b7ed4XBcZh2Pxsv3b50U37BMNCMvRsv3b4eIIbBQ5GIPxsv3b5cL4DBAL1FOxsv3b5G1YbBCViVPxsv3b6DP4HB4Gt7PRsv3b7EcofB6B+hPxsv3b43YILBkDHRPRsv3b4E8ofB35WrPxsv3b7kjoPBIMAKPhsv3b5kbYjBMp60Pxsv3b7SyYTB6BFVPhsv3b5pwYjBHha8Pxsv3b5yD4bBUMlgPhsv3b5mS4nBKvPBPxsv3b5NXYfBKDd3Phsv3b69zYnBnirGPxsv3b4lsYjBmDpcPhsv3b4kZYrBesXIPxsv3b50CIrBaLFkPhsv3b6N8YrB887JPxsv3b57YYvBSCtoPhsv3b4Og4vBHU7JPxsv3b7tt4zB2KplPhsv3b7ZMYzBa8bHPxsv3b5jBo7BWCtcPhsv3b7BKo3BSgLGPxsv3b4VT4/BsA9PPhsv3b4tW47B1yTEPxsv3b56lpDBoKdAPhsv3b48l4/BgzfCPxsv3b6B3ZHBMKAxPhsv3b4J1pDBgDvAPxsv3b49JJPBgAkiPhsv3b7ZFpLBXDi+Pxsv3b6wapTBQBkSPhsv3b4OWpPBWDC8Pxsv3b4lsZXBmAcCPhsv3b7fnJTBkh+6Pxsv3b5j95bBQDTjPRsv3b4z4ZXBDhG4Pxsv3b5TPZjB4CDCPRsv3b7PKJfBSAe2Pxsv3b4yg5nBQD6hPRsv3b5rcZjBXwO0Pxsv3b4RyZrBkK+APRsv3b50upnBzQWyPxsv3b7fDpzBYNZAPRsv3b6OBJvBwxKwPxsv3b51VJ3BQAgBPRsv3b6cUZzB2jKuPxsv3b5+mZ7BgKmEPBsv3b5/pZ3BDoKsPxsv3b4K1p/BAAhHuhsv3b7LUp/BrkOsPxsv3b6R/aDBAGCiORsv3b763qHBqFCvPxsv3b7eE6LBUOuHPRsv3b7q5KTBvIy1Pxsv3b64IKPBSDdDPhsv3b6LnqfBIuC+Pxsv3b4ZLqTBjBigPhsv3b6hcqnBg+nIPxsv3b6VSaXBQOrKPhsv3b4SoarBkw7TPxsv3b58ZabBOH30Phsv3b7yw6vB3D7dPxsv3b5qgafBIt0OPxsv3b4/46zB8HPnPxsv3b42najBpn4jPxsv3b4NAq7BorDxPxsv3b7XuKnBOB84Pxsv3b7NH6/BC+/7Pxsv3b591KrB9rVMPxsv3b7ZPLDB6BgDQBsv3b4C8KvBqlFhPxsv3b6RWbHBiToIQBsv3b6GC63BDux1Pxsv3b4odrLBCl4NQBsv3b7qJq7BGEaFPxsv3b6FkrPBh4ESQBsv3b5NQq/Bj5aPPxsv3b7urrTBxaYXQBsv3b6CXbDBne6ZPxsv3b6Yy7XB1swcQBsv3b6neLHBs0ikPxsv3b486LbBvvQhQBsv3b6dk7LBCqquPxsv3b4UBbjB7R0nQBsv3b5nrrPBPRS5Pxsv3b5qIrnBR0osQBsv3b7uyLTBVIfDPxsv3b7CP7rBFngxQBsv3b4t47XB2QrOPxsv3b5AXrvBsKo2QBsv3b68/LbBwLLYPxsv3b7cfrzB/OI7QBsv3b5kFbjBXIzjPxsv3b6Hor3BTxtBQBsv3b5QKrnBbpTvPxsv3b4b4b7BkmxGQBsv3b7QMrrB7FD/Pxsv3b4IZcDBAERMQBsv3b67KLvBNn0JQBsv3b4nA8LBS6pSQBsv3b7rD7zBFEcUQBsv3b6jiMPBypdZQBsv3b7x5bzBICQgQBsv3b7v9sTBYgZhQBsv3b4Tqb3B7QMtQBsv3b7WSMbBHe5oQBsv3b7vV77BDL46QBsv3b6EeMfBKkRxQBsv3b7J8b7BuhNJQBsv3b5FgcjBT/p5QBsv3b6Ddr/Bv8hXQBsv3b6UYcnBfnuBQBsv3b7i5b/BvhdnQBsv3b7fHsrBjiqGQBsv3b5hPsDBpIR1QBsv3b4NqcrBIAuLQBsv3b7/jMDBjaGAQBsv3b5JCMvBCQCQQBsv3b6e2sDBCsmFQBsv3b4DWcvB6veUQBsv3b6TJ8HB09qKQBsv3b6Kp8vBMPGZQBsv3b7Wc8HBW+aPQBsv3b7l9MvBX+ueQBsv3b7Kv8HBReyUQBsv3b6NQczBL+ajQBsv3b5pC8LBdO+ZQBsv3b6qjczBJuGoQBsv3b7jVsLBqPKeQBsv3b6h2czBptytQBsv3b4IosLBBvSjQBsv3b4fJc3BS9iyQBsv3b4u7cLB8/OoQBsv3b6GcM3BAtS3QBsv3b4SOMPBzPWtQBsv3b7Gu83BNdC8QBsv3b7egsPB2POyQBsv3b6uBs7BR8zBQBsv3b6qzcPBRvO3QBsv3b6sUc7BaMjGQBsv3b5AGMTBJfS8QBsv3b6InM7B68TLQBsv3b7GYsTB+vHBQBsv3b4i587BP7zQQBsv3b4ArcTB6STHQBsv3b7MNM/BNa3VQBsv3b4t88TBv7vMQBsv3b4VhM/B7qjaQBsv3b5iM8XB0iHSQBsv3b7Iyc/BHK7fQBsv3b4VdMXBACfXQBsv3b57CtDBsqXkQBsv3b4ftMXBdrfcQBsv3b7vUdDBPKPpQBsv3b4a68XB3lbiQBsv3b4pkNDBw67uQBsv3b4uIsbBHmvnQBsv3b6dx9DBabTzQBsv3b4DWcbBs7vsQBsv3b6TAdHBgLX4QBsv3b4hi8bBKlryQBsv3b7WOdHBS8D9QBsv3b6luMbBaLf3QBsv3b5WatHBNWkBQRsv3b5u5sbBd8n8QBsv3b4fmNHB0+sDQRsv3b7JE8fB0C8BQRsv3b5XytHBRnAGQRsv3b6QOMfBtAAEQRsv3b6689HBS/sIQRsv3b5GXcfBjY8GQRsv3b6nGNLBRoQLQRsv3b7ggcfBOjMJQRsv3b67PtLBrwkOQRsv3b4bo8fBowIMQRsv3b6zY9LBlJIQQRsv3b7hvsfBy7oOQRsv3b6ZgdLBrR8TQRsv3b7Y2sfB5UcRQRsv3b6QndLBRKgVQRsv3b6f9sfBMwMUQRsv3b5lu9LB3y8YQRsv3b5ZDMjBZNIWQRsv3b6f09LBWLwaQRsv3b7FH8jBtXMZQRsv3b6u59LBrUodQRsv3b5AM8jBAgIcQRsv3b4p+9LB69IfQRsv3b6RRsjBtc4eQRsv3b5OENPB3lsiQRsv3b7OUcjBx5whQRsv3b70HNPB6OokQRsv3b4YXcjBeiwkQRsv3b5BKNPBzHgnQRsv3b5caMjB+McmQRsv3b6+M9PBDAIqQRsv3b71ccjB2JMpQRsv3b41PtPBxowsQRsv3b5PdcjBq1MsQRsv3b72QdPBOhwvQRsv3b6xeMjBJ+MuQRsv3b5YRdPBrqsxQRsv3b4TfMjBm3IxQRsv3b66SNPBITs0QRsv3b51f8jBFgI0QRsv3b4cTNPBk8o2QRsv3b7WgsjBkZE2QRsv3b59T9PBB1o5QRsv3b43hsjBBSE5QRsv3b7eUtPBeuk7QRsv3b6YicjBibA7QRsv3b4/VtPB7Hg+QRsv3b73jMjBDEA+QRsv3b6eWdPBXwhBQRsv3b5WkMjBf89AQRsv3b79XNPB0pdDQRsv3b61k8jB+15DQRsv3b5cYNPBRSdGQRsv3b4Tl8jBfu5FQRsv3b66Y9PBt7ZIQRsv3b5wmsjBCX5IQRsv3b4XZ9PBKEZLQRsv3b7LncjBlA1LQRsv3b5yatPBmdVNQRsv3b4locjBL51NQRsv3b7NbdPBB2VQQRsv3b57pMjB4CxQQRsv3b4jcdPBavRSQRsv3b7Np8jBBb1SQRsv3b52dNPBxCxVQRsv3b5+rsjBP8lYQRsv3b5hVNPB/CBXQRsv3b6PUMjBum9fQRsv3b6wSNLBA1tZQRsv3b7CyMfBFaFiQRsv3b7FidHB9qpbQRsv3b4KPMfBZfFkQRsv3b73/NDB6/pdQRsv3b5Nr8bBjUFnQRsv3b4ucNDB3kpgQRsv3b6OIsbBopFpQRsv3b5n48/B0JpiQRsv3b7NlcXBtOFrQRsv3b6eVs/BwupkQRsv3b4JCcXBtzFuQRsv3b7Wyc7BtDpnQRsv3b5FfMTBq4FwQRsv3b4SPc7BpYppQRsv3b6B78PBrdFyQRsv3b5KsM3BlNprQRsv3b68YsPBrCF1QRsv3b6AI83BhSpuQRsv3b721cLBnXF3QRsv3b66lszBdnpwQRsv3b4wScLBlsF5QRsv3b7zCczBZcpyQRsv3b5pvMHBjRF8QRsv3b4qfcvBbxh1QRsv3b4WMMHB9HR+QRsv3b6768rBF1J3QRsv3b7UpcDBy8WAQRsv3b5AKcrBEXV5QRsv3b4BBMDB9ziCQRsv3b5bUMnBaal7QRsv3b5IXb/BI1ODQRsv3b6iqcjB09d9QRsv3b5XuL7BrYuEQRsv3b6u8MfBtPN/QRsv3b52Dr7BXuuFQRsv3b7zEsfBZQOBQRsv3b5JV73B7SSHQRsv3b5mO8bBNhGCQRsv3b47nbzBvjKIQRsv3b5YgcXBrB2DQRsv3b4e5LvBc06JQRsv3b6kvcTBViOEQRsv3b6CKrvBjpKKQRsv3b4M18PBPSKFQRsv3b7eZLrBAsCLQRsv3b4b7sLBRyWGQRsv3b4WnLnBDMOMQRsv3b5TJcLBUyiHQRsv3b5O07jBFcaNQRsv3b6MXMHBwyiIQRsv3b6RDLjBz+OOQRsv3b7MfsDB8iGJQRsv3b5vQLfBSg2QQRsv3b5Gi7/B+RmKQRsv3b6ebbbBfxiRQRsv3b5OqL7BtBOLQRsv3b5dmbXBOhKSQRsv3b4N1L3Bbw2MQRsv3b4dxbTB8wuTQRsv3b7P/7zBkwWNQRsv3b468rPB+hWUQRsv3b6XHbzBMPiNQRsv3b7NHLPBEi+VQRsv3b5EJrvBDeiOQRsv3b4XQbLBeDOWQRsv3b7uN7rBvdmPQRsv3b65Y7HBKCWXQRsv3b6QWrnBbcuQQRsv3b5bhrDB2haYQRsv3b4wfbjBHL2RQRsv3b79qK/BiQiZQRsv3b7Sn7fBhKySQRsv3b69za7BnRGaQRsv3b7CrLbBvpWTQRsv3b737K3B1xmbQRsv3b5orrXB9X+UQRsv3b6zCK3BqQmcQRsv3b6xxLTBq2qVQRsv3b7zI6zBX/ScQRsv3b7x37PBYVWWQRsv3b4zP6vBFd+dQRsv3b4x+7LBF0CXQRsv3b5zWqrBy8meQRsv3b5xFrLBRSmYQRsv3b40d6nBMMSfQRsv3b5FIrHBqQ2ZQRsv3b6xkKjBJcKgQRsv3b4RIrDBivGZQRsv3b6jpqfBfa6hQRsv3b5bL6/BLNaaQRsv3b7Qu6bBH5OiQRsv3b6IRK7Bz7qbQRsv3b790KXBwHejQRsv3b63Wa3Bcp+cQRsv3b4q5qTBY1ykQRsv3b7kbqzBE4SdQRsv3b5X+6PBBkGlQRsv3b4PhKvB/3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+/")
+}]
diff --git a/world/interactables/fishing_shop_world.tscn b/world/interactables/fishing_shop_world.tscn
index 7b87a81..7221005 100644
--- a/world/interactables/fishing_shop_world.tscn
+++ b/world/interactables/fishing_shop_world.tscn
@@ -1,83 +1,35 @@
-[gd_scene load_steps=10 format=3]
+[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://world/fishing_shop_interaction.gd" id="1_shop"]
-
-[sub_resource type="BoxMesh" id="CounterMesh"]
-size = Vector3(4.2, 1.25, 1.8)
-
-[sub_resource type="BoxMesh" id="RoofMesh"]
-size = Vector3(5.2, 0.35, 3.2)
-
-[sub_resource type="BoxMesh" id="PostMesh"]
-size = Vector3(0.28, 3.2, 0.28)
-
-[sub_resource type="BoxMesh" id="BackMesh"]
-size = Vector3(5, 2.8, 0.25)
-
-[sub_resource type="StandardMaterial3D" id="PrimaryMaterial"]
-albedo_color = Color(0.12, 0.68, 0.62, 1)
-
-[sub_resource type="StandardMaterial3D" id="AccentMaterial"]
-albedo_color = Color(1, 0.68, 0.18, 1)
-
-[sub_resource type="StandardMaterial3D" id="BackMaterial"]
-albedo_color = Color(0.13, 0.34, 0.38, 1)
+[ext_resource type="PackedScene" path="res://art/exported/characters/base/netfishing_base_character.glb" id="2_character"]
+[ext_resource type="Script" path="res://world/world_character_display.gd" id="3_display"]
[sub_resource type="SphereShape3D" id="InteractionShape"]
+resource_local_to_scene = true
radius = 3.0
-[sub_resource type="BoxShape3D" id="CounterShape"]
-size = Vector3(4.2, 1.25, 1.8)
-
[node name="FishingShopWorld" type="Node3D"]
-[node name="Visuals" type="Node3D" parent="."]
+[node name="Shopkeeper" parent="." instance=ExtResource("2_character")]
+script = ExtResource("3_display")
+species_id = "pointy"
+fur_color_id = "white"
+ears_id = "pointy_wide"
+tail_id = "fox"
+eyes_id = "punk_purple"
+nose_id = "triangle_small"
+mouth_id = "slanted"
+head_look_enabled = true
-[node name="Counter" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.625, 0)
-mesh = SubResource("CounterMesh")
-material_override = SubResource("PrimaryMaterial")
+[node name="AnimationPlayer" parent="Shopkeeper"]
+autoplay = "idle"
-[node name="Back" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 1.4, 1.25)
-mesh = SubResource("BackMesh")
-material_override = SubResource("BackMaterial")
-
-[node name="Roof" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 3.15, 0.35)
-mesh = SubResource("RoofMesh")
-material_override = SubResource("AccentMaterial")
-
-[node name="WestPost" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-2.15, 1.6, -0.8)
-mesh = SubResource("PostMesh")
-material_override = SubResource("PrimaryMaterial")
-
-[node name="EastPost" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(2.15, 1.6, -0.8)
-mesh = SubResource("PostMesh")
-material_override = SubResource("PrimaryMaterial")
-
-[node name="ShopName" type="Label3D" parent="Visuals"]
-position = Vector3(0, 3.7, 0)
-text = "fishing shop"
-font_size = 42
-outline_size = 9
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="StaticBody3D" parent="."]
-
-[node name="CounterShape" type="CollisionShape3D" parent="StaticCollision"]
-position = Vector3(0, 0.625, 0)
-shape = SubResource("CounterShape")
-
-[node name="FishingShopInteraction" type="Area3D" parent="."]
+[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")
-[node name="CollisionShape3D" type="CollisionShape3D" parent="FishingShopInteraction"]
+[node name="InteractionShape" type="CollisionShape3D" parent="InteractionArea"]
shape = SubResource("InteractionShape")
diff --git a/world/interactables/pelican_buyer_world.tscn b/world/interactables/pelican_buyer_world.tscn
index 2fde14c..434f526 100644
--- a/world/interactables/pelican_buyer_world.tscn
+++ b/world/interactables/pelican_buyer_world.tscn
@@ -1,39 +1,46 @@
-[gd_scene load_steps=5 format=3]
+[gd_scene load_steps=6 format=3]
+
+[ext_resource type="Script" path="res://world/world_prop.gd" id="1_prop"]
[sub_resource type="BoxMesh" id="PerchMesh"]
+resource_local_to_scene = true
size = Vector3(5, 0.7, 2.2)
[sub_resource type="CylinderMesh" id="PostMesh"]
+resource_local_to_scene = true
top_radius = 0.18
bottom_radius = 0.24
height = 2.5
[sub_resource type="StandardMaterial3D" id="WoodMaterial"]
+resource_local_to_scene = true
albedo_color = Color(0.46, 0.28, 0.13, 1)
[sub_resource type="StandardMaterial3D" id="SignMaterial"]
+resource_local_to_scene = true
albedo_color = Color(0.95, 0.82, 0.42, 1)
[node name="PelicanCoolerPerch" type="Node3D"]
+script = ExtResource("1_prop")
-[node name="Visuals" type="Node3D" parent="."]
+[node name="Visual" type="Node3D" parent="."]
-[node name="Perch" type="MeshInstance3D" parent="Visuals"]
+[node name="Perch" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 1.65, 0)
mesh = SubResource("PerchMesh")
material_override = SubResource("WoodMaterial")
-[node name="WestPost" type="MeshInstance3D" parent="Visuals"]
+[node name="WestPost" type="MeshInstance3D" parent="Visual"]
position = Vector3(-1.8, 0.7, 0)
mesh = SubResource("PostMesh")
material_override = SubResource("WoodMaterial")
-[node name="EastPost" type="MeshInstance3D" parent="Visuals"]
+[node name="EastPost" type="MeshInstance3D" parent="Visual"]
position = Vector3(1.8, 0.7, 0)
mesh = SubResource("PostMesh")
material_override = SubResource("WoodMaterial")
-[node name="Sign" type="Label3D" parent="Visuals"]
+[node name="Sign" type="Label3D" parent="Visual"]
position = Vector3(0, 2.55, 0)
text = "sell to pelicans\nfrom the cooler"
font_size = 30
@@ -41,9 +48,9 @@ outline_size = 8
billboard = 1
no_depth_test = true
-[node name="ConvenienceLabel" type="Label3D" parent="Visuals"]
+[node name="ConvenienceLabel" type="Label3D" parent="Visual"]
position = Vector3(0, 1.65, -1.15)
-text = "available anywhere • 0.25x"
+text = "sell anytime • 0.25x"
font_size = 20
outline_size = 6
billboard = 1
diff --git a/world/materials/foliage_wind.gdshader b/world/materials/foliage_wind.gdshader
new file mode 100644
index 0000000..a1220e7
--- /dev/null
+++ b/world/materials/foliage_wind.gdshader
@@ -0,0 +1,44 @@
+shader_type spatial;
+render_mode cull_back, depth_draw_opaque;
+
+uniform vec4 albedo_color : source_color = vec4(1.0);
+uniform float material_roughness = 1.0;
+uniform float material_metallic = 0.0;
+uniform float local_min_y = 0.0;
+uniform float local_height = 1.0;
+uniform float local_wind_strength = 0.08;
+uniform float wind_speed = 0.9;
+uniform float wind_phase = 0.0;
+
+
+void vertex() {
+ float height_ratio = clamp(
+ (VERTEX.y - local_min_y) / max(local_height, 0.001),
+ 0.0,
+ 1.0
+ );
+ float bend_mask = smoothstep(0.08, 1.0, height_ratio);
+ vec3 world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+ float broad_wave = sin(
+ TIME * wind_speed
+ + dot(world_position.xz, vec2(0.23, 0.17))
+ + wind_phase
+ );
+ float leaf_flutter = sin(
+ TIME * wind_speed * 2.17
+ + dot(world_position.xz, vec2(-0.41, 0.37))
+ + wind_phase * 1.7
+ );
+ float displacement = (
+ broad_wave * 0.72 + leaf_flutter * 0.28
+ ) * local_wind_strength * bend_mask;
+ VERTEX.x += displacement;
+ VERTEX.z += displacement * 0.42;
+}
+
+
+void fragment() {
+ ALBEDO = albedo_color.rgb;
+ ROUGHNESS = material_roughness;
+ METALLIC = material_metallic;
+}
diff --git a/world/materials/foliage_wind.gdshader.uid b/world/materials/foliage_wind.gdshader.uid
new file mode 100644
index 0000000..e5162a5
--- /dev/null
+++ b/world/materials/foliage_wind.gdshader.uid
@@ -0,0 +1 @@
+uid://c3q7l8m9n2v4x
diff --git a/world/materials/shoreline_tide.gdshader b/world/materials/shoreline_tide.gdshader
new file mode 100644
index 0000000..f8a58e0
--- /dev/null
+++ b/world/materials/shoreline_tide.gdshader
@@ -0,0 +1,71 @@
+shader_type spatial;
+render_mode blend_mix, depth_draw_never, cull_disabled, unshaded;
+
+uniform vec4 lead_color : source_color = vec4(0.780, 0.960, 0.930, 1.0);
+uniform vec4 trail_color : source_color = vec4(0.500, 0.840, 0.820, 1.0);
+uniform float ribbon_width : hint_range(0.2, 2.0, 0.01) = 1.35;
+uniform float land_inset : hint_range(0.0, 0.4, 0.01) = 0.12;
+uniform float tide_speed : hint_range(0.1, 3.0, 0.01) = 1.42;
+uniform float tide_distance : hint_range(0.0, 0.6, 0.01) = 0.42;
+uniform float lead_width : hint_range(0.01, 0.2, 0.005) = 0.14;
+uniform float trail_width : hint_range(0.02, 0.4, 0.005) = 0.32;
+uniform float lead_strength : hint_range(0.0, 1.0, 0.01) = 0.88;
+uniform float trail_strength : hint_range(0.0, 1.0, 0.01) = 0.52;
+uniform float phase_scale : hint_range(0.001, 0.2, 0.001) = 0.035;
+uniform float phase_strength : hint_range(0.0, 2.0, 0.01) = 0.55;
+
+varying vec3 world_position;
+
+
+float hash_21(vec2 point) {
+ return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+
+float value_noise(vec2 point) {
+ vec2 cell = floor(point);
+ vec2 local = fract(point);
+ vec2 blend = local * local * (3.0 - 2.0 * local);
+ float bottom = mix(hash_21(cell), hash_21(cell + vec2(1.0, 0.0)), blend.x);
+ float top = mix(
+ hash_21(cell + vec2(0.0, 1.0)),
+ hash_21(cell + vec2(1.0, 1.0)),
+ blend.x
+ );
+ return mix(bottom, top, blend.y);
+}
+
+
+float soft_line(float coordinate, float center, float width) {
+ float normalized_width = max(width / ribbon_width, 0.001);
+ float derivative_width = min(fwidth(coordinate) * 1.25, normalized_width * 0.65);
+ return 1.0 - smoothstep(
+ max(normalized_width * 0.25 - derivative_width, 0.0),
+ normalized_width + derivative_width,
+ abs(coordinate - center)
+ );
+}
+
+
+void vertex() {
+ world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+}
+
+
+void fragment() {
+ float phase = (
+ value_noise(world_position.xz * phase_scale) * 2.0 - 1.0
+ ) * phase_strength;
+ float ebb = sin(TIME * tide_speed + phase) * 0.5 + 0.5;
+ float shoreline = land_inset / ribbon_width;
+ float lead_position = shoreline + ebb * tide_distance / ribbon_width;
+ float trail_position = lead_position + max(tide_distance * 0.72, 0.10) / ribbon_width;
+ float lead = soft_line(UV.y, lead_position, lead_width);
+ float trail = soft_line(UV.y, trail_position, trail_width);
+ float edge_fade = smoothstep(0.0, 0.055, UV.y) * (1.0 - smoothstep(0.90, 1.0, UV.y));
+ float lead_alpha = lead * lead_strength;
+ float trail_alpha = trail * trail_strength;
+ float combined_alpha = max(lead_alpha, trail_alpha) * edge_fade;
+ ALBEDO = mix(trail_color.rgb, lead_color.rgb, lead_alpha);
+ ALPHA = combined_alpha;
+}
diff --git a/world/materials/shoreline_tide.gdshader.uid b/world/materials/shoreline_tide.gdshader.uid
new file mode 100644
index 0000000..e8e5d36
--- /dev/null
+++ b/world/materials/shoreline_tide.gdshader.uid
@@ -0,0 +1 @@
+uid://5dauni8hb2lv
diff --git a/world/materials/shoreline_tide.tres b/world/materials/shoreline_tide.tres
new file mode 100644
index 0000000..65d2fa8
--- /dev/null
+++ b/world/materials/shoreline_tide.tres
@@ -0,0 +1,19 @@
+[gd_resource type="ShaderMaterial" load_steps=2 format=3]
+
+[ext_resource type="Shader" path="res://world/materials/shoreline_tide.gdshader" id="1_shader"]
+
+[resource]
+render_priority = 1
+shader = ExtResource("1_shader")
+shader_parameter/lead_color = Color(0.78, 0.96, 0.93, 1)
+shader_parameter/trail_color = Color(0.5, 0.84, 0.82, 1)
+shader_parameter/ribbon_width = 1.35
+shader_parameter/land_inset = 0.12
+shader_parameter/tide_speed = 1.42
+shader_parameter/tide_distance = 0.42
+shader_parameter/lead_width = 0.14
+shader_parameter/trail_width = 0.32
+shader_parameter/lead_strength = 0.88
+shader_parameter/trail_strength = 0.52
+shader_parameter/phase_scale = 0.035
+shader_parameter/phase_strength = 0.55
diff --git a/world/materials/stylized_water.gdshader b/world/materials/stylized_water.gdshader
new file mode 100644
index 0000000..fa6c266
--- /dev/null
+++ b/world/materials/stylized_water.gdshader
@@ -0,0 +1,592 @@
+shader_type spatial;
+render_mode blend_mix, depth_draw_never, cull_back, unshaded, fog_disabled;
+
+uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
+
+uniform vec4 shore_color : source_color = vec4(0.475, 0.788, 0.796, 1.0);
+uniform vec4 shallow_color : source_color = vec4(0.227, 0.604, 0.663, 1.0);
+uniform vec4 deep_color : source_color = vec4(0.110, 0.345, 0.435, 1.0);
+uniform vec4 tide_wash_color : source_color = vec4(0.360, 0.720, 0.735, 1.0);
+
+uniform float shallow_depth : hint_range(0.1, 3.0, 0.01) = 0.70;
+uniform float deep_depth : hint_range(0.5, 12.0, 0.05) = 3.60;
+uniform float shore_depth_width : hint_range(0.02, 1.0, 0.01) = 0.16;
+
+uniform float shore_alpha : hint_range(0.1, 1.0, 0.01) = 0.44;
+uniform float shallow_alpha : hint_range(0.1, 1.0, 0.01) = 0.60;
+uniform float deep_alpha : hint_range(0.1, 1.0, 0.01) = 0.82;
+
+uniform bool tide_effect_enabled = true;
+uniform float tide_speed : hint_range(0.1, 3.0, 0.01) = 1.42;
+uniform float tide_distance : hint_range(0.0, 0.6, 0.01) = 0.30;
+uniform float tide_wash_width : hint_range(0.05, 1.0, 0.01) = 0.38;
+uniform float tide_wash_strength : hint_range(0.0, 0.3, 0.01) = 0.11;
+uniform float tide_wash_alpha_shift : hint_range(0.0, 0.15, 0.005) = 0.045;
+
+uniform float phase_noise_scale : hint_range(0.001, 0.5, 0.001) = 0.055;
+uniform float phase_noise_strength : hint_range(0.0, 2.0, 0.01) = 0.68;
+uniform float contour_corner_variation : hint_range(0.0, 0.15, 0.005) = 0.045;
+uniform float contour_smoothing_pixels : hint_range(0.0, 2.5, 0.05) = 1.0;
+uniform float contour_smoothing_strength : hint_range(0.0, 1.0, 0.01) = 0.72;
+uniform float contour_depth_reject : hint_range(0.05, 2.0, 0.01) = 0.60;
+uniform float shoreline_horizontal_reach : hint_range(0.05, 1.5, 0.01) = 0.32;
+uniform float contact_fade_depth : hint_range(0.0, 0.1, 0.001) = 0.018;
+uniform float open_water_drift_scale : hint_range(0.001, 0.3, 0.001) = 0.018;
+uniform vec2 open_water_drift_speed = vec2(0.010, -0.007);
+uniform float open_water_drift_strength : hint_range(0.0, 0.08, 0.001) = 0.045;
+uniform vec4 surface_mark_color : source_color = vec4(0.580, 0.850, 0.860, 1.0);
+uniform vec2 surface_mark_density = vec2(1.30, 0.90);
+uniform vec2 surface_mark_drift = vec2(0.045, -0.032);
+uniform float surface_mark_strength : hint_range(0.0, 0.3, 0.005) = 0.12;
+uniform float surface_mottle_scale : hint_range(0.01, 1.0, 0.01) = 0.11;
+uniform vec2 surface_mottle_speed = vec2(-0.018, 0.013);
+uniform float surface_mottle_strength : hint_range(0.0, 0.2, 0.005) = 0.07;
+uniform vec2 shoreline_mark_density = vec2(4.60, 3.20);
+uniform vec2 shoreline_mark_drift = vec2(0.085, -0.060);
+uniform float shoreline_mark_depth : hint_range(0.1, 2.0, 0.01) = 0.95;
+uniform float shoreline_mark_strength : hint_range(0.0, 1.0, 0.01) = 0.82;
+uniform float shoreline_base_fill : hint_range(0.0, 1.0, 0.01) = 0.32;
+uniform float shoreline_opacity_boost : hint_range(0.0, 0.5, 0.01) = 0.26;
+uniform float surface_highlight_night_floor : hint_range(0.0, 1.0, 0.01) = 0.72;
+uniform float shoreline_minimum_reach : hint_range(0.1, 1.0, 0.01) = 0.48;
+
+uniform float distant_alpha_start : hint_range(10.0, 2000.0, 1.0) = 260.0;
+uniform float distant_alpha_end : hint_range(20.0, 5000.0, 1.0) = 1400.0;
+uniform vec4 environment_tint : source_color = vec4(1.0);
+uniform float environment_brightness : hint_range(0.1, 1.2, 0.01) = 1.0;
+uniform vec4 weather_fog_color : source_color = vec4(0.025, 0.038, 0.045, 1.0);
+uniform float weather_fog_amount : hint_range(0.0, 1.0, 0.01) = 0.0;
+uniform float weather_fog_near_amount : hint_range(0.0, 1.0, 0.01) = 0.0;
+uniform float weather_fog_begin : hint_range(0.0, 500.0, 0.5) = 20.0;
+uniform float weather_fog_end : hint_range(1.0, 1000.0, 0.5) = 95.0;
+uniform float weather_fog_curve : hint_range(0.1, 4.0, 0.01) = 1.4;
+
+varying vec3 world_position;
+varying float surface_view_depth;
+varying vec3 surface_view_position;
+
+
+float hash_21(vec2 point) {
+ return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453);
+}
+
+
+float value_noise(vec2 point) {
+ vec2 cell = floor(point);
+ vec2 local = fract(point);
+ vec2 blend = local * local * (3.0 - 2.0 * local);
+ float bottom = mix(hash_21(cell), hash_21(cell + vec2(1.0, 0.0)), blend.x);
+ float top = mix(
+ hash_21(cell + vec2(0.0, 1.0)),
+ hash_21(cell + vec2(1.0, 1.0)),
+ blend.x
+ );
+ return mix(bottom, top, blend.y);
+}
+
+
+float block_mark(vec2 point, vec2 half_size) {
+ return (
+ step(abs(point.x), half_size.x)
+ * step(abs(point.y), half_size.y)
+ );
+}
+
+
+float blocky_mark_layer(
+ vec2 flowing_grid,
+ float seed,
+ float occupancy_threshold
+) {
+ float row = floor(flowing_grid.y);
+ flowing_grid.x += mod(row, 2.0) * 0.43;
+ vec2 cell = floor(flowing_grid);
+ vec2 local = fract(flowing_grid) - vec2(0.5);
+ vec2 jitter = vec2(
+ hash_21(cell + vec2(3.7 + seed, 9.1)),
+ hash_21(cell + vec2(7.3, 2.9 + seed))
+ ) - vec2(0.5);
+ local -= jitter * vec2(0.52, 0.46);
+ local = floor((local + vec2(0.5)) * 8.0) / 8.0 - vec2(0.5);
+ float shape_key = hash_21(cell + vec2(13.1 + seed, 5.7));
+ float size_key = hash_21(cell + vec2(1.9, 17.3 + seed));
+ vec2 half_size = mix(vec2(0.10, 0.07), vec2(0.18, 0.12), size_key);
+ float mark = block_mark(local, half_size);
+ if (shape_key < 0.34) {
+ mark = max(
+ mark,
+ block_mark(
+ local - vec2(half_size.x * 0.8, -0.11),
+ vec2(half_size.x * 0.52, 0.045)
+ )
+ );
+ } else if (shape_key < 0.67) {
+ mark *= 1.0 - block_mark(
+ local - vec2(half_size.x * 0.7, 0.0),
+ vec2(0.045)
+ );
+ } else {
+ mark = max(
+ mark,
+ block_mark(
+ local + vec2(half_size.x * 0.75, 0.10),
+ vec2(0.055, 0.04)
+ )
+ );
+ }
+ return mark * step(
+ occupancy_threshold,
+ hash_21(cell + vec2(19.7 + seed, 23.3))
+ );
+}
+
+
+vec3 reconstruct_view_position(
+ vec2 screen_uv,
+ float raw_depth,
+ mat4 inverse_projection
+) {
+ #if CURRENT_RENDERER == RENDERER_COMPATIBILITY
+ vec3 ndc = vec3(screen_uv, raw_depth) * 2.0 - 1.0;
+ #else
+ vec3 ndc = vec3(screen_uv * 2.0 - 1.0, raw_depth);
+ #endif
+ vec4 view_position = inverse_projection * vec4(ndc, 1.0);
+ return view_position.xyz / max(abs(view_position.w), 0.00001);
+}
+
+
+vec3 reconstruct_world_position(
+ vec2 screen_uv,
+ float raw_depth,
+ mat4 inverse_projection,
+ mat4 inverse_view
+) {
+ vec3 view_position = reconstruct_view_position(
+ screen_uv,
+ raw_depth,
+ inverse_projection
+ );
+ return (inverse_view * vec4(view_position, 1.0)).xyz;
+}
+
+
+float sample_water_depth(
+ vec2 screen_uv,
+ float water_surface_height,
+ mat4 inverse_projection,
+ mat4 inverse_view
+) {
+ float raw_depth = texture(depth_texture, screen_uv).r;
+ vec3 scene_world_position = reconstruct_world_position(
+ screen_uv,
+ raw_depth,
+ inverse_projection,
+ inverse_view
+ );
+ return water_surface_height - scene_world_position.y;
+}
+
+
+float contour_neighbor_weight(
+ float center_depth,
+ float neighbor_depth,
+ float rejection_distance
+) {
+ if (neighbor_depth <= 0.0) {
+ return 0.0;
+ }
+ float safe_rejection = max(rejection_distance, 0.001);
+ return 1.0 - smoothstep(
+ safe_rejection * 0.35,
+ safe_rejection,
+ abs(neighbor_depth - center_depth)
+ );
+}
+
+
+void vertex() {
+ vec4 world_vertex = MODEL_MATRIX * vec4(VERTEX, 1.0);
+ vec4 view_vertex = VIEW_MATRIX * world_vertex;
+ world_position = world_vertex.xyz;
+ surface_view_depth = -view_vertex.z;
+ // Godot's depth fog measures radial view-space distance. Keep the existing
+ // forward depth for distant-water presentation, but pass the linear view
+ // position so weather fog can derive the same per-fragment radial metric as
+ // opaque world geometry. Calculating length here would interpolate four
+ // corner distances across the oversized ocean plane and over-fog its center.
+ surface_view_position = view_vertex.xyz;
+}
+
+
+void fragment() {
+ float raw_depth = texture(depth_texture, SCREEN_UV).r;
+ vec3 scene_world_position = reconstruct_world_position(
+ SCREEN_UV,
+ raw_depth,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float water_depth = max(world_position.y - scene_world_position.y, 0.0);
+ water_depth = min(water_depth, deep_depth * 4.0);
+
+ float contour_depth = water_depth;
+ float contour_feature_support = 1.0;
+ if (tide_effect_enabled) {
+ vec2 contour_texel = (
+ vec2(1.0) / vec2(textureSize(depth_texture, 0))
+ * contour_smoothing_pixels
+ );
+ float contour_depth_sum = water_depth;
+ float contour_weight_sum = 1.0;
+ float left_depth = sample_water_depth(
+ SCREEN_UV - vec2(contour_texel.x, 0.0),
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float right_depth = sample_water_depth(
+ SCREEN_UV + vec2(contour_texel.x, 0.0),
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float upper_depth = sample_water_depth(
+ SCREEN_UV - vec2(0.0, contour_texel.y),
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float lower_depth = sample_water_depth(
+ SCREEN_UV + vec2(0.0, contour_texel.y),
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float upper_left_depth = sample_water_depth(
+ SCREEN_UV - contour_texel,
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float upper_right_depth = sample_water_depth(
+ SCREEN_UV + vec2(contour_texel.x, -contour_texel.y),
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float lower_left_depth = sample_water_depth(
+ SCREEN_UV + vec2(-contour_texel.x, contour_texel.y),
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float lower_right_depth = sample_water_depth(
+ SCREEN_UV + contour_texel,
+ world_position.y,
+ INV_PROJECTION_MATRIX,
+ INV_VIEW_MATRIX
+ );
+ float left_weight = contour_neighbor_weight(
+ water_depth, left_depth, contour_depth_reject
+ );
+ float right_weight = contour_neighbor_weight(
+ water_depth, right_depth, contour_depth_reject
+ );
+ float upper_weight = contour_neighbor_weight(
+ water_depth, upper_depth, contour_depth_reject
+ );
+ float lower_weight = contour_neighbor_weight(
+ water_depth, lower_depth, contour_depth_reject
+ );
+ float diagonal_scale = 0.70710678;
+ float upper_left_weight = contour_neighbor_weight(
+ water_depth, upper_left_depth, contour_depth_reject
+ ) * diagonal_scale;
+ float upper_right_weight = contour_neighbor_weight(
+ water_depth, upper_right_depth, contour_depth_reject
+ ) * diagonal_scale;
+ float lower_left_weight = contour_neighbor_weight(
+ water_depth, lower_left_depth, contour_depth_reject
+ ) * diagonal_scale;
+ float lower_right_weight = contour_neighbor_weight(
+ water_depth, lower_right_depth, contour_depth_reject
+ ) * diagonal_scale;
+ contour_depth_sum += left_depth * left_weight;
+ contour_depth_sum += right_depth * right_weight;
+ contour_depth_sum += upper_depth * upper_weight;
+ contour_depth_sum += lower_depth * lower_weight;
+ contour_depth_sum += upper_left_depth * upper_left_weight;
+ contour_depth_sum += upper_right_depth * upper_right_weight;
+ contour_depth_sum += lower_left_depth * lower_left_weight;
+ contour_depth_sum += lower_right_depth * lower_right_weight;
+ float neighbor_support = (
+ left_weight
+ + right_weight
+ + upper_weight
+ + lower_weight
+ + upper_left_weight
+ + upper_right_weight
+ + lower_left_weight
+ + lower_right_weight
+ );
+ contour_weight_sum += neighbor_support;
+ float smoothed_contour_depth = contour_depth_sum / contour_weight_sum;
+ contour_depth = mix(
+ water_depth,
+ smoothed_contour_depth,
+ contour_smoothing_strength
+ );
+ contour_feature_support = smoothstep(0.75, 1.75, neighbor_support);
+ }
+
+ vec2 scene_world_dx = dFdx(scene_world_position.xz);
+ vec2 scene_world_dy = dFdy(scene_world_position.xz);
+ float horizontal_dx = max(length(scene_world_dx), 0.0001);
+ float horizontal_dy = max(length(scene_world_dy), 0.0001);
+ float depth_gradient = length(vec2(
+ dFdx(water_depth) / horizontal_dx,
+ dFdy(water_depth) / horizontal_dy
+ ));
+ float estimated_shore_distance = min(
+ water_depth / max(depth_gradient, 0.001),
+ shoreline_horizontal_reach * 4.0
+ );
+ float proximity_center = shoreline_horizontal_reach * 0.86;
+ float proximity_filter_width = max(
+ fwidth(estimated_shore_distance) * 1.5,
+ shoreline_horizontal_reach * 0.12
+ );
+ float shoreline_proximity = 1.0 - smoothstep(
+ max(proximity_center - proximity_filter_width, 0.0),
+ proximity_center + proximity_filter_width,
+ estimated_shore_distance
+ );
+ float contact_filter_width = max(fwidth(water_depth) * 1.5, 0.004);
+ float contact_stability = smoothstep(
+ max(contact_fade_depth - contact_filter_width, 0.0),
+ contact_fade_depth + contact_filter_width,
+ water_depth
+ );
+
+ float shallow_mix = smoothstep(0.0, shallow_depth, water_depth);
+ float deep_mix = smoothstep(shallow_depth, deep_depth, water_depth);
+ vec3 water_color = mix(shore_color.rgb, shallow_color.rgb, shallow_mix);
+ water_color = mix(water_color, deep_color.rgb, deep_mix);
+ float water_alpha = mix(shore_alpha, shallow_alpha, shallow_mix);
+ water_alpha = mix(water_alpha, deep_alpha, deep_mix);
+
+ if (tide_effect_enabled) {
+ vec2 phase_coordinates = world_position.xz * phase_noise_scale;
+ float phase_variation = (
+ value_noise(phase_coordinates)
+ + value_noise(phase_coordinates * 0.47 + vec2(9.7, 3.1)) * 0.5
+ - 0.75
+ ) * phase_noise_strength;
+ float corner_variation = (
+ value_noise(phase_coordinates * 1.73 + vec2(4.2, 13.6)) * 2.0 - 1.0
+ ) * contour_corner_variation;
+ float ebb = sin(TIME * tide_speed + phase_variation) * 0.5 + 0.5;
+ float lead_threshold = shore_depth_width + ebb * tide_distance + corner_variation;
+ float tide_wash = 1.0 - smoothstep(
+ lead_threshold,
+ lead_threshold + max(tide_wash_width, 0.001),
+ contour_depth
+ );
+ tide_wash *= contour_feature_support;
+ tide_wash *= shoreline_proximity;
+ tide_wash *= 1.0 - smoothstep(shallow_depth, deep_depth, water_depth);
+ water_color = mix(
+ water_color,
+ tide_wash_color.rgb,
+ tide_wash * tide_wash_strength
+ );
+ water_alpha = min(
+ water_alpha
+ + tide_wash * tide_wash_alpha_shift,
+ 0.96
+ );
+ }
+
+ vec2 flowing_grid = (
+ world_position.xz * surface_mark_density
+ + TIME * surface_mark_drift
+ );
+ float surface_row = floor(flowing_grid.y);
+ flowing_grid.x += mod(surface_row, 2.0) * 0.43;
+ vec2 surface_cell = floor(flowing_grid);
+ vec2 surface_local = fract(flowing_grid) - vec2(0.5);
+ vec2 surface_jitter = vec2(
+ hash_21(surface_cell + vec2(3.7, 9.1)),
+ hash_21(surface_cell + vec2(7.3, 2.9))
+ ) - vec2(0.5);
+ surface_local -= surface_jitter * vec2(0.52, 0.46);
+ surface_local = (
+ floor((surface_local + vec2(0.5)) * 8.0) / 8.0
+ - vec2(0.5)
+ );
+ float surface_shape = hash_21(surface_cell + vec2(13.1, 5.7));
+ float surface_size = hash_21(surface_cell + vec2(1.9, 17.3));
+ vec2 surface_half_size = mix(
+ vec2(0.10, 0.07),
+ vec2(0.18, 0.12),
+ surface_size
+ );
+ float surface_mark = block_mark(surface_local, surface_half_size);
+ if (surface_shape < 0.34) {
+ surface_mark = max(
+ surface_mark,
+ block_mark(
+ surface_local - vec2(surface_half_size.x * 0.8, -0.11),
+ vec2(surface_half_size.x * 0.52, 0.045)
+ )
+ );
+ } else if (surface_shape < 0.67) {
+ surface_mark *= 1.0 - block_mark(
+ surface_local - vec2(surface_half_size.x * 0.7, 0.0),
+ vec2(0.045)
+ );
+ } else {
+ surface_mark = max(
+ surface_mark,
+ block_mark(
+ surface_local + vec2(surface_half_size.x * 0.75, 0.10),
+ vec2(0.055, 0.04)
+ )
+ );
+ }
+ surface_mark *= step(
+ 0.22,
+ hash_21(surface_cell + vec2(19.7, 23.3))
+ );
+ float mark_visibility = mix(1.0, 0.68, deep_mix);
+ float surface_highlight = (
+ surface_mark * surface_mark_strength * mark_visibility
+ );
+
+ vec2 shoreline_phase_coordinates = world_position.xz * phase_noise_scale;
+ float shoreline_phase = (
+ value_noise(shoreline_phase_coordinates)
+ + value_noise(
+ shoreline_phase_coordinates * 0.47 + vec2(9.7, 3.1)
+ ) * 0.5
+ - 0.75
+ ) * phase_noise_strength;
+ float shoreline_cycle = (
+ sin(TIME * tide_speed + shoreline_phase) * 0.5 + 0.5
+ );
+ float animated_shoreline_depth = shoreline_mark_depth * mix(
+ shoreline_minimum_reach,
+ 1.0,
+ shoreline_cycle
+ );
+ float shoreline_mask = 1.0 - smoothstep(
+ max(animated_shoreline_depth * 0.08, 0.01),
+ max(animated_shoreline_depth, 0.02),
+ contour_depth
+ );
+ float shoreline_depth_visibility = 1.0 - smoothstep(
+ shallow_depth,
+ deep_depth,
+ water_depth
+ );
+ shoreline_mask *= shoreline_depth_visibility;
+ float shoreline_marks = max(
+ blocky_mark_layer(
+ world_position.xz * shoreline_mark_density
+ + TIME * shoreline_mark_drift,
+ 17.0,
+ 0.04
+ ),
+ blocky_mark_layer(
+ world_position.xz * shoreline_mark_density * 1.37
+ - TIME * shoreline_mark_drift.yx * 0.73,
+ 41.0,
+ 0.08
+ )
+ );
+ float shoreline_foam = shoreline_mask * mix(
+ shoreline_base_fill,
+ 1.0,
+ shoreline_marks
+ );
+ shoreline_foam *= shoreline_proximity;
+ water_alpha = min(
+ water_alpha + shoreline_foam * shoreline_opacity_boost,
+ 0.98
+ );
+ // Preserve most of the water layer at exact terrain contact. Fully fading
+ // it exposed the sand texture as a broken tan seam along the water plane.
+ water_alpha *= mix(0.82, 1.0, contact_stability);
+
+ float surface_mottle = value_noise(
+ world_position.xz * surface_mottle_scale
+ + TIME * surface_mottle_speed
+ );
+ surface_mottle = floor(surface_mottle * 6.0) / 5.0;
+ water_color *= 1.0 + (
+ surface_mottle * 2.0 - 1.0
+ ) * surface_mottle_strength;
+
+ vec2 drift_coordinates = (
+ world_position.xz * open_water_drift_scale
+ + TIME * open_water_drift_speed
+ );
+ float broad_drift = value_noise(drift_coordinates) * 2.0 - 1.0;
+ water_color *= 1.0 + broad_drift * open_water_drift_strength * deep_mix;
+
+ float distant_mix = smoothstep(
+ distant_alpha_start,
+ max(distant_alpha_end, distant_alpha_start + 1.0),
+ surface_view_depth
+ );
+ water_color = mix(water_color, deep_color.rgb, distant_mix * 0.72);
+ water_alpha = mix(water_alpha, 0.96, distant_mix);
+
+ water_color *= environment_tint.rgb * environment_brightness;
+ vec3 visible_highlight_color = surface_mark_color.rgb * max(
+ environment_tint.rgb * environment_brightness,
+ vec3(surface_highlight_night_floor)
+ );
+ float visible_highlight = max(
+ surface_highlight,
+ shoreline_foam * shoreline_mark_strength
+ );
+ water_color = mix(
+ water_color,
+ visible_highlight_color,
+ clamp(visible_highlight, 0.0, 1.0)
+ );
+ float surface_view_distance = length(surface_view_position);
+ float weather_fog_distance = smoothstep(
+ weather_fog_begin,
+ max(weather_fog_end, weather_fog_begin + 1.0),
+ surface_view_distance
+ );
+ weather_fog_distance = pow(
+ clamp(weather_fog_distance, 0.0, 1.0),
+ weather_fog_curve
+ );
+ float distance_fog_mix = weather_fog_amount * weather_fog_distance;
+ float weather_fog_mix = 1.0 - (
+ (1.0 - weather_fog_near_amount)
+ * (1.0 - distance_fog_mix)
+ );
+ water_color = mix(
+ water_color,
+ weather_fog_color.rgb,
+ weather_fog_mix
+ );
+ float weather_fog_alpha = mix(
+ 0.96,
+ 1.0,
+ weather_fog_near_amount
+ );
+ water_alpha = mix(water_alpha, weather_fog_alpha, weather_fog_mix);
+
+ ALBEDO = water_color;
+ ALPHA = clamp(water_alpha, 0.1, weather_fog_alpha);
+ METALLIC = 0.0;
+ SPECULAR = 0.0;
+ ROUGHNESS = 1.0;
+}
diff --git a/world/materials/stylized_water.gdshader.uid b/world/materials/stylized_water.gdshader.uid
new file mode 100644
index 0000000..e808f87
--- /dev/null
+++ b/world/materials/stylized_water.gdshader.uid
@@ -0,0 +1 @@
+uid://lvo7xyr3c1k3
diff --git a/world/materials/stylized_water.tres b/world/materials/stylized_water.tres
new file mode 100644
index 0000000..7f4671a
--- /dev/null
+++ b/world/materials/stylized_water.tres
@@ -0,0 +1,53 @@
+[gd_resource type="ShaderMaterial" format=3 uid="uid://6lafg0ac2qul"]
+
+[ext_resource type="Shader" uid="uid://lvo7xyr3c1k3" path="res://world/materials/stylized_water.gdshader" id="1_water_shader"]
+
+[resource]
+render_priority = 0
+shader = ExtResource("1_water_shader")
+shader_parameter/shore_color = Color(0.47451, 0.788235, 0.796078, 1)
+shader_parameter/shallow_color = Color(0.227451, 0.603922, 0.662745, 1)
+shader_parameter/deep_color = Color(0.109804, 0.345098, 0.435294, 1)
+shader_parameter/tide_wash_color = Color(0.360784, 0.721569, 0.733333, 1)
+shader_parameter/shallow_depth = 0.7
+shader_parameter/deep_depth = 3.6
+shader_parameter/shore_depth_width = 0.1
+shader_parameter/shore_alpha = 0.68
+shader_parameter/shallow_alpha = 0.78
+shader_parameter/deep_alpha = 0.9
+shader_parameter/tide_effect_enabled = true
+shader_parameter/tide_speed = 1.42
+shader_parameter/tide_distance = 0.11
+shader_parameter/tide_wash_width = 0.16
+shader_parameter/tide_wash_strength = 0.11
+shader_parameter/tide_wash_alpha_shift = 0.045
+shader_parameter/phase_noise_scale = 0.055
+shader_parameter/phase_noise_strength = 0.68
+shader_parameter/contour_corner_variation = 0.025
+shader_parameter/contour_smoothing_pixels = 1.35
+shader_parameter/contour_smoothing_strength = 0.88
+shader_parameter/contour_depth_reject = 0.6
+shader_parameter/shoreline_horizontal_reach = 0.32
+shader_parameter/contact_fade_depth = 0.018
+shader_parameter/open_water_drift_scale = 0.018
+shader_parameter/open_water_drift_speed = Vector2(0.01, -0.007)
+shader_parameter/open_water_drift_strength = 0.045
+shader_parameter/surface_mark_color = Color(0.58, 0.85, 0.86, 1)
+shader_parameter/surface_mark_density = Vector2(1.5, 1.05)
+shader_parameter/surface_mark_drift = Vector2(0.045, -0.032)
+shader_parameter/surface_mark_strength = 0.12
+shader_parameter/surface_mottle_scale = 0.11
+shader_parameter/surface_mottle_speed = Vector2(-0.018, 0.013)
+shader_parameter/surface_mottle_strength = 0.07
+shader_parameter/shoreline_mark_density = Vector2(4.6, 3.2)
+shader_parameter/shoreline_mark_drift = Vector2(0.085, -0.06)
+shader_parameter/shoreline_mark_depth = 0.55
+shader_parameter/shoreline_mark_strength = 0.95
+shader_parameter/shoreline_base_fill = 0.58
+shader_parameter/shoreline_opacity_boost = 0.38
+shader_parameter/surface_highlight_night_floor = 0.72
+shader_parameter/shoreline_minimum_reach = 0.22
+shader_parameter/distant_alpha_start = 260.0
+shader_parameter/distant_alpha_end = 1400.0
+shader_parameter/environment_tint = Color(1, 1, 1, 1)
+shader_parameter/environment_brightness = 1.0
diff --git a/world/materials/stylized_water_fresh.tres b/world/materials/stylized_water_fresh.tres
new file mode 100644
index 0000000..5fe4c8f
--- /dev/null
+++ b/world/materials/stylized_water_fresh.tres
@@ -0,0 +1,53 @@
+[gd_resource type="ShaderMaterial" format=3 uid="uid://d27er7sx5bcqw"]
+
+[ext_resource type="Shader" uid="uid://lvo7xyr3c1k3" path="res://world/materials/stylized_water.gdshader" id="1_water_shader"]
+
+[resource]
+render_priority = 0
+shader = ExtResource("1_water_shader")
+shader_parameter/shore_color = Color(0.47451, 0.788235, 0.796078, 1)
+shader_parameter/shallow_color = Color(0.227451, 0.603922, 0.662745, 1)
+shader_parameter/deep_color = Color(0.109804, 0.345098, 0.435294, 1)
+shader_parameter/tide_wash_color = Color(0.360784, 0.721569, 0.733333, 1)
+shader_parameter/shallow_depth = 0.7
+shader_parameter/deep_depth = 3.6
+shader_parameter/shore_depth_width = 0.1
+shader_parameter/shore_alpha = 0.68
+shader_parameter/shallow_alpha = 0.78
+shader_parameter/deep_alpha = 0.9
+shader_parameter/tide_effect_enabled = false
+shader_parameter/tide_speed = 1.42
+shader_parameter/tide_distance = 0.3
+shader_parameter/tide_wash_width = 0.38
+shader_parameter/tide_wash_strength = 0.11
+shader_parameter/tide_wash_alpha_shift = 0.045
+shader_parameter/phase_noise_scale = 0.055
+shader_parameter/phase_noise_strength = 0.68
+shader_parameter/contour_corner_variation = 0.045
+shader_parameter/contour_smoothing_pixels = 1.35
+shader_parameter/contour_smoothing_strength = 0.88
+shader_parameter/contour_depth_reject = 0.6
+shader_parameter/shoreline_horizontal_reach = 0.32
+shader_parameter/contact_fade_depth = 0.018
+shader_parameter/open_water_drift_scale = 0.018
+shader_parameter/open_water_drift_speed = Vector2(0.01, -0.007)
+shader_parameter/open_water_drift_strength = 0.045
+shader_parameter/surface_mark_color = Color(0.58, 0.85, 0.86, 1)
+shader_parameter/surface_mark_density = Vector2(1.5, 1.05)
+shader_parameter/surface_mark_drift = Vector2(0.045, -0.032)
+shader_parameter/surface_mark_strength = 0.12
+shader_parameter/surface_mottle_scale = 0.11
+shader_parameter/surface_mottle_speed = Vector2(-0.018, 0.013)
+shader_parameter/surface_mottle_strength = 0.07
+shader_parameter/shoreline_mark_density = Vector2(4.6, 3.2)
+shader_parameter/shoreline_mark_drift = Vector2(0.085, -0.06)
+shader_parameter/shoreline_mark_depth = 0.55
+shader_parameter/shoreline_mark_strength = 0.95
+shader_parameter/shoreline_base_fill = 0.58
+shader_parameter/shoreline_opacity_boost = 0.38
+shader_parameter/surface_highlight_night_floor = 0.72
+shader_parameter/shoreline_minimum_reach = 0.22
+shader_parameter/distant_alpha_start = 260.0
+shader_parameter/distant_alpha_end = 1400.0
+shader_parameter/environment_tint = Color(1, 1, 1, 1)
+shader_parameter/environment_brightness = 1.0
diff --git a/world/materials/terrain_surface_projection.gdshader b/world/materials/terrain_surface_projection.gdshader
new file mode 100644
index 0000000..b879c63
--- /dev/null
+++ b/world/materials/terrain_surface_projection.gdshader
@@ -0,0 +1,46 @@
+shader_type spatial;
+render_mode cull_back;
+
+uniform sampler2D albedo_texture : source_color, repeat_enable, filter_nearest;
+uniform vec4 albedo_tint : source_color = vec4(1.0);
+uniform float tile_world_size : hint_range(0.1, 20.0, 0.05) = 2.5;
+uniform float blend_sharpness : hint_range(1.0, 16.0, 0.25) = 6.0;
+uniform float top_projection_bias : hint_range(1.0, 8.0, 0.05) = 2.0;
+uniform float roughness : hint_range(0.0, 1.0, 0.01) = 1.0;
+uniform float specular : hint_range(0.0, 1.0, 0.01) = 0.5;
+
+varying vec3 world_position;
+varying vec3 world_normal;
+
+
+void vertex() {
+ world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+ world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL);
+}
+
+
+void fragment() {
+ vec3 normal = normalize(world_normal);
+ vec3 weights = pow(abs(normal), vec3(max(blend_sharpness, 1.0)));
+ float upward_amount = smoothstep(0.0, 0.8, normal.y);
+ weights.y *= mix(1.0, max(top_projection_bias, 1.0), upward_amount);
+ weights /= max(weights.x + weights.y + weights.z, 0.00001);
+
+ float world_scale = 1.0 / max(tile_world_size, 0.001);
+ vec2 x_uv = world_position.zy * vec2(sign(normal.x), 1.0) * world_scale;
+ vec2 y_uv = world_position.xz * vec2(sign(normal.y), 1.0) * world_scale;
+ vec2 z_uv = world_position.xy * vec2(-sign(normal.z), 1.0) * world_scale;
+ vec4 x_sample = texture(albedo_texture, x_uv);
+ vec4 y_sample = texture(albedo_texture, y_uv);
+ vec4 z_sample = texture(albedo_texture, z_uv);
+ vec4 projected = (
+ x_sample * weights.x
+ + y_sample * weights.y
+ + z_sample * weights.z
+ );
+
+ ALBEDO = projected.rgb * albedo_tint.rgb;
+ ROUGHNESS = roughness;
+ SPECULAR = specular;
+ METALLIC = 0.0;
+}
diff --git a/world/materials/terrain_surface_projection.gdshader.uid b/world/materials/terrain_surface_projection.gdshader.uid
new file mode 100644
index 0000000..dd9fa70
--- /dev/null
+++ b/world/materials/terrain_surface_projection.gdshader.uid
@@ -0,0 +1 @@
+uid://d2ju2vpykktuo
diff --git a/world/player_water_trigger.gd b/world/player_water_trigger.gd
index 3f2dc8c..fbd84ba 100644
--- a/world/player_water_trigger.gd
+++ b/world/player_water_trigger.gd
@@ -1,9 +1,24 @@
+@tool
class_name PlayerWaterTrigger
extends Area3D
signal recovery_requested(player: Player, surface_height: float)
+enum SurfaceHeightMode {
+ EXPLICIT,
+ PARENT_GLOBAL_Y,
+}
+
+@export_group("Surface")
+@export var surface_height_mode: SurfaceHeightMode = SurfaceHeightMode.EXPLICIT:
+ set(value):
+ surface_height_mode = value
+ if Engine.is_editor_hint():
+ notify_property_list_changed()
+## Legacy explicit height used when Surface Height Mode is Explicit.
@export var surface_height: float = 0.2
+@export_group("Recovery Trigger")
+## Recovery begins when the player's body center reaches this depth.
@export_range(0.0, 2.0, 0.05) var entry_depth_threshold: float = 0.35
var _tracked_players: Array[Player] = []
@@ -11,11 +26,15 @@ var _triggered_players: Dictionary[StringName, bool] = {}
func _ready() -> void:
+ if Engine.is_editor_hint():
+ set_physics_process(false)
+ return
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _physics_process(_delta: float) -> void:
+ var active_surface_height: float = get_surface_height()
for player: Player in _tracked_players.duplicate():
if not is_instance_valid(player):
_tracked_players.erase(player)
@@ -25,10 +44,26 @@ func _physics_process(_delta: float) -> void:
continue
if (
player.get_body_center_position().y
- <= surface_height - entry_depth_threshold
+ <= active_surface_height - entry_depth_threshold
):
_triggered_players[player_key] = true
- recovery_requested.emit(player, surface_height)
+ recovery_requested.emit(player, active_surface_height)
+
+
+func get_surface_height() -> float:
+ if surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y:
+ var surface_owner := get_parent() as Node3D
+ if surface_owner != null:
+ return surface_owner.global_position.y
+ return surface_height
+
+
+func _validate_property(property: Dictionary) -> void:
+ if (
+ property.name == "surface_height"
+ and surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y
+ ):
+ property.usage = int(property.usage) & ~PROPERTY_USAGE_EDITOR
func _on_body_entered(body: Node3D) -> void:
diff --git a/world/rain_ambience.gd b/world/rain_ambience.gd
new file mode 100644
index 0000000..acea9fd
--- /dev/null
+++ b/world/rain_ambience.gd
@@ -0,0 +1,121 @@
+class_name RainAmbience
+extends AudioStreamPlayer
+
+const RAIN_STREAM_PATH: String = (
+ "res://audio/ambience/rain_loop_ontario.ogg"
+)
+const SILENT_VOLUME_DB: float = -60.0
+
+@export_range(-40.0, 0.0, 0.5) var rain_volume_db: float = -8.0
+@export_range(0.0, 10.0, 0.1) var fade_seconds: float = 2.5
+
+var _weather_service: WorldWeatherService
+var _fade_tween: Tween
+var _is_active: bool = false
+
+
+func _ready() -> void:
+ bus = &"Environment"
+ var rain_stream := load(RAIN_STREAM_PATH) as AudioStreamOggVorbis
+ var runtime_stream := (
+ rain_stream.duplicate() as AudioStreamOggVorbis
+ if rain_stream != null
+ else null
+ )
+ if runtime_stream != null:
+ runtime_stream.loop = true
+ stream = runtime_stream
+ volume_db = SILENT_VOLUME_DB
+
+
+func configure(weather_service: WorldWeatherService) -> void:
+ if (
+ _weather_service != null
+ and _weather_service.weather_changed.is_connected(
+ _on_weather_changed
+ )
+ ):
+ _weather_service.weather_changed.disconnect(_on_weather_changed)
+ _weather_service = weather_service
+ if (
+ _weather_service != null
+ and not _weather_service.weather_changed.is_connected(
+ _on_weather_changed
+ )
+ ):
+ _weather_service.weather_changed.connect(_on_weather_changed)
+
+
+func set_active(active: bool) -> void:
+ if _is_active == active:
+ return
+ _is_active = active
+ if not active:
+ _silence_immediately()
+ return
+ _transition_to_rain(
+ _weather_service != null and _weather_service.is_raining()
+ )
+
+
+func _on_weather_changed(
+ weather: WorldWeatherService.Weather,
+ _seconds_remaining: float,
+) -> void:
+ _transition_to_rain(weather == WorldWeatherService.Weather.RAINY)
+
+
+func _transition_to_rain(is_raining: bool) -> void:
+ if not _is_active:
+ return
+ _stop_fade()
+ if is_raining:
+ if stream == null:
+ return
+ if not playing:
+ volume_db = SILENT_VOLUME_DB
+ play()
+ _fade_volume_to(rain_volume_db, false)
+ return
+ if playing:
+ _fade_volume_to(SILENT_VOLUME_DB, true)
+
+
+func _fade_volume_to(target_volume_db: float, stop_after_fade: bool) -> void:
+ if fade_seconds <= 0.0:
+ volume_db = target_volume_db
+ if stop_after_fade:
+ _finish_fade_out()
+ return
+ _fade_tween = create_tween()
+ _fade_tween.set_trans(Tween.TRANS_SINE)
+ _fade_tween.set_ease(Tween.EASE_IN_OUT)
+ _fade_tween.tween_property(
+ self,
+ "volume_db",
+ target_volume_db,
+ fade_seconds,
+ )
+ if stop_after_fade:
+ _fade_tween.tween_callback(_finish_fade_out)
+
+
+func _finish_fade_out() -> void:
+ if _weather_service != null and _weather_service.is_raining():
+ return
+ stop()
+ volume_db = SILENT_VOLUME_DB
+ _fade_tween = null
+
+
+func _silence_immediately() -> void:
+ _stop_fade()
+ stop()
+ volume_db = SILENT_VOLUME_DB
+
+
+func _stop_fade() -> void:
+ if _fade_tween == null:
+ return
+ _fade_tween.kill()
+ _fade_tween = null
diff --git a/world/rain_ambience.gd.uid b/world/rain_ambience.gd.uid
new file mode 100644
index 0000000..0eccbff
--- /dev/null
+++ b/world/rain_ambience.gd.uid
@@ -0,0 +1 @@
+uid://dbpt2uqm82kmn
diff --git a/world/regions/coastal_dock_region.tscn b/world/regions/coastal_dock_region.tscn
deleted file mode 100644
index 475d91d..0000000
--- a/world/regions/coastal_dock_region.tscn
+++ /dev/null
@@ -1,140 +0,0 @@
-[gd_scene load_steps=21 format=3]
-
-[ext_resource type="Script" path="res://world/regions/graybox_region.gd" id="1_region"]
-[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_water"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="3_pool"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="4_trigger"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="5_safe"]
-
-[sub_resource type="BoxShape3D" id="ShoreShape"]
-size = Vector3(90, 6, 18)
-[sub_resource type="BoxMesh" id="ShoreMesh"]
-size = Vector3(90, 6, 18)
-[sub_resource type="BoxShape3D" id="DockShape"]
-size = Vector3(7, 0.4, 25)
-[sub_resource type="BoxMesh" id="DockMesh"]
-size = Vector3(7, 0.4, 25)
-[sub_resource type="BoxShape3D" id="DockRampShape"]
-size = Vector3(7, 0.35, 4)
-[sub_resource type="BoxMesh" id="DockRampMesh"]
-size = Vector3(7, 0.35, 4)
-[sub_resource type="CylinderMesh" id="TowerMesh"]
-top_radius = 2.4
-bottom_radius = 3.4
-height = 13
-[sub_resource type="CylinderMesh" id="BeaconMesh"]
-top_radius = 1.4
-bottom_radius = 1.4
-height = 2.5
-[sub_resource type="BoxMesh" id="CraneArmMesh"]
-size = Vector3(1, 1, 10)
-[sub_resource type="PlaneMesh" id="WaterMesh"]
-size = Vector2(90, 54)
-[sub_resource type="BoxShape3D" id="WaterShape"]
-size = Vector3(90, 0.2, 54)
-[sub_resource type="BoxShape3D" id="RecoveryShape"]
-size = Vector3(90, 5, 54)
-[sub_resource type="StandardMaterial3D" id="ShoreMaterial"]
-albedo_color = Color(0.7, 0.66, 0.5, 1)
-[sub_resource type="StandardMaterial3D" id="DockMaterial"]
-albedo_color = Color(0.58, 0.4, 0.2, 1)
-[sub_resource type="StandardMaterial3D" id="TowerMaterial"]
-albedo_color = Color(0.92, 0.9, 0.76, 1)
-[sub_resource type="StandardMaterial3D" id="BeaconMaterial"]
-albedo_color = Color(0.95, 0.28, 0.22, 1)
-[sub_resource type="StandardMaterial3D" id="WaterMaterial"]
-transparency = 1
-albedo_color = Color(0.04, 0.3, 0.62, 0.84)
-
-[node name="CoastalDockRegion" type="Node3D"]
-script = ExtResource("1_region")
-region_id = &"coast"
-[node name="Visuals" type="Node3D" parent="."]
-[node name="Shore" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, 22)
-mesh = SubResource("ShoreMesh")
-material_override = SubResource("ShoreMaterial")
-[node name="Ocean" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.2, -14)
-mesh = SubResource("WaterMesh")
-material_override = SubResource("WaterMaterial")
-[node name="MainDock" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.45, -3.5)
-mesh = SubResource("DockMesh")
-material_override = SubResource("DockMaterial")
-[node name="DockApproachRamp" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.155, 11)
-rotation_degrees = Vector3(9, 0, 0)
-mesh = SubResource("DockRampMesh")
-material_override = SubResource("DockMaterial")
-[node name="Lighthouse" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-32, 6.5, 20)
-mesh = SubResource("TowerMesh")
-material_override = SubResource("TowerMaterial")
-[node name="Beacon" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-32, 14, 20)
-mesh = SubResource("BeaconMesh")
-material_override = SubResource("BeaconMaterial")
-[node name="CraneArm" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(17, 5, 19)
-rotation_degrees = Vector3(0, 0, 60)
-mesh = SubResource("CraneArmMesh")
-material_override = SubResource("BeaconMaterial")
-[node name="CoastLabel" type="Label3D" parent="Visuals"]
-position = Vector3(25, 3.2, 23)
-text = "coast"
-font_size = 38
-outline_size = 8
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="Node3D" parent="."]
-[node name="Shore" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/Shore"]
-position = Vector3(0, -3, 22)
-shape = SubResource("ShoreShape")
-[node name="MainDock" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/MainDock"]
-position = Vector3(0, 0.45, -3.5)
-shape = SubResource("DockShape")
-[node name="DockApproachRamp" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/DockApproachRamp"]
-position = Vector3(0, 0.155, 11)
-rotation_degrees = Vector3(9, 0, 0)
-shape = SubResource("DockRampShape")
-
-[node name="FishingWater" type="Node3D" parent="."]
-[node name="CoastalWater" type="Area3D" parent="FishingWater"]
-position = Vector3(0, 0.2, -14)
-collision_layer = 4
-collision_mask = 0
-monitoring = false
-script = ExtResource("2_water")
-location_tags = Array[StringName]([&"coast"])
-fish_pool = ExtResource("3_pool")
-[node name="Shape" type="CollisionShape3D" parent="FishingWater/CoastalWater"]
-shape = SubResource("WaterShape")
-
-[node name="WaterRecovery" type="Node3D" parent="."]
-[node name="CoastalRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(0, -2.3, -14)
-collision_layer = 8
-collision_mask = 2
-script = ExtResource("4_trigger")
-surface_height = 0.2
-[node name="Shape" type="CollisionShape3D" parent="WaterRecovery/CoastalRecovery"]
-shape = SubResource("RecoveryShape")
-
-[node name="SafeRespawns" type="Node3D" parent="."]
-[node name="DockApproach" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(8, 0.58, 23)
-rotation_degrees = Vector3(0, 180, 0)
-script = ExtResource("5_safe")
-[node name="DockMidpoint" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(0, 1.23, 0)
-rotation_degrees = Vector3(0, 180, 0)
-script = ExtResource("5_safe")
-[node name="LighthouseShore" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-24, 0.58, 24)
-rotation_degrees = Vector3(0, -135, 0)
-script = ExtResource("5_safe")
diff --git a/world/regions/lake_region.tscn b/world/regions/lake_region.tscn
deleted file mode 100644
index 7e9259b..0000000
--- a/world/regions/lake_region.tscn
+++ /dev/null
@@ -1,141 +0,0 @@
-[gd_scene load_steps=17 format=3]
-
-[ext_resource type="Script" path="res://world/regions/graybox_region.gd" id="1_region"]
-[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_water"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="3_pool"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="4_trigger"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="5_safe"]
-
-[sub_resource type="BoxShape3D" id="BankNSShape"]
-size = Vector3(58, 6, 10)
-[sub_resource type="BoxShape3D" id="BankEWShape"]
-size = Vector3(8, 6, 36)
-[sub_resource type="BoxMesh" id="BankNSMesh"]
-size = Vector3(58, 6, 10)
-[sub_resource type="BoxMesh" id="BankEWMesh"]
-size = Vector3(8, 6, 36)
-[sub_resource type="BoxShape3D" id="DockShape"]
-size = Vector3(10, 0.4, 7)
-[sub_resource type="BoxMesh" id="DockMesh"]
-size = Vector3(10, 0.4, 7)
-[sub_resource type="BoxShape3D" id="DockRampShape"]
-size = Vector3(4, 0.35, 7)
-[sub_resource type="BoxMesh" id="DockRampMesh"]
-size = Vector3(4, 0.35, 7)
-[sub_resource type="PlaneMesh" id="WaterMesh"]
-size = Vector2(42, 36)
-[sub_resource type="BoxShape3D" id="WaterShape"]
-size = Vector3(42, 0.2, 36)
-[sub_resource type="BoxShape3D" id="RecoveryShape"]
-size = Vector3(42, 5, 36)
-[sub_resource type="StandardMaterial3D" id="GrassMaterial"]
-albedo_color = Color(0.34, 0.65, 0.3, 1)
-[sub_resource type="StandardMaterial3D" id="DockMaterial"]
-albedo_color = Color(0.55, 0.34, 0.16, 1)
-[sub_resource type="StandardMaterial3D" id="WaterMaterial"]
-transparency = 1
-albedo_color = Color(0.08, 0.43, 0.75, 0.82)
-
-[node name="LakeRegion" type="Node3D"]
-script = ExtResource("1_region")
-region_id = &"lake"
-[node name="Visuals" type="Node3D" parent="."]
-[node name="NorthBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, 23)
-mesh = SubResource("BankNSMesh")
-material_override = SubResource("GrassMaterial")
-[node name="SouthBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, -23)
-mesh = SubResource("BankNSMesh")
-material_override = SubResource("GrassMaterial")
-[node name="WestBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-25, -3, 0)
-mesh = SubResource("BankEWMesh")
-material_override = SubResource("GrassMaterial")
-[node name="EastBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(25, -3, 0)
-mesh = SubResource("BankEWMesh")
-material_override = SubResource("GrassMaterial")
-[node name="Water" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.2, 0)
-mesh = SubResource("WaterMesh")
-material_override = SubResource("WaterMaterial")
-[node name="WestDock" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-12, 0.45, 0)
-mesh = SubResource("DockMesh")
-material_override = SubResource("DockMaterial")
-[node name="WestDockRamp" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-19, 0.155, 0)
-rotation_degrees = Vector3(0, 0, 9)
-mesh = SubResource("DockRampMesh")
-material_override = SubResource("DockMaterial")
-[node name="LakeLabel" type="Label3D" parent="Visuals"]
-position = Vector3(-25, 3, 20)
-text = "lake"
-font_size = 38
-outline_size = 8
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="Node3D" parent="."]
-[node name="NorthBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/NorthBank"]
-position = Vector3(0, -3, 23)
-shape = SubResource("BankNSShape")
-[node name="SouthBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/SouthBank"]
-position = Vector3(0, -3, -23)
-shape = SubResource("BankNSShape")
-[node name="WestBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestBank"]
-position = Vector3(-25, -3, 0)
-shape = SubResource("BankEWShape")
-[node name="EastBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/EastBank"]
-position = Vector3(25, -3, 0)
-shape = SubResource("BankEWShape")
-[node name="WestDock" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestDock"]
-position = Vector3(-12, 0.45, 0)
-shape = SubResource("DockShape")
-[node name="WestDockRamp" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestDockRamp"]
-position = Vector3(-19, 0.155, 0)
-rotation_degrees = Vector3(0, 0, 9)
-shape = SubResource("DockRampShape")
-
-[node name="FishingWater" type="Node3D" parent="."]
-[node name="LakeWater" type="Area3D" parent="FishingWater"]
-position = Vector3(0, 0.2, 0)
-collision_layer = 4
-collision_mask = 0
-monitoring = false
-script = ExtResource("2_water")
-location_tags = Array[StringName]([&"lake"])
-fish_pool = ExtResource("3_pool")
-[node name="Shape" type="CollisionShape3D" parent="FishingWater/LakeWater"]
-shape = SubResource("WaterShape")
-
-[node name="WaterRecovery" type="Node3D" parent="."]
-[node name="LakeRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(0, -2.3, 0)
-collision_layer = 8
-collision_mask = 2
-script = ExtResource("4_trigger")
-surface_height = 0.2
-[node name="Shape" type="CollisionShape3D" parent="WaterRecovery/LakeRecovery"]
-shape = SubResource("RecoveryShape")
-
-[node name="SafeRespawns" type="Node3D" parent="."]
-[node name="WestDockApproach" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-25, 0.58, 8)
-rotation_degrees = Vector3(0, -90, 0)
-script = ExtResource("5_safe")
-[node name="NorthShore" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(0, 0.58, 24)
-rotation_degrees = Vector3(0, 180, 0)
-script = ExtResource("5_safe")
-[node name="SouthEastShore" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(25, 0.58, -23)
-rotation_degrees = Vector3(0, 45, 0)
-script = ExtResource("5_safe")
diff --git a/world/regions/marsh_region.tscn b/world/regions/marsh_region.tscn
deleted file mode 100644
index 29fea93..0000000
--- a/world/regions/marsh_region.tscn
+++ /dev/null
@@ -1,164 +0,0 @@
-[gd_scene load_steps=20 format=3]
-
-[ext_resource type="Script" path="res://world/regions/graybox_region.gd" id="1_region"]
-[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_water"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="3_pool"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="4_trigger"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="5_safe"]
-
-[sub_resource type="BoxShape3D" id="BankNSShape"]
-size = Vector3(50, 6, 8)
-[sub_resource type="BoxShape3D" id="BankEWShape"]
-size = Vector3(8, 6, 30)
-[sub_resource type="BoxMesh" id="BankNSMesh"]
-size = Vector3(50, 6, 8)
-[sub_resource type="BoxMesh" id="BankEWMesh"]
-size = Vector3(8, 6, 30)
-[sub_resource type="BoxShape3D" id="BoardwalkShape"]
-size = Vector3(6, 0.4, 22)
-[sub_resource type="BoxMesh" id="BoardwalkMesh"]
-size = Vector3(6, 0.4, 22)
-[sub_resource type="BoxShape3D" id="BoardwalkRampShape"]
-size = Vector3(6, 0.35, 4)
-[sub_resource type="BoxMesh" id="BoardwalkRampMesh"]
-size = Vector3(6, 0.35, 4)
-[sub_resource type="CylinderMesh" id="ReedMesh"]
-top_radius = 0.06
-bottom_radius = 0.1
-height = 2.2
-[sub_resource type="PlaneMesh" id="WaterMesh"]
-size = Vector2(38, 30)
-[sub_resource type="BoxShape3D" id="WaterShape"]
-size = Vector3(38, 0.2, 30)
-[sub_resource type="BoxShape3D" id="RecoveryShape"]
-size = Vector3(38, 5, 30)
-[sub_resource type="StandardMaterial3D" id="BankMaterial"]
-albedo_color = Color(0.38, 0.53, 0.27, 1)
-[sub_resource type="StandardMaterial3D" id="BoardwalkMaterial"]
-albedo_color = Color(0.48, 0.32, 0.18, 1)
-[sub_resource type="StandardMaterial3D" id="ReedMaterial"]
-albedo_color = Color(0.59, 0.67, 0.2, 1)
-[sub_resource type="StandardMaterial3D" id="WaterMaterial"]
-transparency = 1
-albedo_color = Color(0.1, 0.48, 0.48, 0.8)
-
-[node name="MarshRegion" type="Node3D"]
-script = ExtResource("1_region")
-region_id = &"marsh"
-[node name="Visuals" type="Node3D" parent="."]
-[node name="NorthBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, 19)
-mesh = SubResource("BankNSMesh")
-material_override = SubResource("BankMaterial")
-[node name="SouthBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, -19)
-mesh = SubResource("BankNSMesh")
-material_override = SubResource("BankMaterial")
-[node name="WestBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-23, -3, 0)
-mesh = SubResource("BankEWMesh")
-material_override = SubResource("BankMaterial")
-[node name="EastBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(23, -3, 0)
-mesh = SubResource("BankEWMesh")
-material_override = SubResource("BankMaterial")
-[node name="Water" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.2, 0)
-mesh = SubResource("WaterMesh")
-material_override = SubResource("WaterMaterial")
-[node name="Boardwalk" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.45, 0)
-mesh = SubResource("BoardwalkMesh")
-material_override = SubResource("BoardwalkMaterial")
-[node name="BoardwalkNorthRamp" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.155, 13)
-rotation_degrees = Vector3(9, 0, 0)
-mesh = SubResource("BoardwalkRampMesh")
-material_override = SubResource("BoardwalkMaterial")
-[node name="BoardwalkSouthRamp" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.155, -13)
-rotation_degrees = Vector3(-9, 0, 0)
-mesh = SubResource("BoardwalkRampMesh")
-material_override = SubResource("BoardwalkMaterial")
-[node name="ReedWest" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-13, 1.1, -8)
-mesh = SubResource("ReedMesh")
-material_override = SubResource("ReedMaterial")
-[node name="ReedEast" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(12, 1.1, 7)
-mesh = SubResource("ReedMesh")
-material_override = SubResource("ReedMaterial")
-[node name="MarshLabel" type="Label3D" parent="Visuals"]
-position = Vector3(21, 3, 17)
-text = "marsh"
-font_size = 36
-outline_size = 8
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="Node3D" parent="."]
-[node name="NorthBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/NorthBank"]
-position = Vector3(0, -3, 19)
-shape = SubResource("BankNSShape")
-[node name="SouthBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/SouthBank"]
-position = Vector3(0, -3, -19)
-shape = SubResource("BankNSShape")
-[node name="WestBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestBank"]
-position = Vector3(-23, -3, 0)
-shape = SubResource("BankEWShape")
-[node name="EastBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/EastBank"]
-position = Vector3(23, -3, 0)
-shape = SubResource("BankEWShape")
-[node name="Boardwalk" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/Boardwalk"]
-position = Vector3(0, 0.45, 0)
-shape = SubResource("BoardwalkShape")
-[node name="BoardwalkNorthRamp" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/BoardwalkNorthRamp"]
-position = Vector3(0, 0.155, 13)
-rotation_degrees = Vector3(9, 0, 0)
-shape = SubResource("BoardwalkRampShape")
-[node name="BoardwalkSouthRamp" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/BoardwalkSouthRamp"]
-position = Vector3(0, 0.155, -13)
-rotation_degrees = Vector3(-9, 0, 0)
-shape = SubResource("BoardwalkRampShape")
-
-[node name="FishingWater" type="Node3D" parent="."]
-[node name="MarshWater" type="Area3D" parent="FishingWater"]
-position = Vector3(0, 0.2, 0)
-collision_layer = 4
-collision_mask = 0
-monitoring = false
-script = ExtResource("2_water")
-location_tags = Array[StringName]([&"marsh"])
-fish_pool = ExtResource("3_pool")
-[node name="Shape" type="CollisionShape3D" parent="FishingWater/MarshWater"]
-shape = SubResource("WaterShape")
-
-[node name="WaterRecovery" type="Node3D" parent="."]
-[node name="MarshRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(0, -2.3, 0)
-collision_layer = 8
-collision_mask = 2
-script = ExtResource("4_trigger")
-surface_height = 0.2
-[node name="Shape" type="CollisionShape3D" parent="WaterRecovery/MarshRecovery"]
-shape = SubResource("RecoveryShape")
-
-[node name="SafeRespawns" type="Node3D" parent="."]
-[node name="BoardwalkNorth" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(8, 0.58, 19)
-rotation_degrees = Vector3(0, 180, 0)
-script = ExtResource("5_safe")
-[node name="BoardwalkSouth" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-8, 0.58, -19)
-script = ExtResource("5_safe")
-[node name="EastBank" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(23, 0.58, 7)
-rotation_degrees = Vector3(0, 90, 0)
-script = ExtResource("5_safe")
diff --git a/world/regions/river_region.tscn b/world/regions/river_region.tscn
deleted file mode 100644
index 02fcdd9..0000000
--- a/world/regions/river_region.tscn
+++ /dev/null
@@ -1,143 +0,0 @@
-[gd_scene load_steps=19 format=3]
-
-[ext_resource type="Script" path="res://world/regions/graybox_region.gd" id="1_region"]
-[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_water"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="3_pool"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="4_trigger"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="5_safe"]
-
-[sub_resource type="BoxShape3D" id="BankShape"]
-size = Vector3(13, 6, 61)
-[sub_resource type="BoxMesh" id="BankMesh"]
-size = Vector3(13, 6, 61)
-[sub_resource type="BoxShape3D" id="BridgeShape"]
-size = Vector3(6, 0.4, 7)
-[sub_resource type="BoxMesh" id="BridgeMesh"]
-size = Vector3(6, 0.4, 7)
-[sub_resource type="BoxShape3D" id="BridgeRampShape"]
-size = Vector3(4, 0.35, 7)
-[sub_resource type="BoxMesh" id="BridgeRampMesh"]
-size = Vector3(4, 0.35, 7)
-[sub_resource type="BoxMesh" id="RailMesh"]
-size = Vector3(14, 0.25, 0.25)
-[sub_resource type="PlaneMesh" id="WaterMesh"]
-size = Vector2(14, 58)
-[sub_resource type="BoxShape3D" id="WaterShape"]
-size = Vector3(14, 0.2, 58)
-[sub_resource type="BoxShape3D" id="RecoveryShape"]
-size = Vector3(14, 5, 58)
-[sub_resource type="StandardMaterial3D" id="BankMaterial"]
-albedo_color = Color(0.31, 0.61, 0.37, 1)
-[sub_resource type="StandardMaterial3D" id="BridgeMaterial"]
-albedo_color = Color(0.58, 0.58, 0.54, 1)
-[sub_resource type="StandardMaterial3D" id="RailMaterial"]
-albedo_color = Color(0.3, 0.32, 0.34, 1)
-[sub_resource type="StandardMaterial3D" id="WaterMaterial"]
-transparency = 1
-albedo_color = Color(0.12, 0.48, 0.72, 0.8)
-
-[node name="RiverRegion" type="Node3D"]
-script = ExtResource("1_region")
-region_id = &"river"
-[node name="Visuals" type="Node3D" parent="."]
-[node name="WestBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-13.5, -3, -1.5)
-mesh = SubResource("BankMesh")
-material_override = SubResource("BankMaterial")
-[node name="EastBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(13.5, -3, -1.5)
-mesh = SubResource("BankMesh")
-material_override = SubResource("BankMaterial")
-[node name="Water" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.2, 0)
-mesh = SubResource("WaterMesh")
-material_override = SubResource("WaterMaterial")
-[node name="Bridge" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.45, 7)
-mesh = SubResource("BridgeMesh")
-material_override = SubResource("BridgeMaterial")
-[node name="WestBridgeRamp" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-5, 0.155, 7)
-rotation_degrees = Vector3(0, 0, 9)
-mesh = SubResource("BridgeRampMesh")
-material_override = SubResource("BridgeMaterial")
-[node name="EastBridgeRamp" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(5, 0.155, 7)
-rotation_degrees = Vector3(0, 0, -9)
-mesh = SubResource("BridgeRampMesh")
-material_override = SubResource("BridgeMaterial")
-[node name="NorthRail" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 1.15, 10.2)
-mesh = SubResource("RailMesh")
-material_override = SubResource("RailMaterial")
-[node name="SouthRail" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 1.15, 3.8)
-mesh = SubResource("RailMesh")
-material_override = SubResource("RailMaterial")
-[node name="RiverLabel" type="Label3D" parent="Visuals"]
-position = Vector3(-16, 3, 20)
-text = "river"
-font_size = 34
-outline_size = 7
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="Node3D" parent="."]
-[node name="WestBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestBank"]
-position = Vector3(-13.5, -3, -1.5)
-shape = SubResource("BankShape")
-[node name="EastBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/EastBank"]
-position = Vector3(13.5, -3, -1.5)
-shape = SubResource("BankShape")
-[node name="Bridge" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/Bridge"]
-position = Vector3(0, 0.45, 7)
-shape = SubResource("BridgeShape")
-[node name="WestBridgeRamp" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestBridgeRamp"]
-position = Vector3(-5, 0.155, 7)
-rotation_degrees = Vector3(0, 0, 9)
-shape = SubResource("BridgeRampShape")
-[node name="EastBridgeRamp" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/EastBridgeRamp"]
-position = Vector3(5, 0.155, 7)
-rotation_degrees = Vector3(0, 0, -9)
-shape = SubResource("BridgeRampShape")
-
-[node name="FishingWater" type="Node3D" parent="."]
-[node name="RiverWater" type="Area3D" parent="FishingWater"]
-position = Vector3(0, 0.2, 0)
-collision_layer = 4
-collision_mask = 0
-monitoring = false
-script = ExtResource("2_water")
-location_tags = Array[StringName]([&"river"])
-fish_pool = ExtResource("3_pool")
-[node name="Shape" type="CollisionShape3D" parent="FishingWater/RiverWater"]
-shape = SubResource("WaterShape")
-
-[node name="WaterRecovery" type="Node3D" parent="."]
-[node name="RiverRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(0, -2.3, 0)
-collision_layer = 8
-collision_mask = 2
-script = ExtResource("4_trigger")
-surface_height = 0.2
-[node name="Shape" type="CollisionShape3D" parent="WaterRecovery/RiverRecovery"]
-shape = SubResource("RecoveryShape")
-
-[node name="SafeRespawns" type="Node3D" parent="."]
-[node name="BridgeWest" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-15, 0.58, 14)
-rotation_degrees = Vector3(0, -90, 0)
-script = ExtResource("5_safe")
-[node name="NorthEastBank" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(14, 0.58, 24)
-rotation_degrees = Vector3(0, 90, 0)
-script = ExtResource("5_safe")
-[node name="SouthWestBank" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-14, 0.58, -25)
-rotation_degrees = Vector3(0, -90, 0)
-script = ExtResource("5_safe")
diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd
index 5e35493..4c8880c 100644
--- a/world/regions/starter_island_region.gd
+++ b/world/regions/starter_island_region.gd
@@ -1,27 +1,50 @@
+@tool
class_name StarterIslandRegion
-extends GrayboxRegion
+extends WorldRegion
const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
+const FOLIAGE_WIND_SHADER: Shader = preload(
+ "res://world/materials/foliage_wind.gdshader"
+)
+const FOLIAGE_MATERIAL_NAMES: Array[StringName] = [
+ &"leaf",
+ &"leaf_light",
+ &"leaf_dark",
+]
-@export var visual_mesh_path: NodePath = (
- ^"Visuals/StarterIslandModel/starter_island"
+@export_group("Owned Nodes")
+@export_node_path("MeshInstance3D")
+var visual_mesh_path: NodePath = (
+ ^"Terrain/Visual/starter_island"
)
-@export var terrain_collision_shape_path: NodePath = (
- ^"StaticCollision/Terrain/Shape"
+@export_node_path("Node3D")
+var terrain_visual_root_path: NodePath = ^"Terrain/Visual"
+@export_node_path("CollisionShape3D")
+var terrain_collision_shape_path: NodePath = (
+ ^"Terrain/Collision/Shape"
)
-@export var player_spawn_path: NodePath = ^"PlayerSpawn"
-@export var fishing_shop_path: NodePath = (
- ^"Interactables/FishingShopWorld/FishingShopInteraction"
+@export_node_path("Marker3D")
+var player_spawn_path: NodePath = ^"PlayerSpawn"
+@export_node_path("Area3D")
+var fishing_shop_path: NodePath = (
+ ^"Interactables/FishingShopWorld/InteractionArea"
)
-@export var pelican_landmark_path: NodePath = (
- ^"Interactables/PelicanCoolerPerch"
-)
-
+@export_group("Terrain Collision")
+# The imported GLB is the collision authority. Rebuild once per region load so
+# newly authored terrain and props cannot retain a stale saved fallback shape.
+@export var rebuild_terrain_collision_on_ready: bool = true
+@export_group("Foliage Wind")
+@export_range(0.0, 0.4, 0.005) var foliage_wind_strength: float = 0.36
+@export_range(0.0, 4.0, 0.05) var foliage_wind_speed: float = 0.9
func _ready() -> void:
- _build_terrain_collision()
+ if rebuild_terrain_collision_on_ready or not has_terrain_collision():
+ _build_terrain_collision()
+ _apply_foliage_wind()
+ if Engine.is_editor_hint():
+ update_configuration_warnings()
func get_player_spawn_transform() -> Transform3D:
@@ -33,8 +56,8 @@ func get_fishing_shop() -> FishingShopInteractionType:
return get_node_or_null(fishing_shop_path) as FishingShopInteractionType
-func get_pelican_landmark() -> Node3D:
- return get_node_or_null(pelican_landmark_path) as Node3D
+func get_saltwater_shoreline_mesh() -> MeshInstance3D:
+ return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D
func has_terrain_collision() -> bool:
@@ -45,22 +68,142 @@ func has_terrain_collision() -> bool:
func _build_terrain_collision() -> void:
- var visual_mesh: MeshInstance3D = (
- get_node_or_null(visual_mesh_path) as MeshInstance3D
- )
+ var terrain_root: Node = get_node_or_null(terrain_visual_root_path)
+ if terrain_root == null:
+ terrain_root = get_node_or_null(visual_mesh_path)
var collision_shape: CollisionShape3D = (
get_node_or_null(terrain_collision_shape_path) as CollisionShape3D
)
- if visual_mesh == null or visual_mesh.mesh == null:
- push_error("Starter island visual mesh is unavailable.")
+ if terrain_root == null:
+ push_error("Starter island visual terrain root is unavailable.")
return
if collision_shape == null:
push_error("Starter island collision owner is unavailable.")
return
- var terrain_shape: ConcavePolygonShape3D = (
- visual_mesh.mesh.create_trimesh_shape()
- )
- if terrain_shape == null or terrain_shape.get_faces().is_empty():
+ var mesh_instances := _collect_terrain_mesh_instances(terrain_root)
+ if mesh_instances.is_empty():
+ var visual_mesh: MeshInstance3D = terrain_root as MeshInstance3D
+ if visual_mesh == null or visual_mesh.mesh == null:
+ push_error("Starter island visual mesh is unavailable.")
+ return
+ var fallback_shape: ConcavePolygonShape3D = (
+ visual_mesh.mesh.create_trimesh_shape()
+ )
+ if fallback_shape == null or fallback_shape.get_faces().is_empty():
+ push_error("Starter island terrain collision could not be created.")
+ return
+ collision_shape.shape = fallback_shape
+ return
+ var terrain_shape := ConcavePolygonShape3D.new()
+ var faces := PackedVector3Array()
+ for mesh_instance: MeshInstance3D in mesh_instances:
+ var mesh: Mesh = mesh_instance.mesh
+ if mesh == null:
+ continue
+ var mesh_shape := mesh.create_trimesh_shape()
+ if mesh_shape == null:
+ continue
+ for face_vertex: Vector3 in mesh_shape.get_faces():
+ faces.append(to_local(mesh_instance.to_global(face_vertex)))
+ if faces.is_empty():
push_error("Starter island terrain collision could not be created.")
return
+ terrain_shape.set_faces(faces)
collision_shape.shape = terrain_shape
+
+
+func _collect_terrain_mesh_instances(root: Node) -> Array[MeshInstance3D]:
+ var mesh_instances: Array[MeshInstance3D] = []
+ if root is MeshInstance3D:
+ mesh_instances.append(root as MeshInstance3D)
+ for child: Node in root.get_children():
+ mesh_instances.append_array(_collect_terrain_mesh_instances(child))
+ return mesh_instances
+
+
+func _apply_foliage_wind() -> void:
+ var terrain_root: Node = get_node_or_null(terrain_visual_root_path)
+ if terrain_root == null:
+ return
+ for mesh_instance: MeshInstance3D in _collect_terrain_mesh_instances(
+ terrain_root
+ ):
+ _apply_foliage_wind_to_mesh(mesh_instance)
+
+
+func _apply_foliage_wind_to_mesh(mesh_instance: MeshInstance3D) -> void:
+ if mesh_instance.mesh == null:
+ return
+ var bounds: AABB = mesh_instance.get_aabb()
+ var mesh_global_scale: Vector3 = mesh_instance.global_basis.get_scale().abs()
+ var horizontal_scale: float = maxf(
+ (mesh_global_scale.x + mesh_global_scale.z) * 0.5,
+ 0.001,
+ )
+ var local_strength: float = foliage_wind_strength / horizontal_scale
+ var phase: float = fposmod(
+ mesh_instance.global_position.x * 0.73
+ + mesh_instance.global_position.z * 0.41,
+ TAU,
+ )
+ for surface_index: int in mesh_instance.mesh.get_surface_count():
+ var source_material: Material = mesh_instance.get_active_material(
+ surface_index
+ )
+ if not source_material is StandardMaterial3D:
+ continue
+ if not FOLIAGE_MATERIAL_NAMES.has(
+ StringName(source_material.resource_name)
+ ):
+ continue
+ var source_standard := source_material as StandardMaterial3D
+ var wind_material := ShaderMaterial.new()
+ wind_material.shader = FOLIAGE_WIND_SHADER
+ wind_material.set_shader_parameter(
+ "albedo_color",
+ source_standard.albedo_color,
+ )
+ wind_material.set_shader_parameter(
+ "material_roughness",
+ source_standard.roughness,
+ )
+ wind_material.set_shader_parameter(
+ "material_metallic",
+ source_standard.metallic,
+ )
+ wind_material.set_shader_parameter("local_min_y", bounds.position.y)
+ wind_material.set_shader_parameter(
+ "local_height",
+ maxf(bounds.size.y, 0.001),
+ )
+ wind_material.set_shader_parameter(
+ "local_wind_strength",
+ local_strength,
+ )
+ wind_material.set_shader_parameter("wind_speed", foliage_wind_speed)
+ wind_material.set_shader_parameter("wind_phase", phase)
+ mesh_instance.set_surface_override_material(
+ surface_index,
+ wind_material,
+ )
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings := PackedStringArray()
+ var visual_mesh := get_node_or_null(visual_mesh_path) as MeshInstance3D
+ if visual_mesh == null:
+ warnings.append("Terrain/Visual terrain mesh node is unavailable.")
+ return warnings
+ if visual_mesh == null or visual_mesh.mesh == null:
+ warnings.append("Terrain/Visual must provide the island mesh.")
+ return warnings
+
+ if get_node_or_null(terrain_collision_shape_path) == null:
+ warnings.append("Terrain/Collision must provide a collision shape.")
+ if get_node_or_null(terrain_visual_root_path) == null:
+ warnings.append("Terrain/Visual root is missing for terrain collision generation.")
+ if get_node_or_null(player_spawn_path) == null:
+ warnings.append("PlayerSpawn marker is missing.")
+ if get_node_or_null(fishing_shop_path) == null:
+ warnings.append("Fishing Shop placement is missing.")
+ return warnings
diff --git a/world/regions/starter_island_region.tscn b/world/regions/starter_island_region.tscn
index f2b1d6e..d956feb 100644
--- a/world/regions/starter_island_region.tscn
+++ b/world/regions/starter_island_region.tscn
@@ -1,185 +1,259 @@
-[gd_scene load_steps=20 format=3]
+[gd_scene format=3 uid="uid://ddyqwgwwnnwut"]
-[ext_resource type="Script" path="res://world/regions/starter_island_region.gd" id="1_region"]
-[ext_resource type="PackedScene" path="res://art/exported/environment/terrain/starter_island.glb" id="2_island"]
-[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="3_water"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="4_pool"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="5_trigger"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="6_safe"]
+[ext_resource type="Script" uid="uid://j8syuqdau7vl" path="res://world/regions/starter_island_region.gd" id="1_region"]
+[ext_resource type="PackedScene" uid="uid://bk38p45urtjis" path="res://art/exported/environment/terrain/starter_island.glb" id="2_island"]
+[ext_resource type="Script" uid="uid://b6fp2obu0y2d0" path="res://world/fishable_water_region.gd" id="3_water"]
+[ext_resource type="Resource" path="res://fish/pools/starter_ocean_pool.tres" id="4_pool"]
+[ext_resource type="Resource" path="res://fish/pools/starter_pond_pool.tres" id="12_pond_pool"]
+[ext_resource type="Script" uid="uid://dnlbnlimgxoc2" path="res://world/player_water_trigger.gd" id="5_trigger"]
+[ext_resource type="Script" uid="uid://df2lsduguegxh" path="res://world/safe_respawn_point.gd" id="6_safe"]
[ext_resource type="PackedScene" path="res://world/interactables/fishing_shop_world.tscn" id="7_shop"]
-[ext_resource type="PackedScene" path="res://world/interactables/pelican_buyer_world.tscn" id="8_pelican"]
+[ext_resource type="Script" uid="uid://dtcgnxl1kmdu1" path="res://world/water_body_authoring.gd" id="10_water_body"]
+[ext_resource type="Material" path="res://world/materials/stylized_water.tres" id="13_water"]
+[ext_resource type="ArrayMesh" path="res://world/generated/shorelines/starter_ocean_shoreline.tres" id="17_ocean_shoreline"]
+[ext_resource type="Material" path="res://world/materials/stylized_water_fresh.tres" id="19_fresh_water"]
+[ext_resource type="Script" path="res://world/water_surface_motion.gd" id="20_surface_motion"]
+[ext_resource type="Script" path="res://world/water/shoreline_ribbon_baker.gd" id="21_shoreline_baker"]
+[ext_resource type="Script" path="res://world/water/shoreline_ribbon_config.gd" id="22_shoreline_config"]
-[sub_resource type="StandardMaterial3D" id="WaterMaterial"]
-transparency = 1
-albedo_color = Color(0.10, 0.50, 0.72, 0.76)
-roughness = 0.22
-
-[sub_resource type="PlaneMesh" id="OceanWaterMesh"]
-size = Vector2(120, 90)
+[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_4e4k5"]
+data = PackedVector3Array(10.033899, 3.3913999, 3.3446, 13.3786, 3.3913999, 0, 13.3786, 3.3913999, 3.3446, 10.033899, 3.3913999, 3.3446, 10.033899, 3.3913999, 0, 13.3786, 3.3913999, 0, 6.6893, 3.3913999, 3.3446, 10.033899, 3.3913999, 0, 10.033899, 3.3913999, 3.3446, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 0, 10.033899, 3.3913999, 0, 6.6893, 3.3913999, 0, 10.033899, 3.3913999, -3.3446, 10.033899, 3.3913999, 0, 6.6893, 3.3913999, 6.6893, 6.6893, 3.3913999, 3.3446, 10.033899, 3.3913999, 3.3446, 6.6893, 3.3913999, 6.6893, 10.033899, 3.3913999, 3.3446, 10.033899, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 6.6893, 3.3913999, 0, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 10.033899, 6.6893, 3.3913999, 6.6893, 10.033899, 3.3913999, 6.6893, 6.6893, 3.3913999, 10.033899, 10.033899, 3.3913999, 6.6893, 10.033899, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 6.6893, 3.3446, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 6.6893, 3.3913999, 3.3446, 3.3446, 3.3913999, 10.033899, 6.6893, 3.3913999, 6.6893, 6.6893, 3.3913999, 10.033899, 3.3446, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 6.6893, 3.3913999, 6.6893, 0, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 6.6893, 0, 3.3913999, 6.6893, 0, 3.3913999, 3.3446, 3.3446, 3.3913999, 3.3446, 0, 3.3913999, 10.033899, 0, 3.3913999, 6.6893, 3.3446, 3.3913999, 6.6893, 0, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 3.3446, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, -3.3446, 3.3913999, 10.033899, 0, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, 0, 3.3913999, 10.033899, 0, 3.3913999, 13.3786, 0, 3.3913999, 13.3786, 0, 3.3913999, 10.033899, 3.3446, 3.3913999, 10.033899, 0, 3.3913999, 13.3786, 3.3446, 3.3913999, 10.033899, 3.3446, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, 3.3446, 3.3913999, 10.033899, 6.6893, 3.3913999, 10.033899, 3.3446, 3.3913999, 13.3786, 6.6893, 3.3913999, 10.033899, 6.6893, 3.3913999, 13.3786, 3.9148, 3.3913999, 16.514599, 3.3446, 3.3913999, 13.3786, 6.6893, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, 3.9148, 3.3913999, 16.514599, 3.6399, 3.3913999, 16.2405, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 13.3786, 6.6893, 3.3913999, 16.7232, 6.6893, 3.3913999, 16.7232, 6.6949, 3.3913999, 18.9914, 6.6893, 3.3913999, 18.9861, 6.6949, 3.3913999, 18.9914, 6.6893, 3.3913999, 16.7232, 10.3743, 3.3913999, 16.7232, 6.6949, 3.3913999, 18.9914, 10.3743, 3.3913999, 16.7232, 9.1275, 3.3913999, 18.830599, 10.033899, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 13.3786, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 19.798899, 3.3913999, -13.1096, 19.798899, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 16.7232, 3.3913999, -13.1096, 19.798899, 3.3913999, -13.1096, 16.7232, 3.3913999, -10.033899, 16.4543, 3.3913999, -13.3786, 16.7232, 3.3913999, -13.1096, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 16.4543, 3.3913999, -13.3786, 13.3786, 3.3913999, -6.6893, 13.3786, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -6.6893, 16.7232, 3.3913999, -10.033899, 16.7232, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 13.3786, 3.3913999, -10.033899, 13.3786, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 10.033899, 3.3913999, -10.033899, 13.3786, 3.3913999, -10.033899, 16.4543, 3.3913999, -13.3786, 16.495699, 3.3913999, -16.459, 16.495699, 3.3913999, -13.42, 16.4543, 3.3913999, -13.3786, 12.738999, 3.3913999, -15.894899, 16.495699, 3.3913999, -16.459, 12.738999, 3.3913999, -15.894899, 16.4543, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 12.460899, 3.3913999, -16.1674, 12.738999, 3.3913999, -15.894899, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 12.460899, 3.3913999, -16.1674, 10.033899, 3.3913999, -10.033899, 10.033899, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 6.6893, 3.3913999, -16.7232, 10.033899, 3.3913999, -16.7232, 6.6893, 3.3913999, -10.033899, 10.033899, 3.3913999, -13.3786, 10.033899, 3.3913999, -10.033899, 6.6893, 3.3913999, -10.033899, 6.6893, 3.3913999, -13.3786, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -6.6893, 6.6893, 3.3913999, -10.033899, 10.033899, 3.3913999, -10.033899, 6.6893, 3.3913999, -6.6893, 10.033899, 3.3913999, -10.033899, 10.033899, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 6.6893, 3.3913999, -13.3786, 6.6893, 3.3913999, -10.033899, 4.6821, 3.3913999, -13.3786, 6.6893, 3.3913999, -16.7232, 6.6893, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 4.6821, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 3.8388, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 4.6821, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 4.6821, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 4.4131, 3.2091, -13.357699, 3.3446, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 6.6893, 3.3913999, -10.033899, 3.3446, 3.3913999, -6.6893, 6.6893, 3.3913999, -10.033899, 6.6893, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 3.3446, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, 0, 3.3913999, -8.7143, 4.0225, 3.3913999, -8.7143, 3.3446, 3.3913999, -3.3446, 3.3446, 3.3913999, -6.6893, 6.6893, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, 0, 3.3913999, -3.3446, 0, 3.3913999, -6.6893, 3.3446, 3.3913999, -6.6893, 0, 3.3913999, -3.3446, 3.3446, 3.3913999, -6.6893, 3.3446, 3.3913999, -3.3446, -3.6378, 3.3913999, -2.0792, 0, 3.3913999, -6.6893, 0, 3.3913999, -3.3446, -2.9566998, 3.3913999, 0, -3.6378, 3.3913999, -2.0792, 0, 3.3913999, -3.3446, -3.6378, 3.3913999, -2.0792, -3.3446, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -6.6893, -3.3446, 3.3913999, -6.6893, -6.6893, 3.3913999, -3.7326, -3.3446, 3.3913999, -6.6893, -3.6378, 3.3913999, -2.0792, -2.9566998, 3.3913999, 0, 0, 3.3913999, -3.3446, 0, 3.3913999, 0, 0, 3.3913999, 0, 0, 3.3913999, -3.3446, 3.3446, 3.3913999, -3.3446, -2.9566998, 3.3913999, 3.3446, -2.9566998, 3.3913999, 0, 0, 3.3913999, 0, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 0, 0, 3.3913999, 3.3446, -6.6893, 3.3913999, -3.7326, -6.6893, 3.3913999, -6.6893, -3.3446, 3.3913999, -6.6893, -6.6893, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, -3.3446, 3.3913999, -6.6893, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -6.6893, -6.6893, 3.3913999, -3.7326, 0, 3.3913999, 3.3446, 0, 3.3913999, 0, 3.3446, 3.3913999, 0, 0, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 3.3446, 3.3913999, 0, 0, 3.3913999, 3.3446, 3.3446, 3.3913999, 0, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 0, 6.6893, 3.3913999, 0, 3.3446, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 6.6893, 3.3913999, -3.3446, 3.3446, 3.3913999, 0, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 6.6893, 3.3913999, -6.6893, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, 0, 6.6893, 3.3913999, -3.3446, 10.033899, 3.3913999, -3.3446, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 6.6893, 3.3913999, -3.3446, 10.033899, 3.3913999, -6.6893, 10.033899, 3.3913999, -3.3446, -6.6893, 3.3913999, -6.6893, -6.6893, 3.3913999, -10.033899, -3.9101999, 3.3913999, -9.8592, -6.6893, 3.3913999, -10.033899, -3.9101999, 3.3913999, -12.538199, -3.9101999, 3.3913999, -9.8592, -10.033899, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -6.6893, 3.3913999, -6.6893, -10.033899, 3.3913999, -6.6893, -6.6893, 3.3913999, -10.033899, -6.6893, 3.3913999, -6.6893, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -10.033899, -6.6893, 3.3913999, -12.538199, -3.9101999, 3.3913999, -12.538199, -13.3786, 3.3913999, -3.7326, -13.3786, 3.3913999, -6.6893, -10.033899, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -10.033899, 3.3913999, -10.033899, -6.6893, 3.3913999, -10.033899, -13.3786, 3.3913999, -6.6893, -10.033899, 3.3913999, -10.033899, -10.033899, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -16.7232, 3.3913999, -6.6893, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -10.033899, -10.033899, 3.3913999, -10.033899, -19.7545, 3.3913999, -2.7851999, -16.7232, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -19.7545, 3.3913999, -2.7851999, -19.280699, 3.3913999, -6.6893, -16.7232, 3.3913999, -6.6893, -17.1112, 3.3913999, 0, -19.7545, 3.3913999, -2.7851999, -16.191599, 3.3913999, -3.0971, -17.1112, 3.3913999, 0, -19.9511, 3.3913999, -2.5834, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -17.1112, 3.3913999, 0, -20.0679, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -20.0679, 3.3913999, 0, -17.1112, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -17.1112, 3.3913999, 0, -17.1112, 3.3913999, 3.3044999, -13.3786, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -10.033899, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -10.033899, -10.033899, 3.3913999, -10.033899, -13.3786, 3.3913999, -10.033899, -13.3786, 3.3913999, -13.3786, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -12.538199, -13.3786, 3.3913999, -13.3786, -10.699599, 3.3913999, -15.835, -10.699599, 3.3913999, -13.175799, -13.3786, 3.3913999, -13.3786, -13.3786, 3.3913999, -15.4154, -10.699599, 3.3913999, -15.835, -6.6893, 3.3913999, -12.538199, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -6.6893, 3.175, -12.867, -20.0679, 3.3913999, 6.6893, -20.0679, 3.3913999, 3.3446, -17.1112, 3.3913999, 3.3044999, -22.5541, 3.3913999, 9.2058, -22.3151, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -22.5541, 3.3913999, 9.2058, -20.0679, 3.3913999, 6.6893, -20.0679, 3.3913999, 9.167399, -20.0679, 3.3913999, 9.167399, -20.0679, 3.3913999, 6.6893, -16.7232, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -17.1112, 3.3913999, 3.3044999, -16.7232, 3.3913999, 6.6893, -20.0679, 3.3913999, 9.167399, -16.7232, 3.3913999, 6.6893, -16.285099, 3.3913999, 9.5865, -16.7232, 3.3913999, 6.6893, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -16.7232, 3.3913999, 6.6893, -13.1355, 3.3913999, 6.7549996, -17.1112, 3.3913999, 3.3044999, -13.1355, 3.3913999, 6.7549996, -16.7232, 3.3913999, 6.6893, -16.053999, 3.3913999, 9.8082, -13.1355, 3.3913999, 6.7549996, -13.3786, 3.3913999, 10.033899, -13.1355, 3.3913999, 6.7549996, -17.1112, 3.3913999, 3.3044999, -14.877199, 3.3913999, 4.6657, -14.877199, 3.3913999, 4.6657, -17.1112, 3.3913999, 3.3044999, -15.582299, 3.3913999, 4.0366, -13.1355, 3.3913999, 6.7549996, -14.877199, 3.3913999, 4.6657, -14.0948, 3.3913999, 5.5073, -13.3786, 3.3913999, 10.033899, -13.1355, 3.3913999, 6.7549996, -10.033899, 3.3913999, 7.0772, -13.3786, 3.3913999, 10.033899, -10.033899, 3.3913999, 7.0772, -10.033899, 3.3913999, 10.033899, -10.033899, 3.3913999, 10.033899, -10.033899, 3.3913999, 7.0772, -6.6893, 3.3913999, 7.0772, -10.033899, 3.3913999, 10.033899, -6.6893, 3.3913999, 7.0772, -6.6893, 3.3913999, 10.033899, -10.033899, 3.0033998, 6.6893, -13.1355, 3.3913999, 6.7549996, -12.747499, 3.0033998, 6.3671, -10.033899, 3.0033998, 6.6893, -10.033899, 3.3913999, 7.0772, -13.1355, 3.3913999, 6.7549996, -2.5674999, 2.4164, 7.6215, -6.6893, 3.3913999, 7.0772, -6.6893, 3.0033998, 6.6893, -2.5674999, 2.4164, 7.6215, -2.1794999, 2.8042998, 8.009399, -6.6893, 3.3913999, 7.0772, -12.747499, 3.0033998, 6.3671, -14.0948, 3.3913999, 5.5073, -13.6942, 3.0033998, 5.1798, -12.747499, 3.0033998, 6.3671, -13.1355, 3.3913999, 6.7549996, -14.0948, 3.3913999, 5.5073, -6.6893, 3.0033998, 6.6893, -10.033899, 3.3913999, 7.0772, -10.033899, 3.0033998, 6.6893, -6.6893, 3.0033998, 6.6893, -6.6893, 3.3913999, 7.0772, -10.033899, 3.3913999, 7.0772, -16.7232, 3.0033998, 0, -16.191599, 3.3913999, -3.0971, -15.8036995, 3.0033998, -2.7091, -16.7232, 3.0033998, 0, -17.1112, 3.3913999, 0, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -3.7326, -15.8036995, 3.0033998, -2.7091, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -3.7326, -13.3786, 3.0033998, -3.3446, -15.8036995, 3.0033998, -2.7091, -2.9566998, 3.3913999, 0, -4.0257998, 3.0033998, -1.6911999, -3.6378, 3.3913999, -2.0792, -2.9566998, 3.3913999, 0, -3.3446, 3.0033998, 0, -4.0257998, 3.0033998, -1.6911999, -3.6378, 3.3913999, -2.0792, -6.6893, 3.0033998, -3.3446, -6.6893, 3.3913999, -3.7326, -3.6378, 3.3913999, -2.0792, -4.0257998, 3.0033998, -1.6911999, -6.6893, 3.0033998, -3.3446, -10.033899, 3.3913999, -3.7326, -13.3786, 3.0033998, -3.3446, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -3.7326, -10.033899, 3.0033998, -3.3446, -13.3786, 3.0033998, -3.3446, -6.6893, 3.3913999, -3.7326, -10.033899, 3.0033998, -3.3446, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -3.7326, -6.6893, 3.0033998, -3.3446, -10.033899, 3.0033998, -3.3446, -16.7232, 3.0033998, 2.9166, -17.1112, 3.3913999, 0, -16.7232, 3.0033998, 0, -16.7232, 3.0033998, 2.9166, -17.1112, 3.3913999, 3.3044999, -17.1112, 3.3913999, 0, -14.4892, 3.0033998, 4.2778, -15.582299, 3.3913999, 4.0366, -15.372, 3.0033998, 3.6132998, -14.4892, 3.0033998, 4.2778, -14.877199, 3.3913999, 4.6657, -15.582299, 3.3913999, 4.0366, -2.9566998, 3.3913999, 3.3446, -3.3446, 3.0033998, 0, -2.9566998, 3.3913999, 0, -2.9566998, 3.3913999, 3.3446, -3.3446, 3.0033998, 3.3446, -3.3446, 3.0033998, 0, -2.1794999, 2.8042998, 8.009399, -2.1397, 3.0033998, 5.1795, -1.7832999, 3.3913999, 5.2162, -2.1794999, 2.8042998, 8.009399, -2.5674999, 2.4164, 7.6215, -2.1397, 3.0033998, 5.1795, -6.6893, 3.3913999, 10.033899, -2.1794999, 2.8042998, 8.009399, -3.3446, 3.3913999, 10.033899, -6.6893, 3.3913999, 10.033899, -6.6893, 3.3913999, 7.0772, -2.1794999, 2.8042998, 8.009399, -1.7832999, 3.3913999, 5.2162, 0, 3.3913999, 6.6893, -2.1794999, 2.8042998, 8.009399, 0, 3.3913999, 6.6893, -1.7832999, 3.3913999, 5.2162, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 6.6893, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 3.3446, -3.3446, 3.3913999, 10.033899, 0, 3.3913999, 6.6893, 0, 3.3913999, 10.033899, -3.3446, 3.3913999, 10.033899, -2.1794999, 2.8042998, 8.009399, 0, 3.3913999, 6.6893, -2.1397, 3.0033998, 5.1795, -2.9566998, 3.3913999, 3.3446, -1.7832999, 3.3913999, 5.2162, -2.1397, 3.0033998, 5.1795, -3.3446, 3.0033998, 3.3446, -2.9566998, 3.3913999, 3.3446, -14.0948, 3.3913999, 5.5073, -14.4892, 3.0033998, 4.2778, -13.6942, 3.0033998, 5.1798, -14.0948, 3.3913999, 5.5073, -14.877199, 3.3913999, 4.6657, -14.4892, 3.0033998, 4.2778, -15.582299, 3.3913999, 4.0366, -16.7232, 3.0033998, 2.9166, -15.372, 3.0033998, 3.6132998, -15.582299, 3.3913999, 4.0366, -17.1112, 3.3913999, 3.3044999, -16.7232, 3.0033998, 2.9166, 20.198, 3.1490998, -10.033899, 19.798899, 3.3913999, -13.1096, 20.1539, 3.1299, -13.399799, 20.198, 3.1490998, -10.033899, 19.798899, 3.3913999, -10.033899, 19.798899, 3.3913999, -13.1096, 20.1539, 3.1299, -13.399799, 16.7232, 3.3913999, -13.1096, 16.8204, 3.138, -13.447599, 20.1539, 3.1299, -13.399799, 19.798899, 3.3913999, -13.1096, 16.7232, 3.3913999, -13.1096, 16.756899, 3.1650999, -16.7286, 12.738999, 3.3913999, -15.894899, 12.7471, 3.1766999, -16.1988, 16.756899, 3.1650999, -16.7286, 16.495699, 3.3913999, -16.459, 12.738999, 3.3913999, -15.894899, 16.8051, 3.1778, -13.479, 16.495699, 3.3913999, -16.459, 16.756899, 3.1650999, -16.7286, 16.8051, 3.1778, -13.479, 16.495699, 3.3913999, -13.42, 16.495699, 3.3913999, -16.459, 16.4543, 3.3913999, -13.3786, 16.8204, 3.138, -13.447599, 16.7232, 3.3913999, -13.1096, 16.8204, 3.138, -13.447599, 16.4543, 3.3913999, -13.3786, 16.495699, 3.3913999, -13.42, 16.8204, 3.138, -13.447599, 16.495699, 3.3913999, -13.42, 16.8051, 3.1778, -13.479, 10.033899, 3.0523999, -19.0294, 6.6893, 3.2782998, -18.727999, 6.6893, 3.0440998, -19.036999, 10.033899, 3.0523999, -19.0294, 10.033899, 3.2784998, -18.6997, 6.6893, 3.2782998, -18.727999, 12.723599, 2.9213, -19.2902, 10.033899, 3.2784998, -18.6997, 10.033899, 3.0523999, -19.0294, 12.723599, 2.9213, -19.2902, 12.446099, 3.1699998, -18.9401, 10.033899, 3.2784998, -18.6997, 12.446099, 3.1699998, -18.9401, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 12.446099, 3.1699998, -18.9401, 12.723599, 2.9213, -19.2902, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 10.033899, 3.2784998, -18.6997, 12.446099, 3.1699998, -18.9401, 10.033899, 3.3913999, -16.7232, 10.033899, 3.2784998, -18.6997, 12.460899, 3.3913999, -16.1674, 10.033899, 3.3913999, -16.7232, 12.460899, 3.3913999, -16.1674, 12.460899, 3.3913999, -16.1674, 6.6893, 3.3913999, -16.7232, 10.033899, 3.2784998, -18.6997, 10.033899, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 6.6893, 3.2782998, -18.727999, 10.033899, 3.2784998, -18.6997, 12.738999, 3.3913999, -15.894899, 12.747, 3.1771, -16.1991, 12.7471, 3.1766999, -16.1988, 12.738999, 3.3913999, -15.894899, 12.460899, 3.3913999, -16.1674, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 12.738999, 3.3913999, -15.894899, 12.460899, 3.3913999, -16.1674, 3.8388, 3.3913999, -16.7232, 4.4783998, 3.1067, -18.2665, 4.6443, 3.2897, -17.9799, 3.8388, 3.3913999, -16.7232, 3.5895, 3.2033, -16.7232, 4.4783998, 3.1067, -18.2665, 4.6821, 3.3913999, -13.3786, 3.5895, 3.2033, -16.7232, 3.8388, 3.3913999, -16.7232, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 3.5895, 3.2033, -16.7232, 6.6640997, 3.0702, -19.018099, 4.6443, 3.2897, -17.9799, 4.4783998, 3.1067, -18.2665, 6.6640997, 3.0702, -19.018099, 6.6705, 3.2772, -18.741, 4.6443, 3.2897, -17.9799, 4.6443, 3.2897, -17.9799, 6.6893, 3.3913999, -16.7232, 3.8388, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 4.6443, 3.2897, -17.9799, 6.6705, 3.2772, -18.741, 6.6893, 3.3913999, -16.7232, 6.6705, 3.2772, -18.741, 6.6893, 3.2782998, -18.727999, 6.6893, 3.2782998, -18.727999, 6.6640997, 3.0702, -19.018099, 6.6893, 3.0440998, -19.036999, 6.6893, 3.2782998, -18.727999, 6.6705, 3.2772, -18.741, 6.6640997, 3.0702, -19.018099, 3.7529, 3.1712, -9.0494995, 0, 3.3913999, -8.7143, 0, 3.179, -9.0652, 3.7529, 3.1712, -9.0494995, 4.0225, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, 4.0225, 3.3913999, -8.7143, 4.4131, 3.2091, -13.357699, 4.6821, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 3.7529, 3.1712, -9.0494995, 4.4131, 3.2091, -13.357699, -3.6538, 3.1585999, -12.813399, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -3.6538, 3.1585999, -12.813399, -3.9101999, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -3.6076, 3.1676, -10.0696, -3.9101999, 3.3913999, -12.538199, -3.6538, 3.1585999, -12.813399, -3.6076, 3.1676, -10.0696, -3.9101999, 3.3913999, -9.8592, -3.9101999, 3.3913999, -12.538199, -0.0081, 3.1864998, -9.063499, -3.9101999, 3.3913999, -9.8592, -3.6076, 3.1676, -10.0696, -0.0081, 3.1864998, -9.063499, 0, 3.3913999, -8.7143, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, -0.0081, 3.1864998, -9.063499, 0, 3.179, -9.0652, 0, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, -0.0081, 3.1864998, -9.063499, -10.4043, 3.1608999, -13.4295, -10.699599, 3.3913999, -15.835, -10.4319, 3.1557999, -16.1556, -10.4043, 3.1608999, -13.4295, -10.699599, 3.3913999, -13.175799, -10.699599, 3.3913999, -15.835, -10.4319, 3.1557999, -16.1556, -13.3786, 3.3913999, -15.4154, -13.3786, 3.1771998, -15.7625, -10.4319, 3.1557999, -16.1556, -10.699599, 3.3913999, -15.835, -13.3786, 3.3913999, -15.4154, -10.699599, 3.3913999, -13.175799, -6.6893, 3.175, -12.867, -6.6893, 3.3913999, -12.538199, -10.699599, 3.3913999, -13.175799, -10.4043, 3.1608999, -13.4295, -6.6893, 3.175, -12.867, -16.130499, 3.6227, -13.623799, -16.0243, 3.3509998, -15.752, -15.7941, 3.5591, -15.4641, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -16.0243, 3.3509998, -15.752, -13.3911, 3.1892, -15.757, -15.7941, 3.5591, -15.4641, -16.0243, 3.3509998, -15.752, -13.3911, 3.1892, -15.757, -13.396, 3.3925, -15.4333, -15.7941, 3.5591, -15.4641, -15.7941, 3.5591, -15.4641, -13.3786, 3.3913999, -13.3786, -16.130499, 3.6227, -13.623799, -13.3786, 3.3913999, -13.3786, -15.7941, 3.5591, -15.4641, -13.396, 3.3925, -15.4333, -13.3786, 3.3913999, -13.3786, -13.396, 3.3925, -15.4333, -13.3786, 3.3913999, -15.4154, -13.3786, 3.3913999, -15.4154, -13.3911, 3.1892, -15.757, -13.3786, 3.1771998, -15.7625, -13.3786, 3.3913999, -15.4154, -13.396, 3.3925, -15.4333, -13.3911, 3.1892, -15.757, -16.3753, 3.4225, -13.6277, -16.1241, 3.3832998, -9.5097, -16.4131, 3.1706, -9.788899, -16.3753, 3.4225, -13.6277, -16.130499, 3.6227, -13.623799, -16.1241, 3.3832998, -9.5097, -16.7232, 3.3913999, -6.6893, -13.3786, 3.3913999, -10.033899, -13.3786, 3.3913999, -6.6893, -16.7232, 3.3913999, -6.6893, -16.1241, 3.3832998, -9.5097, -13.3786, 3.3913999, -10.033899, -16.1241, 3.3832998, -9.5097, -13.3786, 3.3913999, -13.3786, -13.3786, 3.3913999, -10.033899, -16.1241, 3.3832998, -9.5097, -16.130499, 3.6227, -13.623799, -13.3786, 3.3913999, -13.3786, -16.130499, 3.6227, -13.623799, -16.1241, 3.3832998, -9.5097, -16.130499, 3.6227, -13.623799, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -16.3753, 3.4225, -13.6277, -16.130499, 3.6227, -13.623799, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -19.7545, 3.3913999, -2.7851999, -19.515, 3.2122, -6.6893, -19.280699, 3.3913999, -6.6893, -19.7545, 3.3913999, -2.7851999, -19.9489, 3.2063, -2.8082, -19.515, 3.2122, -6.6893, -19.280699, 3.3913999, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.3965, 3.3909, -9.5092, -19.280699, 3.3913999, -6.6893, -19.515, 3.2122, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.280699, 3.3913999, -6.6893, -16.1241, 3.3832998, -9.5097, -16.7232, 3.3913999, -6.6893, -19.280699, 3.3913999, -6.6893, -19.3965, 3.3909, -9.5092, -16.1241, 3.3832998, -9.5097, -16.1241, 3.3832998, -9.5097, -19.6237, 3.1655998, -9.757899, -16.4131, 3.1706, -9.788899, -19.6237, 3.1655998, -9.757899, -16.1241, 3.3832998, -9.5097, -19.3965, 3.3909, -9.5092, -19.6237, 3.1655998, -9.757899, -19.3965, 3.3909, -9.5092, -19.6161, 3.2075999, -9.7116995, -22.8657, 3.4937, 3.3613, -23.3676, 3.4459999, 0.1372, -23.170399, 3.6251, 0.122999996, -22.8657, 3.4937, 3.3613, -23.0809, 3.3083, 3.3615, -23.3676, 3.4459999, 0.1372, -23.170399, 3.6251, 0.122999996, -23.390299, 3.3349998, -2.6938999, -23.192099, 3.5300999, -2.4668999, -23.170399, 3.6251, 0.122999996, -23.3676, 3.4459999, 0.1372, -23.390299, 3.3349998, -2.6938999, -23.192099, 3.5300999, -2.4668999, -19.9489, 3.2063, -2.8082, -19.9511, 3.3913999, -2.5834, -23.192099, 3.5300999, -2.4668999, -23.390299, 3.3349998, -2.6938999, -19.9489, 3.2063, -2.8082, -23.192099, 3.5300999, -2.4668999, -20.0679, 3.3913999, 0, -23.170399, 3.6251, 0.122999996, -20.0679, 3.3913999, 0, -23.192099, 3.5300999, -2.4668999, -19.9511, 3.3913999, -2.5834, -19.9511, 3.3913999, -2.5834, -23.192099, 3.5300999, -2.4668999, -19.9511, 3.3913999, -2.5834, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -22.8657, 3.4937, 3.3613, -23.170399, 3.6251, 0.122999996, -20.0679, 3.3913999, 0, -19.7545, 3.3913999, -2.7851999, -19.9489, 3.2063, -2.8082, -19.9489, 3.2063, -2.8082, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -19.9489, 3.2063, -2.8082, -19.9511, 3.3913999, -2.5834, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -23.0777, 3.3197, 3.3734, -22.3151, 3.3913999, 6.6893, -22.563799, 3.2219, 6.6893, -23.0777, 3.3197, 3.3734, -22.8764, 3.4937, 3.3741, -22.3151, 3.3913999, 6.6893, -22.8764, 3.4937, 3.3741, -20.0679, 3.3913999, 6.6893, -22.3151, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -22.8764, 3.4937, 3.3741, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 6.6893, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 3.3446, -22.8657, 3.4937, 3.3613, -23.0777, 3.3197, 3.3734, -23.0809, 3.3083, 3.3615, -22.8657, 3.4937, 3.3613, -22.8764, 3.4937, 3.3741, -23.0777, 3.3197, 3.3734, -16.285099, 3.3913999, 9.5865, -20.0679, 3.1943, 9.407499, -20.0679, 3.3913999, 9.167399, -16.285099, 3.3913999, 9.5865, -16.2818, 3.1913998, 9.8056, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -20.0679, 3.3913999, 9.167399, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -22.5541, 3.3913999, 9.2058, -20.0679, 3.3913999, 9.167399, -22.5541, 3.3913999, 9.2058, -22.563799, 3.2219, 6.6893, -22.3151, 3.3913999, 6.6893, -22.5541, 3.3913999, 9.2058, -22.791399, 3.2124999, 9.4432, -22.563799, 3.2219, 6.6893, -22.791399, 3.2124999, 9.4432, -22.5541, 3.3913999, 9.2058, -22.791399, 3.2124999, 9.4432, -13.3786, 3.3922, 12.571899, -16.076399, 3.04, 12.735, -15.847199, 3.2614, 12.4973, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, -16.076399, 3.04, 12.735, -15.847199, 3.2614, 12.4973, -16.2818, 3.1913998, 9.8056, -16.053999, 3.3913999, 9.8082, -15.847199, 3.2614, 12.4973, -16.076399, 3.04, 12.735, -16.2818, 3.1913998, 9.8056, -15.847199, 3.2614, 12.4973, -13.3786, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -15.847199, 3.2614, 12.4973, -16.053999, 3.3913999, 9.8082, -13.3786, 3.3913999, 10.033899, -16.053999, 3.3913999, 9.8082, -15.847199, 3.2614, 12.4973, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.2818, 3.1913998, 9.8056, -16.2818, 3.1913998, 9.8056, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -16.2818, 3.1913998, 9.8056, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -5.9742, 3.3994, 12.8817, -10.033899, 2.9384, 12.4537, -10.033899, 3.2142, 12.1061, -5.9742, 3.3994, 12.8817, -6.2748, 3.126, 13.122, -10.033899, 2.9384, 12.4537, -10.033899, 3.2142, 12.1061, -13.3786, 3.194, 12.8089, -13.3786, 3.3922, 12.571899, -10.033899, 3.2142, 12.1061, -10.033899, 2.9384, 12.4537, -13.3786, 3.194, 12.8089, -10.033899, 3.2142, 12.1061, -6.6893, 3.3913999, 10.033899, -5.9742, 3.3994, 12.8817, -10.033899, 3.2142, 12.1061, -10.033899, 3.3913999, 10.033899, -6.6893, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -10.033899, 3.3913999, 10.033899, -10.033899, 3.2142, 12.1061, -13.3786, 3.3922, 12.571899, -13.3786, 3.3913999, 10.033899, -10.033899, 3.3913999, 10.033899, -13.3786, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -13.3786, 3.3922, 12.571899, -5.9742, 3.3994, 12.8817, -3.3446, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, -5.9742, 3.3994, 12.8817, -6.6893, 3.3913999, 10.033899, -3.3446, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, -13.3786, 3.194, 12.8089, -13.3786, 3.3922, 12.571899, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, 3.6399, 3.3913999, 16.2405, 0, 2.9636998, 16.0179, 0, 3.2243998, 15.7093, 3.6399, 3.3913999, 16.2405, 3.6425998, 3.1413999, 16.5127, 0, 2.9636998, 16.0179, 0, 3.2243998, 15.7093, -3.3446, 3.1497998, 15.914599, -3.3446, 3.3913999, 15.6011, 0, 3.2243998, 15.7093, 0, 2.9636998, 16.0179, -3.3446, 3.1497998, 15.914599, -6.2558, 3.1427999, 16.4098, -3.3446, 3.3913999, 15.6011, -3.3446, 3.1497998, 15.914599, -6.2558, 3.1427999, 16.4098, -5.9737997, 3.392, 16.087599, -3.3446, 3.3913999, 15.6011, -3.3446, 3.3913999, 15.6011, 0, 3.3913999, 13.3786, 0, 3.2243998, 15.7093, -3.3446, 3.3913999, 15.6011, -3.3446, 3.3913999, 13.3786, 0, 3.3913999, 13.3786, 0, 3.2243998, 15.7093, 3.3446, 3.3913999, 13.3786, 3.6399, 3.3913999, 16.2405, 0, 3.2243998, 15.7093, 0, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, -5.9737997, 3.392, 16.087599, -3.3446, 3.3913999, 13.3786, -3.3446, 3.3913999, 15.6011, -5.9737997, 3.392, 16.087599, -5.9742, 3.3994, 12.8817, -3.3446, 3.3913999, 13.3786, -6.2748, 3.1260998, 16.4145, -5.9737997, 3.392, 16.087599, -6.2558, 3.1427999, 16.4098, -5.9737997, 3.392, 16.087599, -6.2748, 3.1260998, 16.4145, -6.2748, 3.126, 13.122, -5.9737997, 3.392, 16.087599, -6.2748, 3.126, 13.122, -5.9742, 3.3994, 12.8817, 6.6893, 3.3913999, 18.9861, 3.9266999, 3.5967, 19.6223, 4.1273, 3.7335, 19.3804, 6.6893, 3.3913999, 18.9861, 6.6893, 3.2050998, 19.228699, 3.9266999, 3.5967, 19.6223, 4.1273, 3.7335, 19.3804, 3.6536, 3.2078998, 16.5669, 3.8651, 3.3981998, 16.5647, 4.1273, 3.7335, 19.3804, 3.9266999, 3.5967, 19.6223, 3.6536, 3.2078998, 16.5669, 3.8651, 3.3981998, 16.5647, 6.6893, 3.3913999, 18.9861, 4.1273, 3.7335, 19.3804, 6.6893, 3.3913999, 18.9861, 3.8651, 3.3981998, 16.5647, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 18.9861, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 16.7232, 3.9148, 3.3913999, 16.514599, 3.6425998, 3.1413999, 16.5127, 3.6399, 3.3913999, 16.2405, 3.6425998, 3.1413999, 16.5127, 3.9148, 3.3913999, 16.514599, 3.8651, 3.3981998, 16.5647, 3.6425998, 3.1413999, 16.5127, 3.8651, 3.3981998, 16.5647, 3.6536, 3.2078998, 16.5669, 9.3066, 3.2186, 19.0653, 10.3743, 3.3913999, 16.7232, 10.5913, 3.1985998, 16.7232, 9.3066, 3.2186, 19.0653, 9.1275, 3.3913999, 18.830599, 10.3743, 3.3913999, 16.7232, 9.1275, 3.3913999, 18.830599, 6.6991997, 3.2148, 19.2255, 6.6949, 3.3913999, 18.9914, 9.1275, 3.3913999, 18.830599, 9.3066, 3.2186, 19.0653, 6.6991997, 3.2148, 19.2255, 6.6893, 3.3913999, 18.9861, 6.6991997, 3.2148, 19.2255, 6.6893, 3.2050998, 19.228699, 6.6893, 3.3913999, 18.9861, 6.6949, 3.3913999, 18.9914, 6.6991997, 3.2148, 19.2255, 9.8131, 3.3546, 13.1979, 10.5893, 3.2117, 16.7106, 10.403999, 3.3911998, 16.6938, 9.8131, 3.3546, 13.1979, 10.0546, 3.1885, 13.4071, 10.5893, 3.2117, 16.7106, 6.6893, 3.3913999, 13.3786, 10.3743, 3.3913999, 16.7232, 6.6893, 3.3913999, 16.7232, 10.3743, 3.3913999, 16.7232, 6.6893, 3.3913999, 13.3786, 9.8131, 3.3546, 13.1979, 10.3743, 3.3913999, 16.7232, 9.8131, 3.3546, 13.1979, 10.403999, 3.3911998, 16.6938, 6.6893, 3.3913999, 13.3786, 10.033899, 3.3913999, 10.033899, 9.8131, 3.3546, 13.1979, 6.6893, 3.3913999, 13.3786, 6.6893, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 10.3743, 3.3913999, 16.7232, 10.5893, 3.2117, 16.7106, 10.5913, 3.1985998, 16.7232, 10.3743, 3.3913999, 16.7232, 10.403999, 3.3911998, 16.6938, 10.5893, 3.2117, 16.7106, 13.1839, 3.2744, 6.6893, 13.5511, 3.1797, 6.6893, 13.4036, 3.0749998, 6.6893, 13.1839, 3.2744, 6.6893, 13.3786, 3.2744, 6.496, 13.5511, 3.1797, 6.6893, 12.9758, 3.2036, 10.033899, 13.1839, 3.2744, 6.6893, 13.4036, 3.0749998, 6.6893, 12.9758, 3.2036, 10.033899, 12.7325, 3.3913999, 10.033899, 13.1839, 3.2744, 6.6893, 13.2631, 3.5611, 13.4043, 12.7325, 3.3913999, 10.033899, 12.9758, 3.2036, 10.033899, 13.2631, 3.5611, 13.4043, 13.025499, 3.6999, 13.1985, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 13.1839, 3.2744, 6.6893, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 10.033899, 3.3913999, 6.6893, 13.1839, 3.2744, 6.6893, 9.8131, 3.3546, 13.1979, 12.7325, 3.3913999, 10.033899, 13.025499, 3.6999, 13.1985, 9.8131, 3.3546, 13.1979, 10.033899, 3.3913999, 10.033899, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 3.3446, 13.1839, 3.2744, 6.6893, 10.033899, 3.3913999, 6.6893, 13.1839, 3.2744, 6.6893, 10.033899, 3.3913999, 3.3446, 13.3786, 3.3913999, 3.3446, 13.1839, 3.2744, 6.6893, 13.3786, 3.3913999, 3.3446, 13.3786, 3.2744, 6.496, 13.025499, 3.6999, 13.1985, 10.0546, 3.1885, 13.4071, 9.8131, 3.3546, 13.1979, 13.025499, 3.6999, 13.1985, 13.2631, 3.5611, 13.4043, 10.0546, 3.1885, 13.4071, 13.2631, 3.5611, 13.4043, 13.2631, 3.5611, 13.4043, 13.025499, 3.6999, 13.1985, 14.0489, 3.0042, -3.3446, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3028998, -3.6027, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.0865998, 13.1206, 3.3028998, -3.3446, 16.7232, 3.1513, -3.602, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.6027, 16.7232, 3.1513, -3.602, 16.7127, 2.8734, -3.3446, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.6027, 16.7232, 3.3913999, -6.6893, 16.7232, 3.1513, -3.602, 13.3786, 3.3028998, -3.6027, 13.3786, 3.3913999, -6.6893, 16.7232, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 13.1206, 3.3028998, -3.3446, 10.033899, 3.3913999, -3.3446, 13.1206, 3.3028998, -3.3446, 10.033899, 3.3913999, -6.6893, 13.3786, 3.3913999, -6.6893, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3913999, -6.6893, 13.3786, 3.3028998, -3.6027, 20.0679, 0, 0, 14.0489, 3.0042, -3.3446, 20.961199, 0, -3.3446, 20.0679, 0, 3.3446, 14.0489, 3.0042, -3.3446, 20.0679, 0, 0, 14.0489, 3.0042, -3.3446, 13.3786, 3.3913999, 0, 13.3786, 3.3028998, -3.0865998, 14.0489, 3.0042, -3.3446, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 0, 14.0489, 3.0042, -3.3446, 20.0679, 0, 3.3446, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 0, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 3.3446, 13.3786, 3.2744, 6.496, 20.0679, 0, 3.3446, 16.7232, 1.5715, 6.6893, 16.7232, 1.5715, 6.6893, 20.0679, 0, 3.3446, 20.0679, -0.124199994, 6.6893, 13.3786, 3.2744, 6.496, 16.7232, 1.5715, 6.6893, 13.5511, 3.1797, 6.6893, 10.033899, 3.3913999, -3.3446, 13.3786, 3.3913999, 0, 10.033899, 3.3913999, 0, 13.3786, 3.3913999, 0, 10.033899, 3.3913999, -3.3446, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3913999, 0, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3028998, -3.0865998, 19.831999, 3.3902998, -10.0008, 20.147, 3.0588999, -6.6893, 19.8322, 3.2891998, -6.6893, 19.831999, 3.3902998, -10.0008, 20.1796, 3.182, -9.995999, 20.147, 3.0588999, -6.6893, 19.8322, 3.2891998, -6.6893, 20.1349, 2.7974, -3.3783998, 19.8322, 3.0491, -3.602, 19.8322, 3.2891998, -6.6893, 20.147, 3.0588999, -6.6893, 20.1349, 2.7974, -3.3783998, 16.7232, 3.1513, -3.602, 19.8322, 3.2891998, -6.6893, 19.8322, 3.0491, -3.602, 16.7232, 3.1513, -3.602, 16.7232, 3.3913999, -6.6893, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -10.033899, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -6.6893, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -10.033899, 19.798899, 3.3913999, -10.033899, 19.8322, 3.2891998, -6.6893, 19.798899, 3.3913999, -10.033899, 19.831999, 3.3902998, -10.0008, 20.198, 3.1490998, -10.033899, 19.831999, 3.3902998, -10.0008, 19.798899, 3.3913999, -10.033899, 20.198, 3.1490998, -10.033899, 20.1796, 3.182, -9.995999, 19.831999, 3.3902998, -10.0008, 20.144999, 2.7605999, -3.3446, 19.8322, 3.0491, -3.602, 20.1349, 2.7974, -3.3783998, 19.8322, 3.0491, -3.602, 20.144999, 2.7605999, -3.3446, 16.7127, 2.8734, -3.3446, 19.8322, 3.0491, -3.602, 16.7127, 2.8734, -3.3446, 16.7232, 3.1513, -3.602, 15.0508995, 0, 6.6893, 16.7232, 1.5715, 6.6893, 16.7232, 0, 6.6893, 16.7232, 0, 6.6893, 16.7232, 1.5715, 6.6893, 20.0679, -0.124199994, 6.6893, 15.0508995, 0, 6.6893, 13.5511, 3.1797, 6.6893, 16.7232, 1.5715, 6.6893, 15.0508995, 0, 6.6893, 13.4036, 3.0749998, 6.6893, 13.5511, 3.1797, 6.6893, 13.4036, 3.0749998, 6.6893, 15.0508995, 0, 6.6893, 13.8046, 0, 6.6893, -16.7232, 0.6137, 0, -15.7546, 0.6137, 3.5372, -16.7232, 0.6137, 2.9166, -15.7546, 0.6137, 3.5372, -16.7232, 0.6137, 0, -13.3786, 0.6137, 0, -16.7232, 0.6137, 0, -15.8036995, 0.6137, -2.7091, -13.3786, 0.6137, -3.3446, -16.7232, 0.6137, 0, -13.3786, 0.6137, -3.3446, -13.3786, 0.6137, 0, -13.3786, 0.6137, 0, -13.3786, 0.6137, -3.3446, -10.033899, 0.6137, -3.3446, -15.7546, 0.6137, 3.5372, -13.3786, 0.6137, 0, -14.4892, 0.6137, 4.2778, -13.3786, 0.6137, 0, -10.033899, 0.6137, -3.3446, -10.033899, 0.6137, 0, -14.4892, 0.6137, 4.2778, -13.3786, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, -3.3446, -6.6893, 0.6137, -3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, -3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 0, -6.6893, 0.6137, -3.3446, -4.0257998, 0.6137, -1.6911999, -6.6893, 0.6137, 0, -4.0257998, 0.6137, -1.6911999, -3.3446, 0.6137, 0, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, 0, -14.4892, 0.6137, 4.2778, -10.033899, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 0, -3.3446, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -3.3446, 0.6137, 0, -3.3446, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -14.4892, 0.6137, 4.2778, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 6.6893, -10.033899, 0.6137, 6.6893, -13.677099, 0.6137, 5.2616, -14.4892, 0.6137, 4.2778, -13.677099, 0.6137, 5.2616, -10.033899, 0.6137, 6.6893, -12.747499, 0.6137, 6.3671, -6.6893, 0.6137, 3.3446, -2.5674999, 0.0266, 7.6215, -6.6893, 0.6137, 6.6893, -2.5674999, 0.0266, 7.6215, -6.6893, 0.6137, 3.3446, -3.3446, 0.6137, 3.3446, -2.5674999, 0.0266, 7.6215, -3.3446, 0.6137, 3.3446, -2.1193, 0.6137, 5.2736998, -16.7232, 3.0033998, 2.9166, -15.7546, 0.6137, 3.5372, -15.372, 3.0033998, 3.6132998, -16.7232, 3.0033998, 2.9166, -16.7232, 0.6137, 2.9166, -15.7546, 0.6137, 3.5372, -14.4892, 3.0033998, 4.2778, -13.677099, 0.6137, 5.2616, -13.6942, 3.0033998, 5.1798, -14.4892, 3.0033998, 4.2778, -14.4892, 0.6137, 4.2778, -13.677099, 0.6137, 5.2616, -15.8036995, 3.0033998, -2.7091, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -15.8036995, 3.0033998, -2.7091, -15.8036995, 0.6137, -2.7091, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 2.9166, -16.7232, 3.0033998, 2.9166, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 0, -16.7232, 0.6137, 2.9166, -3.3446, 3.0033998, 0, -4.0257998, 0.6137, -1.6911999, -4.0257998, 3.0033998, -1.6911999, -3.3446, 3.0033998, 0, -3.3446, 0.6137, 0, -4.0257998, 0.6137, -1.6911999, -3.3446, 3.0033998, 3.3446, -3.3446, 0.6137, 0, -3.3446, 3.0033998, 0, -3.3446, 3.0033998, 3.3446, -3.3446, 0.6137, 3.3446, -3.3446, 0.6137, 0, -12.747499, 3.0033998, 6.3671, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, 6.6893, -12.747499, 3.0033998, 6.3671, -12.747499, 0.6137, 6.3671, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, -3.3446, -13.3786, 0.6137, -3.3446, -13.3786, 3.0033998, -3.3446, -10.033899, 3.0033998, -3.3446, -10.033899, 0.6137, -3.3446, -13.3786, 0.6137, -3.3446, -6.6893, 3.0033998, -3.3446, -10.033899, 0.6137, -3.3446, -10.033899, 3.0033998, -3.3446, -6.6893, 3.0033998, -3.3446, -6.6893, 0.6137, -3.3446, -10.033899, 0.6137, -3.3446, -13.3786, 3.0033998, -3.3446, -15.8036995, 0.6137, -2.7091, -15.8036995, 3.0033998, -2.7091, -13.3786, 3.0033998, -3.3446, -13.3786, 0.6137, -3.3446, -15.8036995, 0.6137, -2.7091, -4.0257998, 3.0033998, -1.6911999, -6.6893, 0.6137, -3.3446, -6.6893, 3.0033998, -3.3446, -4.0257998, 3.0033998, -1.6911999, -4.0257998, 0.6137, -1.6911999, -6.6893, 0.6137, -3.3446, -6.6893, 3.0033998, 6.6893, -2.5674999, 0.0266, 7.6215, -2.5674999, 2.4164, 7.6215, -6.6893, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -2.5674999, 0.0266, 7.6215, -10.033899, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -6.6893, 3.0033998, 6.6893, -10.033899, 3.0033998, 6.6893, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 6.6893, -2.5674999, 2.4164, 7.6215, -2.1193, 0.6137, 5.2736998, -2.1397, 3.0033998, 5.1795, -2.5674999, 2.4164, 7.6215, -2.5674999, 0.0266, 7.6215, -2.1193, 0.6137, 5.2736998, -2.1193, 0.6137, 5.2736998, -3.3446, 3.0033998, 3.3446, -2.1397, 3.0033998, 5.1795, -2.1193, 0.6137, 5.2736998, -3.3446, 0.6137, 3.3446, -3.3446, 3.0033998, 3.3446, -13.677099, 0.6137, 5.2616, -12.747499, 3.0033998, 6.3671, -13.6942, 3.0033998, 5.1798, -13.677099, 0.6137, 5.2616, -12.747499, 0.6137, 6.3671, -12.747499, 3.0033998, 6.3671, -15.7546, 0.6137, 3.5372, -14.4892, 3.0033998, 4.2778, -15.372, 3.0033998, 3.6132998, -15.7546, 0.6137, 3.5372, -14.4892, 0.6137, 4.2778, -14.4892, 3.0033998, 4.2778, 21.1831, 0, -13.6544, 16.8204, 3.138, -13.447599, 18.0241, 0, -14.3019, 21.1831, 0, -13.6544, 20.1539, 3.1299, -13.399799, 16.8204, 3.138, -13.447599, 21.8888, 0, -10.033899, 20.1539, 3.1299, -13.399799, 21.1831, 0, -13.6544, 21.8888, 0, -10.033899, 20.198, 3.1490998, -10.033899, 20.1539, 3.1299, -13.399799, 17.2285, 0, -16.8036, 12.7471, 3.1766999, -16.1988, 13.7014, 0, -17.416399, 17.2285, 0, -16.8036, 16.756899, 3.1650999, -16.7286, 12.7471, 3.1766999, -16.1988, 16.8204, 3.138, -13.447599, 17.2285, 0, -16.8036, 18.0241, 0, -14.3019, 17.2285, 0, -16.8036, 16.8204, 3.138, -13.447599, 16.8051, 3.1778, -13.479, 17.2285, 0, -16.8036, 16.8051, 3.1778, -13.479, 16.756899, 3.1650999, -16.7286, 12.7471, 3.1766999, -16.1988, 13.3786, 0, -20.5992, 13.7014, 0, -17.416399, 13.3786, 0, -20.5992, 12.7471, 3.1766999, -16.1988, 12.747, 3.1771, -16.1991, 13.3786, 0, -20.5992, 12.747, 3.1771, -16.1991, 12.723599, 2.9213, -19.2902, 13.3786, 0, -20.5992, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 13.3786, 0, -20.5992, 12.723599, 2.9213, -19.2902, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 6.6893, 3.0440998, -19.036999, 6.6893, 0, -20.1318, 10.033899, 0, -20.467, 10.033899, 3.0523999, -19.0294, 6.6893, 3.0440998, -19.036999, 3.0140998, 0, -16.7232, 4.4131, 3.2091, -13.357699, 3.3446, -0.3989, -12.9443, 3.0140998, 0, -16.7232, 3.5895, 3.2033, -16.7232, 4.4131, 3.2091, -13.357699, 6.6893, 3.0440998, -19.036999, 3.3446, 0, -20.5992, 6.6893, 0, -20.1318, 3.3446, 0, -20.5992, 6.6893, 3.0440998, -19.036999, 6.6640997, 3.0702, -19.018099, 3.3446, 0, -20.5992, 6.6640997, 3.0702, -19.018099, 4.4783998, 3.1067, -18.2665, 3.3446, 0, -20.5992, 3.5895, 3.2033, -16.7232, 3.0140998, 0, -16.7232, 3.3446, 0, -20.5992, 4.4783998, 3.1067, -18.2665, 3.5895, 3.2033, -16.7232, 2.8241, 0, -10.4594, 0, 3.179, -9.0652, 0, 0, -10.7654, 2.8241, 0, -10.4594, 3.7529, 3.1712, -9.0494995, 0, 3.179, -9.0652, 3.3446, -0.3989, -12.9443, 3.7529, 3.1712, -9.0494995, 2.8241, 0, -10.4594, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357699, 3.7529, 3.1712, -9.0494995, 4.4131, 3.2091, -13.357699, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357699, -2.6297, 0, -10.5745, -3.6538, 3.1585999, -12.813399, -3.3446, 0, -13.3786, -2.6297, 0, -10.5745, -3.6076, 3.1676, -10.0696, -3.6538, 3.1585999, -12.813399, -3.3446, 0, -13.3786, -6.6893, 3.175, -12.867, -6.6893, 0, -14.2637, -3.3446, 0, -13.3786, -3.6538, 3.1585999, -12.813399, -6.6893, 3.175, -12.867, 0, 3.179, -9.0652, -2.6297, 0, -10.5745, 0, 0, -10.7654, -2.6297, 0, -10.5745, 0, 3.179, -9.0652, -0.0081, 3.1864998, -9.063499, -2.6297, 0, -10.5745, -0.0081, 3.1864998, -9.063499, -3.6076, 3.1676, -10.0696, -6.6893, 0, -14.2637, -10.4043, 3.1608999, -13.4295, -9.6168995, 0, -14.127899, -6.6893, 0, -14.2637, -6.6893, 3.175, -12.867, -10.4043, 3.1608999, -13.4295, -6.6893, 3.175, -12.867, -6.6893, 0, -14.2637, -6.6893, 3.175, -12.867, -9.6168995, 0, -14.127899, -10.4319, 3.1557999, -16.1556, -10.033899, 0, -16.7232, -9.6168995, 0, -14.127899, -10.4043, 3.1608999, -13.4295, -10.4319, 3.1557999, -16.1556, -10.033899, 0, -16.7232, -13.3786, 3.1771998, -15.7625, -13.3786, 0, -17.3364, -10.033899, 0, -16.7232, -10.4319, 3.1557999, -16.1556, -13.3786, 3.1771998, -15.7625, -16.7232, 0, -16.7232, -16.3753, 3.4225, -13.6277, -16.7232, 0, -13.3786, -16.7232, 0, -16.7232, -16.0243, 3.3509998, -15.752, -16.3753, 3.4225, -13.6277, -13.3786, 3.1771998, -15.7625, -16.7232, 0, -16.7232, -13.3786, 0, -17.3364, -16.7232, 0, -16.7232, -13.3786, 3.1771998, -15.7625, -13.3911, 3.1892, -15.757, -16.7232, 0, -16.7232, -13.3911, 3.1892, -15.757, -16.0243, 3.3509998, -15.752, -17.275, 0, -10.5169, -19.6237, 3.1655998, -9.757899, -20.0679, 0, -10.033899, -17.275, 0, -10.5169, -16.4131, 3.1706, -9.788899, -19.6237, 3.1655998, -9.757899, -16.7232, 0, -13.3786, -16.4131, 3.1706, -9.788899, -17.275, 0, -10.5169, -16.7232, 0, -13.3786, -16.3753, 3.4225, -13.6277, -16.4131, 3.1706, -9.788899, -16.3753, 3.4225, -13.6277, -16.7232, 0, -13.3786, -16.3753, 3.4225, -13.6277, -20.3764, 0, -6.6893, -19.9489, 3.2063, -2.8082, -20.0679, -0.3484, -3.5669, -20.3764, 0, -6.6893, -19.515, 3.2122, -6.6893, -19.9489, 3.2063, -2.8082, -19.6237, 3.1655998, -9.757899, -20.3764, 0, -6.6893, -20.0679, 0, -10.033899, -20.3764, 0, -6.6893, -19.6237, 3.1655998, -9.757899, -19.6161, 3.2075999, -9.7116995, -20.3764, 0, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.515, 3.2122, -6.6893, -23.412498, -0.2913, 0.26119998, -23.0809, 3.3083, 3.3615, -23.412498, 0, 3.3446, -23.412498, -0.2913, 0.26119998, -23.3676, 3.4459999, 0.1372, -23.0809, 3.3083, 3.3615, -23.412498, 0, -3.3446, -23.3676, 3.4459999, 0.1372, -23.412498, -0.2913, 0.26119998, -23.412498, 0, -3.3446, -23.390299, 3.3349998, -2.6938999, -23.3676, 3.4459999, 0.1372, -20.0679, -0.3484, -3.5669, -23.390299, 3.3349998, -2.6938999, -23.412498, 0, -3.3446, -20.0679, -0.3484, -3.5669, -19.9489, 3.2063, -2.8082, -23.390299, 3.3349998, -2.6938999, -19.9489, 3.2063, -2.8082, -20.0679, -0.3484, -3.5669, -19.9489, 3.2063, -2.8082, -23.0809, 3.3083, 3.3615, -23.8078, 0, 6.6893, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -23.0809, 3.3083, 3.3615, -23.0777, 3.3197, 3.3734, -23.8078, 0, 6.6893, -23.0777, 3.3197, 3.3734, -22.563799, 3.2219, 6.6893, -23.8078, 0, 6.6893, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -23.8078, 0, 6.6893, -22.563799, 3.2219, 6.6893, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -20.0679, 3.1943, 9.407499, -20.0679, 0, 10.033899, -23.412498, 0, 10.033899, -22.791399, 3.2124999, 9.4432, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -22.791399, 3.2124999, 9.4432, -20.0679, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.033899, -20.0679, 0, 10.033899, -20.0679, 3.1943, 9.407499, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 13.3786, -13.3786, 3.194, 12.8089, -13.3786, 0, 13.3786, -16.7232, 0, 13.3786, -16.076399, 3.04, 12.735, -13.3786, 3.194, 12.8089, -16.7232, 0, 10.033899, -16.076399, 3.04, 12.735, -16.7232, 0, 13.3786, -16.7232, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -16.076399, 3.04, 12.735, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -13.3786, 0, 13.3786, -10.033899, 2.9384, 12.4537, -10.033899, 0, 13.3786, -13.3786, 0, 13.3786, -13.3786, 3.194, 12.8089, -10.033899, 2.9384, 12.4537, -13.3786, 3.194, 12.8089, -13.3786, 0, 13.3786, -13.3786, 3.194, 12.8089, -10.033899, 0, 13.3786, -6.2748, 3.126, 13.122, -6.6893, 0, 13.3786, -10.033899, 0, 13.3786, -10.033899, 2.9384, 12.4537, -6.2748, 3.126, 13.122, -6.6893, 0, 13.3786, -6.2748, 3.1260998, 16.4145, -6.6893, 0, 16.7232, -6.6893, 0, 13.3786, -6.2748, 3.126, 13.122, -6.2748, 3.1260998, 16.4145, -3.3446, 0, 16.7232, 0, 2.9636998, 16.0179, 0, 0, 16.7232, -3.3446, 0, 16.7232, -3.3446, 3.1497998, 15.914599, 0, 2.9636998, 16.0179, 0, 0, 16.7232, 3.6425998, 3.1413999, 16.5127, 3.3446, 0, 16.7232, 0, 0, 16.7232, 0, 2.9636998, 16.0179, 3.6425998, 3.1413999, 16.5127, -6.2748, 3.1260998, 16.4145, -3.3446, 0, 16.7232, -6.6893, 0, 16.7232, -3.3446, 0, 16.7232, -6.2748, 3.1260998, 16.4145, -6.2558, 3.1427999, 16.4098, -3.3446, 0, 16.7232, -6.2558, 3.1427999, 16.4098, -3.3446, 3.1497998, 15.914599, 3.3446, 0, 20.0679, 6.6893, 3.2050998, 19.228699, 6.6893, 0, 20.0679, 3.3446, 0, 20.0679, 3.9266999, 3.5967, 19.6223, 6.6893, 3.2050998, 19.228699, 3.6425998, 3.1413999, 16.5127, 3.3446, 0, 20.0679, 3.3446, 0, 16.7232, 3.3446, 0, 20.0679, 3.6425998, 3.1413999, 16.5127, 3.6536, 3.2078998, 16.5669, 3.3446, 0, 20.0679, 3.6536, 3.2078998, 16.5669, 3.9266999, 3.5967, 19.6223, 6.6893, 3.2050998, 19.228699, 10.572599, 0, 20.0679, 6.6893, 0, 20.0679, 10.572599, 0, 20.0679, 6.6893, 3.2050998, 19.228699, 6.6991997, 3.2148, 19.2255, 10.572599, 0, 20.0679, 6.6991997, 3.2148, 19.2255, 9.3066, 3.2186, 19.0653, 10.572599, 0, 20.0679, 10.5913, 3.1985998, 16.7232, 10.572599, 0, 16.7232, 10.572599, 0, 20.0679, 9.3066, 3.2186, 19.0653, 10.5913, 3.1985998, 16.7232, 10.5913, 3.1985998, 16.7232, 10.572599, 0, 13.918799, 10.572599, 0, 16.7232, 10.572599, 0, 13.918799, 10.5913, 3.1985998, 16.7232, 10.5893, 3.2117, 16.7106, 10.572599, 0, 13.918799, 10.5893, 3.2117, 16.7106, 10.0546, 3.1885, 13.4071, 10.572599, 0, 13.918799, 13.2631, 3.5611, 13.4043, 13.8046, 0, 13.918799, 10.572599, 0, 13.918799, 10.0546, 3.1885, 13.4071, 13.2631, 3.5611, 13.4043, 13.8046, 0, 13.918799, 12.9758, 3.2036, 10.033899, 13.8046, 0, 10.033899, 13.8046, 0, 13.918799, 13.2631, 3.5611, 13.4043, 12.9758, 3.2036, 10.033899, 13.8046, 0, 13.918799, 13.2631, 3.5611, 13.4043, 13.2631, 3.5611, 13.4043, 13.8046, 0, 10.033899, 13.4036, 3.0749998, 6.6893, 13.8046, 0, 6.6893, 13.8046, 0, 10.033899, 12.9758, 3.2036, 10.033899, 13.4036, 3.0749998, 6.6893, 20.961199, 0, -3.3446, 16.7127, 2.8734, -3.3446, 20.144999, 2.7605999, -3.3446, 20.961199, 0, -3.3446, 14.0489, 3.0042, -3.3446, 16.7127, 2.8734, -3.3446, 20.144999, 2.7605999, -3.3446, 21.2349, 0, -6.6893, 20.961199, 0, -3.3446, 21.2349, 0, -6.6893, 20.144999, 2.7605999, -3.3446, 20.1349, 2.7974, -3.3783998, 21.2349, 0, -6.6893, 20.1349, 2.7974, -3.3783998, 20.147, 3.0588999, -6.6893, 20.147, 3.0588999, -6.6893, 21.8888, 0, -10.033899, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 20.147, 3.0588999, -6.6893, 20.1796, 3.182, -9.995999, 21.8888, 0, -10.033899, 20.1796, 3.182, -9.995999, 20.198, 3.1490998, -10.033899, 23.412498, 0, -26.7572, 26.7572, -0.2593, -23.412498, 23.412498, 0, -23.412498, 26.7572, -0.2593, -23.412498, 23.412498, 0, -26.7572, 26.7572, -0.2593, -25.084799, 26.7572, -0.2593, -25.084799, 23.412498, 0, -26.7572, 26.7572, -0.2593, -26.7572, 26.7572, -0.2593, -21.7402, 23.412498, 0, -23.412498, 26.7572, -0.2593, -23.412498, 26.7572, -0.2593, -20.0679, 23.412498, 0, -23.412498, 26.7572, -0.2593, -21.7402, 23.412498, 0, -23.412498, 26.7572, -0.2593, -20.0679, 23.412498, 0, -20.0679, 26.7572, -0.2593, -18.3955, 23.412498, 0, -20.0679, 26.7572, -0.2593, -20.0679, 26.7572, -0.2593, -16.7232, 23.412498, 0, -20.0679, 26.7572, -0.2593, -18.3955, 23.412498, 0, -20.0679, 26.7572, -0.2593, -16.7232, 23.412498, 0, -16.7232, 26.7572, -0.2593, -15.0508995, 23.412498, 0, -16.7232, 26.7572, -0.2593, -16.7232, 26.7572, -0.2593, -13.3786, 23.412498, 0, -16.7232, 26.7572, -0.2593, -15.0508995, 23.412498, 0, -16.7232, 26.7572, -0.2593, -13.3786, 23.412498, 0, -13.3786, 26.7572, -0.2593, -11.7063, 23.412498, 0, -13.3786, 26.7572, -0.2593, -13.3786, 26.7572, -0.2593, -10.033899, 23.412498, 0, -13.3786, 26.7572, -0.2593, -11.7063, 23.412498, 0, -13.3786, 26.7572, -0.2593, -10.033899, 23.412498, 0, -10.033899, 26.7572, -0.2593, -8.3616, 23.412498, 0, -10.033899, 26.7572, -0.2593, -10.033899, 26.7572, -0.2593, -6.6893, 23.412498, 0, -10.033899, 26.7572, -0.2593, -8.3616, 23.412498, 0, -10.033899, 26.7572, -0.2593, -6.6893, 23.412498, 0, -6.6893, 26.7572, -0.2593, -5.0169997, 23.412498, 0, -6.6893, 26.7572, -0.2593, -6.6893, 26.7572, -0.2593, -3.3446, 23.412498, 0, -6.6893, 26.7572, -0.2593, -5.0169997, 23.412498, 0, -6.6893, 26.7572, -0.2593, -3.3446, 23.412498, 0, -3.3446, 26.7572, -0.2593, -1.6723, 23.412498, 0, -3.3446, 26.7572, -0.2593, -3.3446, 26.7572, -0.2593, 0, 23.412498, 0, -3.3446, 26.7572, -0.2593, -1.6723, 23.412498, 0, -3.3446, 26.7572, -0.2593, 0, 23.412498, 0, 0, 26.7572, -0.2593, 1.6723, 23.412498, 0, 0, 26.7572, -0.2593, 0, 26.7572, -0.2593, 3.3446, 23.412498, 0, 0, 26.7572, -0.2593, 1.6723, 23.412498, 0, 0, 26.7572, -0.2593, 3.3446, 23.412498, 0, 3.3446, 26.7572, -0.2593, 5.0169997, 23.412498, 0, 3.3446, 26.7572, -0.2593, 3.3446, 26.7572, -0.2593, 6.6893, 23.412498, 0, 3.3446, 26.7572, -0.2593, 5.0169997, 23.412498, 0, 3.3446, 26.7572, -0.2593, 6.6893, 23.412498, 0, 6.6893, 26.7572, -0.2593, 8.3616, 23.412498, 0, 6.6893, 26.7572, -0.2593, 6.6893, 26.7572, -0.2593, 10.033899, 23.412498, 0, 6.6893, 26.7572, -0.2593, 8.3616, 23.412498, 0, 6.6893, 26.7572, -0.2593, 10.033899, 23.412498, 0, 10.033899, 26.7572, -0.2593, 11.7063, 23.412498, 0, 10.033899, 26.7572, -0.2593, 10.033899, 26.7572, -0.2593, 13.3786, 23.412498, 0, 10.033899, 26.7572, -0.2593, 11.7063, 23.412498, 0, 10.033899, 26.7572, -0.2593, 13.3786, 23.412498, 0, 13.3786, 26.7572, -0.2593, 15.0508995, 23.412498, 0, 13.3786, 26.7572, -0.2593, 13.3786, 26.7572, -0.2593, 16.7232, 23.412498, 0, 13.3786, 26.7572, -0.2593, 15.0508995, 23.412498, 0, 13.3786, 26.7572, -0.2593, 16.7232, 23.412498, 0, 16.7232, 26.7572, -0.2593, 18.3955, 23.412498, 0, 16.7232, 26.7572, -0.2593, 16.7232, 26.7572, -0.2593, 20.0679, 23.412498, 0, 16.7232, 26.7572, -0.2593, 18.3955, 23.412498, 0, 16.7232, 26.7572, -0.2593, 20.0679, 23.412498, 0, 20.0679, 26.7572, -0.2593, 21.7402, 23.412498, 0, 20.0679, 26.7572, -0.2593, 20.0679, 26.7572, -0.2593, 23.412498, 23.412498, 0, 20.0679, 26.7572, -0.2593, 21.7402, 23.412498, 0, 20.0679, 26.7572, -0.2593, 23.412498, 23.412498, 0, 23.412498, 26.7572, -0.2593, 25.084799, 23.412498, 0, 23.412498, 26.7572, -0.2593, 23.412498, 26.7572, -0.2593, 26.7572, 23.412498, 0, 23.412498, 26.7572, -0.2593, 25.084799, 23.412498, 0, 23.412498, 26.7572, -0.2593, 26.7572, 23.412498, 0, 26.7572, -3.3446, 0, -23.412498, 0, 0, -26.7572, 0, 0, -23.412498, 0, 0, -23.412498, 0, 0, -26.7572, 3.3446, 0, -26.7572, -3.3446, 0, -23.412498, -3.3446, 0, -26.7572, 0, 0, -26.7572, 0, 0, -23.412498, 3.3446, 0, -26.7572, 3.3446, 0, -23.412498, 3.3446, 0, -23.412498, 3.3446, 0, -26.7572, 6.6893, 0, -26.7572, -3.3446, 0, -20.0679, -3.3446, 0, -23.412498, 0, 0, -23.412498, 3.3446, 0, -23.412498, 6.6893, 0, -26.7572, 6.6893, 0, -23.412498, 6.6893, 0, -23.412498, 6.6893, 0, -26.7572, 10.033899, 0, -26.7572, 0, 0, -20.0679, 0, 0, -23.412498, 3.3446, 0, -23.412498, -3.3446, 0, -20.0679, 0, 0, -23.412498, 0, 0, -20.0679, 3.3446, 0, -20.5992, 3.3446, 0, -23.412498, 6.6893, 0, -23.412498, 0, 0, -20.0679, 3.3446, 0, -23.412498, 3.3446, 0, -20.5992, 3.3446, 0, -20.5992, 6.6893, 0, -23.412498, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -26.7572, 10.033899, 0, -23.412498, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, 0, -26.7572, 13.3786, 0, -26.7572, 6.6893, 0, -20.1318, 10.033899, 0, -23.412498, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.3786, 0, -26.7572, 13.3786, 0, -23.412498, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.3786, 0, -23.412498, 13.3786, 0, -23.412498, 13.3786, 0, -26.7572, 16.7232, 0, -26.7572, 10.033899, 0, -20.467, 13.3786, 0, -23.412498, 13.3786, 0, -20.5992, 13.3786, 0, -23.412498, 16.7232, 0, -26.7572, 16.7232, 0, -23.412498, 13.3786, 0, -20.5992, 13.3786, 0, -23.412498, 16.7232, 0, -23.412498, 16.7232, 0, -23.412498, 16.7232, 0, -26.7572, 20.0679, 0, -26.7572, 13.3786, 0, -20.5992, 16.7232, 0, -23.412498, 16.7232, 0, -20.0679, 13.7014, 0, -17.416399, 13.3786, 0, -20.5992, 16.7232, 0, -20.0679, 13.7014, 0, -17.416399, 16.7232, 0, -20.0679, 17.2285, 0, -16.8036, 16.7232, 0, -23.412498, 20.0679, 0, -26.7572, 20.0679, 0, -23.412498, 16.7232, 0, -20.0679, 16.7232, 0, -23.412498, 20.0679, 0, -23.412498, 20.0679, 0, -23.412498, 20.0679, 0, -26.7572, 23.412498, 0, -26.7572, 20.0679, 0, -23.412498, 23.412498, 0, -26.7572, 23.412498, 0, -23.412498, 16.7232, 0, -20.0679, 20.0679, 0, -23.412498, 20.0679, 0, -20.0679, 20.0679, 0, -20.0679, 20.0679, 0, -23.412498, 23.412498, 0, -23.412498, 17.2285, 0, -16.8036, 16.7232, 0, -20.0679, 20.0679, 0, -20.0679, 20.0679, 0, -20.0679, 23.412498, 0, -23.412498, 23.412498, 0, -20.0679, 17.2285, 0, -16.8036, 20.0679, 0, -20.0679, 20.0679, 0, -16.7232, 20.0679, 0, -16.7232, 20.0679, 0, -20.0679, 23.412498, 0, -20.0679, 18.0241, 0, -14.3019, 17.2285, 0, -16.8036, 20.0679, 0, -16.7232, 20.0679, 0, -16.7232, 23.412498, 0, -20.0679, 23.412498, 0, -16.7232, 18.0241, 0, -14.3019, 20.0679, 0, -16.7232, 21.1831, 0, -13.6544, 21.1831, 0, -13.6544, 20.0679, 0, -16.7232, 23.412498, 0, -16.7232, 21.1831, 0, -13.6544, 23.412498, 0, -16.7232, 23.412498, 0, -13.3786, 21.8888, 0, -10.033899, 21.1831, 0, -13.6544, 23.412498, 0, -13.3786, 21.8888, 0, -10.033899, 23.412498, 0, -13.3786, 23.412498, 0, -10.033899, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 23.412498, 0, -10.033899, 21.2349, 0, -6.6893, 23.412498, 0, -10.033899, 23.412498, 0, -6.6893, 20.961199, 0, -3.3446, 21.2349, 0, -6.6893, 23.412498, 0, -6.6893, 20.961199, 0, -3.3446, 23.412498, 0, -6.6893, 23.412498, 0, -3.3446, 20.0679, 0, 0, 20.961199, 0, -3.3446, 23.412498, 0, -3.3446, 20.0679, 0, 0, 23.412498, 0, -3.3446, 23.412498, 0, 0, 20.0679, 0, 3.3446, 20.0679, 0, 0, 23.412498, 0, 0, 20.0679, 0, 3.3446, 23.412498, 0, 0, 23.412498, 0, 3.3446, 10.572599, 0, 16.7232, 13.8046, 0, 13.918799, 13.3786, 0, 16.7232, 10.572599, 0, 16.7232, 10.572599, 0, 13.918799, 13.8046, 0, 13.918799, 10.572599, 0, 20.0679, 10.572599, 0, 16.7232, 13.3786, 0, 16.7232, 13.3786, 0, 16.7232, 13.8046, 0, 13.918799, 15.0508995, 0, 13.3786, 10.572599, 0, 20.0679, 13.3786, 0, 16.7232, 13.3786, 0, 20.0679, 13.8046, 0, 13.918799, 15.0508995, 0, 10.033899, 15.0508995, 0, 13.3786, 13.8046, 0, 13.918799, 13.8046, 0, 10.033899, 15.0508995, 0, 10.033899, 13.8046, 0, 10.033899, 15.0508995, 0, 6.6893, 15.0508995, 0, 10.033899, 13.8046, 0, 10.033899, 13.8046, 0, 6.6893, 15.0508995, 0, 6.6893, 15.0508995, 0, 10.033899, 15.0508995, 0, 6.6893, 16.7232, 0, 6.6893, 15.0508995, 0, 10.033899, 16.7232, 0, 6.6893, 16.7232, 0, 10.033899, 15.0508995, 0, 13.3786, 15.0508995, 0, 10.033899, 16.7232, 0, 10.033899, 13.3786, 0, 16.7232, 15.0508995, 0, 13.3786, 15.0508995, 0, 16.7232, 13.3786, 0, 20.0679, 13.3786, 0, 16.7232, 15.0508995, 0, 16.7232, 15.0508995, 0, 13.3786, 16.7232, 0, 10.033899, 16.7232, 0, 13.3786, 15.0508995, 0, 16.7232, 15.0508995, 0, 13.3786, 16.7232, 0, 13.3786, 16.7232, 0, 13.3786, 16.7232, 0, 10.033899, 20.0679, 0, 10.033899, 16.7232, 0, 13.3786, 20.0679, 0, 10.033899, 20.0679, 0, 13.3786, 20.0679, 0, 13.3786, 20.0679, 0, 10.033899, 23.412498, 0, 10.033899, 20.0679, 0, 13.3786, 23.412498, 0, 10.033899, 23.412498, 0, 13.3786, 16.7232, 0, 16.7232, 16.7232, 0, 13.3786, 20.0679, 0, 13.3786, 15.0508995, 0, 16.7232, 16.7232, 0, 13.3786, 16.7232, 0, 16.7232, 20.0679, 0, 16.7232, 20.0679, 0, 13.3786, 23.412498, 0, 13.3786, 16.7232, 0, 16.7232, 20.0679, 0, 13.3786, 20.0679, 0, 16.7232, 20.0679, 0, 16.7232, 23.412498, 0, 13.3786, 23.412498, 0, 16.7232, 15.0508995, 0, 20.0679, 15.0508995, 0, 16.7232, 16.7232, 0, 16.7232, 13.3786, 0, 20.0679, 15.0508995, 0, 16.7232, 15.0508995, 0, 20.0679, 16.7232, 0, 20.0679, 16.7232, 0, 16.7232, 20.0679, 0, 16.7232, 15.0508995, 0, 20.0679, 16.7232, 0, 16.7232, 16.7232, 0, 20.0679, 20.0679, 0, 20.0679, 20.0679, 0, 16.7232, 23.412498, 0, 16.7232, 16.7232, 0, 20.0679, 20.0679, 0, 16.7232, 20.0679, 0, 20.0679, 20.0679, 0, 20.0679, 23.412498, 0, 16.7232, 23.412498, 0, 20.0679, 20.0679, 0, 23.412498, 20.0679, 0, 20.0679, 23.412498, 0, 20.0679, 20.0679, 0, 23.412498, 23.412498, 0, 20.0679, 23.412498, 0, 23.412498, 20.0679, 0, 26.7572, 20.0679, 0, 23.412498, 23.412498, 0, 23.412498, 20.0679, 0, 26.7572, 23.412498, 0, 23.412498, 23.412498, 0, 26.7572, 16.7232, 0, 26.7572, 20.0679, 0, 23.412498, 20.0679, 0, 26.7572, 16.7232, 0, 23.412498, 20.0679, 0, 20.0679, 20.0679, 0, 23.412498, 16.7232, 0, 26.7572, 16.7232, 0, 23.412498, 20.0679, 0, 23.412498, 16.7232, 0, 23.412498, 16.7232, 0, 20.0679, 20.0679, 0, 20.0679, 15.0508995, 0, 26.7572, 16.7232, 0, 23.412498, 16.7232, 0, 26.7572, 15.0508995, 0, 23.412498, 16.7232, 0, 20.0679, 16.7232, 0, 23.412498, 15.0508995, 0, 26.7572, 15.0508995, 0, 23.412498, 16.7232, 0, 23.412498, 15.0508995, 0, 23.412498, 15.0508995, 0, 20.0679, 16.7232, 0, 20.0679, 13.3786, 0, 26.7572, 15.0508995, 0, 23.412498, 15.0508995, 0, 26.7572, 13.3786, 0, 23.412498, 15.0508995, 0, 20.0679, 15.0508995, 0, 23.412498, 13.3786, 0, 26.7572, 13.3786, 0, 23.412498, 15.0508995, 0, 23.412498, 13.3786, 0, 23.412498, 13.3786, 0, 20.0679, 15.0508995, 0, 20.0679, 10.033899, 0, 26.7572, 13.3786, 0, 23.412498, 13.3786, 0, 26.7572, 10.033899, 0, 23.412498, 13.3786, 0, 20.0679, 13.3786, 0, 23.412498, 10.033899, 0, 26.7572, 10.033899, 0, 23.412498, 13.3786, 0, 23.412498, 10.033899, 0, 23.412498, 10.572599, 0, 20.0679, 13.3786, 0, 20.0679, 6.6893, 0, 26.7572, 10.033899, 0, 23.412498, 10.033899, 0, 26.7572, 6.6893, 0, 23.412498, 10.572599, 0, 20.0679, 10.033899, 0, 23.412498, 6.6893, 0, 26.7572, 6.6893, 0, 23.412498, 10.033899, 0, 23.412498, 6.6893, 0, 23.412498, 6.6893, 0, 20.0679, 10.572599, 0, 20.0679, 3.3446, 0, 26.7572, 6.6893, 0, 23.412498, 6.6893, 0, 26.7572, 3.3446, 0, 23.412498, 6.6893, 0, 20.0679, 6.6893, 0, 23.412498, 3.3446, 0, 26.7572, 3.3446, 0, 23.412498, 6.6893, 0, 23.412498, 3.3446, 0, 23.412498, 3.3446, 0, 20.0679, 6.6893, 0, 20.0679, 0, 0, 26.7572, 3.3446, 0, 23.412498, 3.3446, 0, 26.7572, 0, 0, 26.7572, 0, 0, 23.412498, 3.3446, 0, 23.412498, 0, 0, 23.412498, 3.3446, 0, 20.0679, 3.3446, 0, 23.412498, -3.3446, 0, 26.7572, 0, 0, 23.412498, 0, 0, 26.7572, 0, 0, 23.412498, 0, 0, 20.0679, 3.3446, 0, 20.0679, 0, 0, 20.0679, 3.3446, 0, 16.7232, 3.3446, 0, 20.0679, 0, 0, 20.0679, 0, 0, 16.7232, 3.3446, 0, 16.7232, -3.3446, 0, 26.7572, -3.3446, 0, 23.412498, 0, 0, 23.412498, -3.3446, 0, 23.412498, 0, 0, 20.0679, 0, 0, 23.412498, -6.6893, 0, 26.7572, -3.3446, 0, 23.412498, -3.3446, 0, 26.7572, -3.3446, 0, 20.0679, 0, 0, 16.7232, 0, 0, 20.0679, -3.3446, 0, 23.412498, -3.3446, 0, 20.0679, 0, 0, 20.0679, -3.3446, 0, 20.0679, -3.3446, 0, 16.7232, 0, 0, 16.7232, -6.6893, 0, 26.7572, -6.6893, 0, 23.412498, -3.3446, 0, 23.412498, -6.6893, 0, 23.412498, -3.3446, 0, 20.0679, -3.3446, 0, 23.412498, -10.033899, 0, 26.7572, -6.6893, 0, 23.412498, -6.6893, 0, 26.7572, -6.6893, 0, 20.0679, -3.3446, 0, 16.7232, -3.3446, 0, 20.0679, -6.6893, 0, 23.412498, -6.6893, 0, 20.0679, -3.3446, 0, 20.0679, -6.6893, 0, 20.0679, -6.6893, 0, 16.7232, -3.3446, 0, 16.7232, -10.033899, 0, 26.7572, -10.033899, 0, 23.412498, -6.6893, 0, 23.412498, -10.033899, 0, 23.412498, -6.6893, 0, 20.0679, -6.6893, 0, 23.412498, -13.3786, 0, 26.7572, -10.033899, 0, 23.412498, -10.033899, 0, 26.7572, -10.033899, 0, 20.0679, -6.6893, 0, 16.7232, -6.6893, 0, 20.0679, -10.033899, 0, 23.412498, -10.033899, 0, 20.0679, -6.6893, 0, 20.0679, -13.3786, 0, 26.7572, -13.3786, 0, 23.412498, -10.033899, 0, 23.412498, -13.3786, 0, 23.412498, -10.033899, 0, 20.0679, -10.033899, 0, 23.412498, -16.7232, 0, 26.7572, -13.3786, 0, 23.412498, -13.3786, 0, 26.7572, -10.033899, 0, 20.0679, -10.033899, 0, 16.7232, -6.6893, 0, 16.7232, -10.033899, 0, 16.7232, -6.6893, 0, 13.3786, -6.6893, 0, 16.7232, -10.033899, 0, 16.7232, -10.033899, 0, 13.3786, -6.6893, 0, 13.3786, -13.3786, 0, 20.0679, -10.033899, 0, 16.7232, -10.033899, 0, 20.0679, -13.3786, 0, 23.412498, -13.3786, 0, 20.0679, -10.033899, 0, 20.0679, -13.3786, 0, 16.7232, -10.033899, 0, 13.3786, -10.033899, 0, 16.7232, -13.3786, 0, 20.0679, -13.3786, 0, 16.7232, -10.033899, 0, 16.7232, -13.3786, 0, 16.7232, -13.3786, 0, 13.3786, -10.033899, 0, 13.3786, -16.7232, 0, 26.7572, -16.7232, 0, 23.412498, -13.3786, 0, 23.412498, -16.7232, 0, 23.412498, -13.3786, 0, 20.0679, -13.3786, 0, 23.412498, -20.0679, 0, 26.7572, -16.7232, 0, 23.412498, -16.7232, 0, 26.7572, -16.7232, 0, 20.0679, -13.3786, 0, 16.7232, -13.3786, 0, 20.0679, -16.7232, 0, 23.412498, -16.7232, 0, 20.0679, -13.3786, 0, 20.0679, -16.7232, 0, 16.7232, -13.3786, 0, 13.3786, -13.3786, 0, 16.7232, -16.7232, 0, 20.0679, -16.7232, 0, 16.7232, -13.3786, 0, 16.7232, -16.7232, 0, 16.7232, -16.7232, 0, 13.3786, -13.3786, 0, 13.3786, -20.0679, 0, 26.7572, -20.0679, 0, 23.412498, -16.7232, 0, 23.412498, -20.0679, 0, 23.412498, -16.7232, 0, 20.0679, -16.7232, 0, 23.412498, -23.412498, 0, 26.7572, -20.0679, 0, 23.412498, -20.0679, 0, 26.7572, -20.0679, 0, 20.0679, -16.7232, 0, 16.7232, -16.7232, 0, 20.0679, -20.0679, 0, 23.412498, -20.0679, 0, 20.0679, -16.7232, 0, 20.0679, -23.412498, 0, 26.7572, -23.412498, 0, 23.412498, -20.0679, 0, 23.412498, -23.412498, 0, 23.412498, -20.0679, 0, 20.0679, -20.0679, 0, 23.412498, -26.7572, 0, 26.7572, -23.412498, 0, 23.412498, -23.412498, 0, 26.7572, -26.7572, 0, 26.7572, -26.7572, 0, 23.412498, -23.412498, 0, 23.412498, -26.7572, 0, 23.412498, -23.412498, 0, 20.0679, -23.412498, 0, 23.412498, -23.412498, 0, 23.412498, -23.412498, 0, 20.0679, -20.0679, 0, 20.0679, -26.7572, 0, 23.412498, -26.7572, 0, 20.0679, -23.412498, 0, 20.0679, -20.0679, 0, 20.0679, -20.0679, 0, 16.7232, -16.7232, 0, 16.7232, -23.412498, 0, 20.0679, -20.0679, 0, 16.7232, -20.0679, 0, 20.0679, -20.0679, 0, 16.7232, -16.7232, 0, 13.3786, -16.7232, 0, 16.7232, -26.7572, 0, 20.0679, -23.412498, 0, 16.7232, -23.412498, 0, 20.0679, -23.412498, 0, 20.0679, -23.412498, 0, 16.7232, -20.0679, 0, 16.7232, -26.7572, 0, 20.0679, -26.7572, 0, 16.7232, -23.412498, 0, 16.7232, -20.0679, 0, 16.7232, -20.0679, 0, 13.3786, -16.7232, 0, 13.3786, -23.412498, 0, 16.7232, -20.0679, 0, 13.3786, -20.0679, 0, 16.7232, -20.0679, 0, 13.3786, -16.7232, 0, 10.033899, -16.7232, 0, 13.3786, -20.0679, 0, 13.3786, -20.0679, 0, 10.033899, -16.7232, 0, 10.033899, -23.412498, 0, 16.7232, -23.412498, 0, 13.3786, -20.0679, 0, 13.3786, -23.412498, 0, 13.3786, -20.0679, 0, 10.033899, -20.0679, 0, 13.3786, -26.7572, 0, 16.7232, -23.412498, 0, 13.3786, -23.412498, 0, 16.7232, -23.412498, 0, 13.3786, -23.412498, 0, 10.033899, -20.0679, 0, 10.033899, -26.7572, 0, 16.7232, -26.7572, 0, 13.3786, -23.412498, 0, 13.3786, -26.7572, 0, 13.3786, -23.412498, 0, 10.033899, -23.412498, 0, 13.3786, -26.7572, 0, 13.3786, -26.7572, 0, 10.033899, -23.412498, 0, 10.033899, -26.7572, 0, 10.033899, -23.8078, 0, 6.6893, -23.412498, 0, 10.033899, -26.7572, 0, 10.033899, -26.7572, 0, 6.6893, -23.8078, 0, 6.6893, -26.7572, 0, 6.6893, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -26.7572, 0, 6.6893, -26.7572, 0, 3.3446, -23.412498, 0, 3.3446, -16.7232, 0, -23.412498, -13.3786, 0, -26.7572, -13.3786, 0, -23.412498, -13.3786, 0, -23.412498, -13.3786, 0, -26.7572, -10.033899, 0, -26.7572, -16.7232, 0, -23.412498, -16.7232, 0, -26.7572, -13.3786, 0, -26.7572, -13.3786, 0, -23.412498, -10.033899, 0, -26.7572, -10.033899, 0, -23.412498, -10.033899, 0, -23.412498, -10.033899, 0, -26.7572, -6.6893, 0, -26.7572, -16.7232, 0, -20.0679, -16.7232, 0, -23.412498, -13.3786, 0, -23.412498, -10.033899, 0, -23.412498, -6.6893, 0, -26.7572, -6.6893, 0, -23.412498, -6.6893, 0, -23.412498, -6.6893, 0, -26.7572, -3.3446, 0, -26.7572, -6.6893, 0, -23.412498, -3.3446, 0, -26.7572, -3.3446, 0, -23.412498, -13.3786, 0, -20.0679, -13.3786, 0, -23.412498, -10.033899, 0, -23.412498, -16.7232, 0, -20.0679, -13.3786, 0, -23.412498, -13.3786, 0, -20.0679, -10.033899, 0, -20.0679, -10.033899, 0, -23.412498, -6.6893, 0, -23.412498, -13.3786, 0, -20.0679, -10.033899, 0, -23.412498, -10.033899, 0, -20.0679, -6.6893, 0, -20.0679, -6.6893, 0, -23.412498, -3.3446, 0, -23.412498, -10.033899, 0, -20.0679, -6.6893, 0, -23.412498, -6.6893, 0, -20.0679, -6.6893, 0, -20.0679, -3.3446, 0, -23.412498, -3.3446, 0, -20.0679, -13.3786, 0, -17.3364, -13.3786, 0, -20.0679, -10.033899, 0, -20.0679, -13.3786, 0, -17.3364, -10.033899, 0, -20.0679, -10.033899, 0, -16.7232, -10.033899, 0, -16.7232, -10.033899, 0, -20.0679, -6.6893, 0, -20.0679, -16.7232, 0, -16.7232, -13.3786, 0, -20.0679, -13.3786, 0, -17.3364, -16.7232, 0, -16.7232, -16.7232, 0, -20.0679, -13.3786, 0, -20.0679, -10.033899, 0, -16.7232, -6.6893, 0, -20.0679, -6.6893, 0, -16.7232, -6.6893, 0, -16.7232, -6.6893, 0, -20.0679, -3.3446, 0, -20.0679, -9.6168995, 0, -14.127899, -10.033899, 0, -16.7232, -6.6893, 0, -16.7232, -9.6168995, 0, -14.127899, -6.6893, 0, -16.7232, -6.6893, 0, -14.2637, -6.6893, 0, -16.7232, -3.3446, 0, -20.0679, -3.3446, 0, -16.7232, -6.6893, 0, -14.2637, -6.6893, 0, -16.7232, -3.3446, 0, -16.7232, -3.3446, 0, -16.7232, -3.3446, 0, -20.0679, 0, 0, -20.0679, -6.6893, 0, -14.2637, -3.3446, 0, -16.7232, -3.3446, 0, -13.3786, -3.3446, 0, -16.7232, 0, 0, -20.0679, 0, 0, -16.7232, -3.3446, 0, -13.3786, -3.3446, 0, -16.7232, 0, 0, -16.7232, 0, 0, -16.7232, 0, 0, -20.0679, 3.3446, 0, -20.5992, 0, 0, -16.7232, 3.3446, 0, -20.5992, 3.0140998, 0, -16.7232, -3.3446, 0, -13.3786, 0, 0, -16.7232, 0, 0, -13.3786, -2.6297, 0, -10.5745, -3.3446, 0, -13.3786, 0, 0, -13.3786, -2.6297, 0, -10.5745, 0, 0, -13.3786, 0, 0, -10.7654, -20.0679, 0, -16.7232, -16.7232, 0, -20.0679, -16.7232, 0, -16.7232, -20.0679, 0, -16.7232, -20.0679, 0, -20.0679, -16.7232, 0, -20.0679, -20.0679, 0, -20.0679, -16.7232, 0, -23.412498, -16.7232, 0, -20.0679, -20.0679, 0, -13.3786, -20.0679, 0, -16.7232, -16.7232, 0, -16.7232, -20.0679, 0, -13.3786, -16.7232, 0, -16.7232, -16.7232, 0, -13.3786, -23.412498, 0, -16.7232, -20.0679, 0, -20.0679, -20.0679, 0, -16.7232, -20.0679, 0, -10.033899, -20.0679, 0, -13.3786, -16.7232, 0, -13.3786, -20.0679, 0, -10.033899, -16.7232, 0, -13.3786, -17.275, 0, -10.5169, -23.412498, 0, -13.3786, -20.0679, 0, -16.7232, -20.0679, 0, -13.3786, -23.412498, 0, -13.3786, -23.412498, 0, -16.7232, -20.0679, 0, -16.7232, -23.412498, 0, -10.033899, -20.0679, 0, -13.3786, -20.0679, 0, -10.033899, -23.412498, 0, -10.033899, -23.412498, 0, -13.3786, -20.0679, 0, -13.3786, -26.7572, 0, -10.033899, -23.412498, 0, -13.3786, -23.412498, 0, -10.033899, -26.7572, 0, -10.033899, -26.7572, 0, -13.3786, -23.412498, 0, -13.3786, -26.7572, 0, -13.3786, -23.412498, 0, -16.7232, -23.412498, 0, -13.3786, -26.7572, 0, -6.6893, -26.7572, 0, -10.033899, -23.412498, 0, -10.033899, -26.7572, 0, -13.3786, -26.7572, 0, -16.7232, -23.412498, 0, -16.7232, -26.7572, 0, -6.6893, -23.412498, 0, -10.033899, -23.412498, 0, -6.6893, -23.412498, 0, -6.6893, -23.412498, 0, -10.033899, -20.0679, 0, -10.033899, -23.412498, 0, -6.6893, -20.0679, 0, -10.033899, -20.3764, 0, -6.6893, -26.7572, 0, -3.3446, -26.7572, 0, -6.6893, -23.412498, 0, -6.6893, -26.7572, 0, -3.3446, -23.412498, 0, -6.6893, -23.412498, 0, -3.3446, -26.7572, 0, -16.7232, -23.412498, 0, -20.0679, -23.412498, 0, -16.7232, -23.412498, 0, -16.7232, -23.412498, 0, -20.0679, -20.0679, 0, -20.0679, -26.7572, 0, -16.7232, -26.7572, 0, -20.0679, -23.412498, 0, -20.0679, -23.412498, 0, -20.0679, -20.0679, 0, -23.412498, -20.0679, 0, -20.0679, -20.0679, 0, -20.0679, -20.0679, 0, -23.412498, -16.7232, 0, -23.412498, -20.0679, 0, -23.412498, -16.7232, 0, -26.7572, -16.7232, 0, -23.412498, -20.0679, 0, -23.412498, -20.0679, 0, -26.7572, -16.7232, 0, -26.7572, -23.412498, 0, -20.0679, -23.412498, 0, -23.412498, -20.0679, 0, -23.412498, -23.412498, 0, -23.412498, -20.0679, 0, -26.7572, -20.0679, 0, -23.412498, -26.7572, 0, -20.0679, -23.412498, 0, -23.412498, -23.412498, 0, -20.0679, -23.412498, 0, -23.412498, -23.412498, 0, -26.7572, -20.0679, 0, -26.7572, -26.7572, 0, -20.0679, -26.7572, 0, -23.412498, -23.412498, 0, -23.412498, -26.7572, 0, -23.412498, -23.412498, 0, -26.7572, -23.412498, 0, -23.412498, -26.7572, 0, -23.412498, -26.7572, 0, -26.7572, -23.412498, 0, -26.7572, -23.412498, 0, -3.3446, -20.3764, 0, -6.6893, -20.0679, -0.3484, -3.5669, -23.412498, 0, -3.3446, -23.412498, 0, -6.6893, -20.3764, 0, -6.6893, 16.7232, 0, 10.033899, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 10.033899, 16.7232, 0, 10.033899, 16.7232, 0, 6.6893, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 10.033899, 23.412498, 0, 6.6893, 23.412498, 0, 10.033899, 20.0679, 0, 10.033899, 20.0679, -0.124199994, 6.6893, 23.412498, 0, 6.6893, -26.7572, 0, 3.3446, -23.412498, -0.2913, 0.26119998, -23.412498, 0, 3.3446, -26.7572, 0, 3.3446, -26.7572, 0, 0, -23.412498, -0.2913, 0.26119998, -26.7572, 0, 0, -23.412498, 0, -3.3446, -23.412498, -0.2913, 0.26119998, -26.7572, 0, 0, -26.7572, 0, -3.3446, -23.412498, 0, -3.3446, 0, 0, -13.3786, 3.0140998, 0, -16.7232, 3.3446, -0.3989, -12.9443, 0, 0, -13.3786, 0, 0, -16.7232, 3.0140998, 0, -16.7232, 0, 0, -10.7654, 3.3446, -0.3989, -12.9443, 2.8241, 0, -10.4594, 0, 0, -10.7654, 0, 0, -13.3786, 3.3446, -0.3989, -12.9443, 20.0679, -0.124199994, 6.6893, 23.412498, 0, 3.3446, 23.412498, 0, 6.6893, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 3.3446, 23.412498, 0, 3.3446, 51.2184, -3.9413, -15.0508995, 54.072197, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 54.072197, -3.9413, -13.3786, 54.072197, -3.9413, -11.7063, 51.2184, -3.9413, -15.0508995, 54.072197, -3.9413, -15.0508995, 54.072197, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 54.072197, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 54.072197, -3.9413, -11.7063, 54.072197, -3.9413, -10.033899, 48.3645, -3.9413, -15.0508995, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 54.072197, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 54.072197, -3.9413, -10.033899, 54.072197, -3.9413, -8.3616, 48.3645, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 48.3645, -3.9413, -15.0508995, 51.2184, -3.9413, -13.3786, 48.3645, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 48.3645, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 54.072197, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 54.072197, -3.9413, -8.3616, 54.072197, -3.9413, -6.6893, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 51.2184, -3.9413, -8.3616, 45.5107, -3.9413, -13.3786, 48.3645, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 48.3645, -3.9413, -11.7063, 48.3645, -3.9413, -10.033899, 45.5107, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 51.2184, -3.9413, -8.3616, 54.072197, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 54.072197, -3.9413, -6.6893, 54.072197, -3.9413, -5.0169997, 48.3645, -3.9413, -10.033899, 51.2184, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 45.5107, -3.9413, -11.7063, 48.3645, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 54.072197, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 54.072197, -3.9413, -5.0169997, 54.072197, -3.9413, -3.3446, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 45.5107, -3.9413, -10.033899, 48.3645, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 54.072197, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 54.072197, -3.9413, -3.3446, 54.072197, -3.9413, -1.6723, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 45.5107, -3.9413, -8.3616, 48.3645, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 54.072197, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 54.072197, -3.9413, -1.6723, 54.072197, -3.9413, 0, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 45.5107, -3.9413, -6.6893, 48.3645, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 54.072197, -3.9413, 0, 51.2184, -3.9413, 0, 51.2184, -3.9413, 0, 54.072197, -3.9413, 0, 54.072197, -3.9413, 1.6723, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 51.2184, -3.9413, 0, 45.5107, -3.9413, -5.0169997, 48.3645, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, 0, 54.072197, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 54.072197, -3.9413, 1.6723, 54.072197, -3.9413, 3.3446, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, 0, 48.3645, -3.9413, 0, 48.3645, -3.9413, 0, 51.2184, -3.9413, 0, 51.2184, -3.9413, 1.6723, 45.5107, -3.9413, -3.3446, 48.3645, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 48.3645, -3.9413, 0, 51.2184, -3.9413, 1.6723, 54.072197, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 54.072197, -3.9413, 3.3446, 54.072197, -3.9413, 5.0169997, 48.3645, -3.9413, 0, 51.2184, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 45.5107, -3.9413, -1.6723, 48.3645, -3.9413, 0, 45.5107, -3.9413, 0, 45.5107, -3.9413, 0, 48.3645, -3.9413, 0, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 54.072197, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 54.072197, -3.9413, 5.0169997, 54.072197, -3.9413, 6.6893, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 45.5107, -3.9413, 0, 48.3645, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 54.072197, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 54.072197, -3.9413, 6.6893, 54.072197, -3.9413, 8.3616, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 45.5107, -3.9413, 1.6723, 48.3645, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 54.072197, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 54.072197, -3.9413, 8.3616, 54.072197, -3.9413, 10.033899, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 45.5107, -3.9413, 3.3446, 48.3645, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 54.072197, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 54.072197, -3.9413, 10.033899, 54.072197, -3.9413, 11.7063, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 45.5107, -3.9413, 5.0169997, 48.3645, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 54.072197, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 54.072197, -3.9413, 11.7063, 54.072197, -3.9413, 13.3786, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 45.5107, -3.9413, 6.6893, 48.3645, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 54.072197, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 54.072197, -3.9413, 13.3786, 54.072197, -3.9413, 15.0508995, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 45.5107, -3.9413, 8.3616, 48.3645, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 54.072197, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 54.072197, -3.9413, 15.0508995, 54.072197, -3.9413, 16.7232, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 45.5107, -3.9413, 10.033899, 48.3645, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 54.072197, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 54.072197, -3.9413, 16.7232, 54.072197, -3.9413, 18.3955, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 45.5107, -3.9413, 11.7063, 48.3645, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 54.072197, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 54.072197, -3.9413, 18.3955, 54.072197, -3.9413, 20.0679, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 45.5107, -3.9413, 13.3786, 48.3645, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 54.072197, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 54.072197, -3.9413, 20.0679, 54.072197, -3.9413, 21.7402, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 45.5107, -3.9413, 15.0508995, 48.3645, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 54.072197, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 54.072197, -3.9413, 21.7402, 54.072197, -3.9413, 23.412498, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 45.5107, -3.9413, 16.7232, 48.3645, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 54.072197, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 54.072197, -3.9413, 23.412498, 54.072197, -3.9413, 25.084799, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 45.5107, -3.9413, 18.3955, 48.3645, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 54.072197, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 54.072197, -3.9413, 25.084799, 54.072197, -3.9413, 26.7572, 51.2184, -3.9413, 25.084799, 54.072197, -3.9413, 26.7572, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 51.2184, -3.9413, 25.084799, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 48.3645, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 23.412498, 51.2184, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 48.3645, -3.9413, 23.412498, 45.5107, -3.9413, 20.0679, 48.3645, -3.9413, 21.7402, 45.5107, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 48.3645, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 45.5107, -3.9413, 21.7402, 48.3645, -3.9413, 23.412498, 45.5107, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 25.084799, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 25.084799, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 26.7572, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 25.084799, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 42.6568, -3.9413, 25.084799, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 23.412498, 42.6568, -3.9413, 25.084799, 42.6568, -3.9413, 23.412498, 45.5107, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 23.412498, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 42.6568, -3.9413, 23.412498, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 21.7402, 42.6568, -3.9413, 23.412498, 42.6568, -3.9413, 21.7402, 45.5107, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 21.7402, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 20.0679, 45.5107, -3.9413, 21.7402, 42.6568, -3.9413, 21.7402, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 20.0679, 42.6568, -3.9413, 21.7402, 42.6568, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 45.5107, -3.9413, 21.7402, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 20.0679, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 18.3955, 45.5107, -3.9413, 20.0679, 42.6568, -3.9413, 20.0679, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 18.3955, 42.6568, -3.9413, 20.0679, 42.6568, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 45.5107, -3.9413, 20.0679, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 18.3955, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 16.7232, 45.5107, -3.9413, 18.3955, 42.6568, -3.9413, 18.3955, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 16.7232, 42.6568, -3.9413, 18.3955, 42.6568, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 45.5107, -3.9413, 18.3955, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 16.7232, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 15.0508995, 45.5107, -3.9413, 16.7232, 42.6568, -3.9413, 16.7232, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 15.0508995, 42.6568, -3.9413, 16.7232, 42.6568, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 45.5107, -3.9413, 16.7232, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 15.0508995, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 13.3786, 45.5107, -3.9413, 15.0508995, 42.6568, -3.9413, 15.0508995, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 13.3786, 42.6568, -3.9413, 15.0508995, 42.6568, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 45.5107, -3.9413, 15.0508995, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 13.3786, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 11.7063, 45.5107, -3.9413, 13.3786, 42.6568, -3.9413, 13.3786, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 11.7063, 42.6568, -3.9413, 13.3786, 42.6568, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 45.5107, -3.9413, 13.3786, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 11.7063, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 10.033899, 45.5107, -3.9413, 11.7063, 42.6568, -3.9413, 11.7063, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 10.033899, 42.6568, -3.9413, 11.7063, 42.6568, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 45.5107, -3.9413, 11.7063, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 10.033899, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 8.3616, 45.5107, -3.9413, 10.033899, 42.6568, -3.9413, 10.033899, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 8.3616, 42.6568, -3.9413, 10.033899, 42.6568, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 45.5107, -3.9413, 10.033899, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 8.3616, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 6.6893, 45.5107, -3.9413, 8.3616, 42.6568, -3.9413, 8.3616, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 6.6893, 42.6568, -3.9413, 8.3616, 42.6568, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 45.5107, -3.9413, 8.3616, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 6.6893, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 5.0169997, 45.5107, -3.9413, 6.6893, 42.6568, -3.9413, 6.6893, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 5.0169997, 42.6568, -3.9413, 6.6893, 42.6568, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 45.5107, -3.9413, 6.6893, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 5.0169997, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 3.3446, 45.5107, -3.9413, 5.0169997, 42.6568, -3.9413, 5.0169997, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 3.3446, 42.6568, -3.9413, 5.0169997, 42.6568, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 45.5107, -3.9413, 5.0169997, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 3.3446, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 1.6723, 45.5107, -3.9413, 3.3446, 42.6568, -3.9413, 3.3446, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 1.6723, 42.6568, -3.9413, 3.3446, 42.6568, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 45.5107, -3.9413, 3.3446, 39.7973, -3.9413, 0, 42.6568, -3.9413, 1.6723, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 0, 45.5107, -3.9413, 1.6723, 42.6568, -3.9413, 1.6723, 39.7973, -3.9413, 0, 42.6568, -3.9413, 0, 42.6568, -3.9413, 1.6723, 42.6568, -3.9413, 0, 45.5107, -3.9413, 0, 45.5107, -3.9413, 1.6723, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, 0, 39.7973, -3.9413, 0, 42.6568, -3.9413, -1.6723, 45.5107, -3.9413, 0, 42.6568, -3.9413, 0, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, -1.6723, 42.6568, -3.9413, 0, 42.6568, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 45.5107, -3.9413, 0, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -1.6723, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, -3.3446, 45.5107, -3.9413, -1.6723, 42.6568, -3.9413, -1.6723, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -3.3446, 42.6568, -3.9413, -1.6723, 42.6568, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 45.5107, -3.9413, -1.6723, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -3.3446, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -5.0169997, 45.5107, -3.9413, -3.3446, 42.6568, -3.9413, -3.3446, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -5.0169997, 42.6568, -3.9413, -3.3446, 42.6568, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 45.5107, -3.9413, -3.3446, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -5.0169997, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -6.6893, 45.5107, -3.9413, -5.0169997, 42.6568, -3.9413, -5.0169997, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -6.6893, 42.6568, -3.9413, -5.0169997, 42.6568, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 45.5107, -3.9413, -5.0169997, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -6.6893, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -8.3616, 45.5107, -3.9413, -6.6893, 42.6568, -3.9413, -6.6893, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -8.3616, 42.6568, -3.9413, -6.6893, 42.6568, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 45.5107, -3.9413, -6.6893, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -8.3616, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -10.033899, 45.5107, -3.9413, -8.3616, 42.6568, -3.9413, -8.3616, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -10.033899, 42.6568, -3.9413, -8.3616, 42.6568, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 45.5107, -3.9413, -8.3616, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -10.033899, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -11.7063, 45.5107, -3.9413, -10.033899, 42.6568, -3.9413, -10.033899, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -11.7063, 42.6568, -3.9413, -10.033899, 42.6568, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 45.5107, -3.9413, -10.033899, 39.7973, -3.9413, -13.3786, 42.6568, -3.9413, -11.7063, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -13.3786, 45.5107, -3.9413, -11.7063, 42.6568, -3.9413, -11.7063, 39.7973, -3.9413, -13.3786, 42.6568, -3.9413, -13.3786, 42.6568, -3.9413, -11.7063, 42.6568, -3.9413, -13.3786, 45.5107, -3.9413, -13.3786, 45.5107, -3.9413, -11.7063, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -13.3786, 39.7973, -3.9413, -13.3786, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 42.6568, -3.9413, -13.3786, 42.6568, -3.9413, -15.0508995, 45.5107, -3.9413, -13.3786, 42.6568, -3.9413, -13.3786, 39.7973, -3.9413, -16.7232, 42.6568, -3.9413, -15.0508995, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 45.5107, -3.9413, -15.0508995, 45.5107, -3.9413, -13.3786, 45.5107, -3.9413, -15.0508995, 48.3645, -3.9413, -13.3786, 45.5107, -3.9413, -13.3786, 39.7973, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 42.6568, -3.9413, -15.0508995, 42.6568, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 39.7973, -3.9413, -18.3955, 42.6568, -3.9413, -16.7232, 39.7973, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 48.3645, -3.9413, -15.0508995, 48.3645, -3.9413, -13.3786, 39.7973, -3.9413, -18.3955, 42.6568, -3.9413, -18.3955, 42.6568, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 45.5107, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 45.5107, -3.9413, -16.7232, 48.3645, -3.9413, -15.0508995, 45.5107, -3.9413, -15.0508995, 42.6568, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 39.7973, -3.9413, -20.0679, 42.6568, -3.9413, -18.3955, 39.7973, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 48.3645, -3.9413, -16.7232, 48.3645, -3.9413, -15.0508995, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -15.0508995, 48.3645, -3.9413, -15.0508995, 42.6568, -3.9413, -18.3955, 45.5107, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -16.7232, 45.5107, -3.9413, -16.7232, 39.7973, -3.9413, -20.0679, 42.6568, -3.9413, -20.0679, 42.6568, -3.9413, -18.3955, 42.6568, -3.9413, -20.0679, 45.5107, -3.9413, -18.3955, 42.6568, -3.9413, -18.3955, 39.7973, -3.9413, -21.7402, 42.6568, -3.9413, -20.0679, 39.7973, -3.9413, -20.0679, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -16.7232, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -16.7232, 54.072197, -3.9413, -15.0508995, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -16.7232, 54.072197, -3.9413, -16.7232, 54.072197, -3.9413, -15.0508995, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -16.7232, 48.3645, -3.9413, -16.7232, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -18.3955, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -18.3955, 54.072197, -3.9413, -16.7232, 51.2184, -3.9413, -16.7232, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -18.3955, 51.2184, -3.9413, -16.7232, 51.2184, -3.9413, -18.3955, 54.072197, -3.9413, -18.3955, 54.072197, -3.9413, -16.7232, 45.5107, -3.9413, -20.0679, 48.3645, -3.9413, -18.3955, 45.5107, -3.9413, -18.3955, 42.6568, -3.9413, -20.0679, 45.5107, -3.9413, -20.0679, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -20.0679, 51.2184, -3.9413, -18.3955, 48.3645, -3.9413, -18.3955, 45.5107, -3.9413, -20.0679, 48.3645, -3.9413, -20.0679, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -20.0679, 54.072197, -3.9413, -18.3955, 51.2184, -3.9413, -18.3955, 48.3645, -3.9413, -20.0679, 51.2184, -3.9413, -20.0679, 51.2184, -3.9413, -18.3955, 51.2184, -3.9413, -20.0679, 54.072197, -3.9413, -20.0679, 54.072197, -3.9413, -18.3955, 39.7973, -3.9413, -21.7402, 42.6568, -3.9413, -21.7402, 42.6568, -3.9413, -20.0679, 42.6568, -3.9413, -21.7402, 45.5107, -3.9413, -20.0679, 42.6568, -3.9413, -20.0679, 39.7973, -3.9413, -23.412498, 42.6568, -3.9413, -21.7402, 39.7973, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 48.3645, -3.9413, -20.0679, 45.5107, -3.9413, -20.0679, 42.6568, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 45.5107, -3.9413, -20.0679, 39.7973, -3.9413, -23.412498, 42.6568, -3.9413, -23.412498, 42.6568, -3.9413, -21.7402, 42.6568, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 42.6568, -3.9413, -21.7402, 39.7973, -3.9413, -25.084799, 42.6568, -3.9413, -23.412498, 39.7973, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 48.3645, -3.9413, -21.7402, 48.3645, -3.9413, -20.0679, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -20.0679, 48.3645, -3.9413, -20.0679, 42.6568, -3.9413, -23.412498, 45.5107, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 45.5107, -3.9413, -23.412498, 48.3645, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 39.7973, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -23.412498, 42.6568, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 42.6568, -3.9413, -23.412498, 39.7973, -3.9413, -26.7572, 42.6568, -3.9413, -25.084799, 39.7973, -3.9413, -25.084799, 39.7973, -3.9413, -26.7572, 42.6568, -3.9413, -26.7572, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -26.7572, 45.5107, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 42.6568, -3.9413, -26.7572, 45.5107, -3.9413, -26.7572, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 48.3645, -3.9413, -23.412498, 48.3645, -3.9413, -21.7402, 45.5107, -3.9413, -25.084799, 48.3645, -3.9413, -23.412498, 45.5107, -3.9413, -23.412498, 45.5107, -3.9413, -26.7572, 48.3645, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -23.412498, 45.5107, -3.9413, -26.7572, 48.3645, -3.9413, -26.7572, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 51.2184, -3.9413, -20.0679, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -21.7402, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 54.072197, -3.9413, -20.0679, 51.2184, -3.9413, -20.0679, 51.2184, -3.9413, -21.7402, 54.072197, -3.9413, -21.7402, 54.072197, -3.9413, -20.0679, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 51.2184, -3.9413, -21.7402, 51.2184, -3.9413, -23.412498, 54.072197, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 48.3645, -3.9413, -25.084799, 51.2184, -3.9413, -23.412498, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 54.072197, -3.9413, -23.412498, 54.072197, -3.9413, -21.7402, 48.3645, -3.9413, -26.7572, 51.2184, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -23.412498, 51.2184, -3.9413, -25.084799, 54.072197, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 48.3645, -3.9413, -26.7572, 51.2184, -3.9413, -26.7572, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 54.072197, -3.9413, -25.084799, 54.072197, -3.9413, -23.412498, 51.2184, -3.9413, -26.7572, 54.072197, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -26.7572, 54.072197, -3.9413, -26.7572, 54.072197, -3.9413, -25.084799, 29.679499, -0.6044, -15.0508995, 32.4649, -1.1213, -13.3786, 29.679499, -0.6044, -13.3786, 29.679499, -0.6044, -13.3786, 32.4649, -1.1213, -13.3786, 32.4649, -1.1213, -11.7063, 29.679499, -0.6044, -15.0508995, 32.4649, -1.1213, -15.0508995, 32.4649, -1.1213, -13.3786, 29.679499, -0.6044, -13.3786, 32.4649, -1.1213, -11.7063, 29.679499, -0.6044, -11.7063, 29.679499, -0.6044, -16.7232, 32.4649, -1.1213, -15.0508995, 29.679499, -0.6044, -15.0508995, 29.679499, -0.6044, -11.7063, 32.4649, -1.1213, -11.7063, 32.4649, -1.1213, -10.033899, 29.679499, -0.6044, -16.7232, 32.4649, -1.1213, -16.7232, 32.4649, -1.1213, -15.0508995, 29.679499, -0.6044, -11.7063, 32.4649, -1.1213, -10.033899, 29.679499, -0.6044, -10.033899, 29.679499, -0.6044, -18.3955, 32.4649, -1.1213, -16.7232, 29.679499, -0.6044, -16.7232, 29.679499, -0.6044, -10.033899, 32.4649, -1.1213, -10.033899, 32.4649, -1.1213, -8.3616, 29.679499, -0.6044, -18.3955, 32.4649, -1.1213, -18.3955, 32.4649, -1.1213, -16.7232, 29.679499, -0.6044, -10.033899, 32.4649, -1.1213, -8.3616, 29.679499, -0.6044, -8.3616, 29.679499, -0.6044, -20.0679, 32.4649, -1.1213, -18.3955, 29.679499, -0.6044, -18.3955, 29.679499, -0.6044, -8.3616, 32.4649, -1.1213, -8.3616, 32.4649, -1.1213, -6.6893, 29.679499, -0.6044, -20.0679, 32.4649, -1.1213, -20.0679, 32.4649, -1.1213, -18.3955, 29.679499, -0.6044, -8.3616, 32.4649, -1.1213, -6.6893, 29.679499, -0.6044, -6.6893, 29.679499, -0.6044, -21.7402, 32.4649, -1.1213, -20.0679, 29.679499, -0.6044, -20.0679, 29.679499, -0.6044, -6.6893, 32.4649, -1.1213, -6.6893, 32.4649, -1.1213, -5.0169997, 29.679499, -0.6044, -21.7402, 32.4649, -1.1213, -21.7402, 32.4649, -1.1213, -20.0679, 29.679499, -0.6044, -6.6893, 32.4649, -1.1213, -5.0169997, 29.679499, -0.6044, -5.0169997, 29.679499, -0.6044, -23.412498, 32.4649, -1.1213, -21.7402, 29.679499, -0.6044, -21.7402, 29.679499, -0.6044, -5.0169997, 32.4649, -1.1213, -5.0169997, 32.4649, -1.1213, -3.3446, 29.679499, -0.6044, -23.412498, 32.4649, -1.1213, -23.412498, 32.4649, -1.1213, -21.7402, 29.679499, -0.6044, -5.0169997, 32.4649, -1.1213, -3.3446, 29.679499, -0.6044, -3.3446, 29.679499, -0.6044, -25.084799, 32.4649, -1.1213, -23.412498, 29.679499, -0.6044, -23.412498, 29.679499, -0.6044, -3.3446, 32.4649, -1.1213, -3.3446, 32.4649, -1.1213, -1.6723, 29.679499, -0.6044, -25.084799, 32.4649, -1.1213, -25.084799, 32.4649, -1.1213, -23.412498, 29.679499, -0.6044, -26.7572, 32.4649, -1.1213, -25.084799, 29.679499, -0.6044, -25.084799, 29.679499, -0.6044, -26.7572, 32.4649, -1.1213, -26.7572, 32.4649, -1.1213, -25.084799, 29.679499, -0.6044, -3.3446, 32.4649, -1.1213, -1.6723, 29.679499, -0.6044, -1.6723, 29.679499, -0.6044, -1.6723, 32.4649, -1.1213, -1.6723, 32.4649, -1.1213, 0, 29.679499, -0.6044, -1.6723, 32.4649, -1.1213, 0, 29.679499, -0.6044, 0, 29.679499, -0.6044, 0, 32.4649, -1.1213, 0, 32.4649, -1.1213, 1.6723, 29.679499, -0.6044, 0, 32.4649, -1.1213, 1.6723, 29.679499, -0.6044, 1.6723, 29.679499, -0.6044, 1.6723, 32.4649, -1.1213, 1.6723, 32.4649, -1.1213, 3.3446, 29.679499, -0.6044, 1.6723, 32.4649, -1.1213, 3.3446, 29.679499, -0.6044, 3.3446, 29.679499, -0.6044, 3.3446, 32.4649, -1.1213, 3.3446, 32.4649, -1.1213, 5.0169997, 29.679499, -0.6044, 3.3446, 32.4649, -1.1213, 5.0169997, 29.679499, -0.6044, 5.0169997, 29.679499, -0.6044, 5.0169997, 32.4649, -1.1213, 5.0169997, 32.4649, -1.1213, 6.6893, 29.679499, -0.6044, 5.0169997, 32.4649, -1.1213, 6.6893, 29.679499, -0.6044, 6.6893, 29.679499, -0.6044, 6.6893, 32.4649, -1.1213, 6.6893, 32.4649, -1.1213, 8.3616, 29.679499, -0.6044, 6.6893, 32.4649, -1.1213, 8.3616, 29.679499, -0.6044, 8.3616, 29.679499, -0.6044, 8.3616, 32.4649, -1.1213, 8.3616, 32.4649, -1.1213, 10.033899, 29.679499, -0.6044, 8.3616, 32.4649, -1.1213, 10.033899, 29.679499, -0.6044, 10.033899, 29.679499, -0.6044, 10.033899, 32.4649, -1.1213, 10.033899, 32.4649, -1.1213, 11.7063, 29.679499, -0.6044, 10.033899, 32.4649, -1.1213, 11.7063, 29.679499, -0.6044, 11.7063, 29.679499, -0.6044, 11.7063, 32.4649, -1.1213, 11.7063, 32.4649, -1.1213, 13.3786, 29.679499, -0.6044, 11.7063, 32.4649, -1.1213, 13.3786, 29.679499, -0.6044, 13.3786, 29.679499, -0.6044, 13.3786, 32.4649, -1.1213, 13.3786, 32.4649, -1.1213, 15.0508995, 29.679499, -0.6044, 13.3786, 32.4649, -1.1213, 15.0508995, 29.679499, -0.6044, 15.0508995, 29.679499, -0.6044, 15.0508995, 32.4649, -1.1213, 15.0508995, 32.4649, -1.1213, 16.7232, 29.679499, -0.6044, 15.0508995, 32.4649, -1.1213, 16.7232, 29.679499, -0.6044, 16.7232, 29.679499, -0.6044, 16.7232, 32.4649, -1.1213, 16.7232, 32.4649, -1.1213, 18.3955, 29.679499, -0.6044, 16.7232, 32.4649, -1.1213, 18.3955, 29.679499, -0.6044, 18.3955, 29.679499, -0.6044, 18.3955, 32.4649, -1.1213, 18.3955, 32.4649, -1.1213, 20.0679, 29.679499, -0.6044, 18.3955, 32.4649, -1.1213, 20.0679, 29.679499, -0.6044, 20.0679, 29.679499, -0.6044, 20.0679, 32.4649, -1.1213, 20.0679, 32.4649, -1.1213, 21.7402, 29.679499, -0.6044, 20.0679, 32.4649, -1.1213, 21.7402, 29.679499, -0.6044, 21.7402, 29.679499, -0.6044, 21.7402, 32.4649, -1.1213, 21.7402, 32.4649, -1.1213, 23.412498, 29.679499, -0.6044, 21.7402, 32.4649, -1.1213, 23.412498, 29.679499, -0.6044, 23.412498, 29.679499, -0.6044, 23.412498, 32.4649, -1.1213, 23.412498, 32.4649, -1.1213, 25.084799, 29.679499, -0.6044, 23.412498, 32.4649, -1.1213, 25.084799, 29.679499, -0.6044, 25.084799, 29.679499, -0.6044, 25.084799, 32.4649, -1.1213, 25.084799, 32.4649, -1.1213, 26.7572, 29.679499, -0.6044, 25.084799, 32.4649, -1.1213, 26.7572, 29.679499, -0.6044, 26.7572, 32.4649, -1.1213, -15.0508995, 39.7973, -3.9413, -13.3786, 32.4649, -1.1213, -13.3786, 32.4649, -1.1213, -13.3786, 39.7973, -3.9413, -13.3786, 39.7973, -3.9413, -11.7063, 32.4649, -1.1213, -15.0508995, 39.7973, -3.9413, -15.0508995, 39.7973, -3.9413, -13.3786, 32.4649, -1.1213, -13.3786, 39.7973, -3.9413, -11.7063, 32.4649, -1.1213, -11.7063, 32.4649, -1.1213, -16.7232, 39.7973, -3.9413, -15.0508995, 32.4649, -1.1213, -15.0508995, 32.4649, -1.1213, -11.7063, 39.7973, -3.9413, -11.7063, 39.7973, -3.9413, -10.033899, 32.4649, -1.1213, -16.7232, 39.7973, -3.9413, -16.7232, 39.7973, -3.9413, -15.0508995, 32.4649, -1.1213, -11.7063, 39.7973, -3.9413, -10.033899, 32.4649, -1.1213, -10.033899, 32.4649, -1.1213, -18.3955, 39.7973, -3.9413, -16.7232, 32.4649, -1.1213, -16.7232, 32.4649, -1.1213, -10.033899, 39.7973, -3.9413, -10.033899, 39.7973, -3.9413, -8.3616, 32.4649, -1.1213, -18.3955, 39.7973, -3.9413, -18.3955, 39.7973, -3.9413, -16.7232, 32.4649, -1.1213, -10.033899, 39.7973, -3.9413, -8.3616, 32.4649, -1.1213, -8.3616, 32.4649, -1.1213, -20.0679, 39.7973, -3.9413, -18.3955, 32.4649, -1.1213, -18.3955, 32.4649, -1.1213, -8.3616, 39.7973, -3.9413, -8.3616, 39.7973, -3.9413, -6.6893, 32.4649, -1.1213, -20.0679, 39.7973, -3.9413, -20.0679, 39.7973, -3.9413, -18.3955, 32.4649, -1.1213, -8.3616, 39.7973, -3.9413, -6.6893, 32.4649, -1.1213, -6.6893, 32.4649, -1.1213, -21.7402, 39.7973, -3.9413, -20.0679, 32.4649, -1.1213, -20.0679, 32.4649, -1.1213, -6.6893, 39.7973, -3.9413, -6.6893, 39.7973, -3.9413, -5.0169997, 32.4649, -1.1213, -21.7402, 39.7973, -3.9413, -21.7402, 39.7973, -3.9413, -20.0679, 32.4649, -1.1213, -6.6893, 39.7973, -3.9413, -5.0169997, 32.4649, -1.1213, -5.0169997, 32.4649, -1.1213, -23.412498, 39.7973, -3.9413, -21.7402, 32.4649, -1.1213, -21.7402, 32.4649, -1.1213, -5.0169997, 39.7973, -3.9413, -5.0169997, 39.7973, -3.9413, -3.3446, 32.4649, -1.1213, -23.412498, 39.7973, -3.9413, -23.412498, 39.7973, -3.9413, -21.7402, 32.4649, -1.1213, -5.0169997, 39.7973, -3.9413, -3.3446, 32.4649, -1.1213, -3.3446, 32.4649, -1.1213, -25.084799, 39.7973, -3.9413, -23.412498, 32.4649, -1.1213, -23.412498, 32.4649, -1.1213, -3.3446, 39.7973, -3.9413, -3.3446, 39.7973, -3.9413, -1.6723, 32.4649, -1.1213, -25.084799, 39.7973, -3.9413, -25.084799, 39.7973, -3.9413, -23.412498, 32.4649, -1.1213, -26.7572, 39.7973, -3.9413, -25.084799, 32.4649, -1.1213, -25.084799, 32.4649, -1.1213, -26.7572, 39.7973, -3.9413, -26.7572, 39.7973, -3.9413, -25.084799, 32.4649, -1.1213, -3.3446, 39.7973, -3.9413, -1.6723, 32.4649, -1.1213, -1.6723, 32.4649, -1.1213, -1.6723, 39.7973, -3.9413, -1.6723, 39.7973, -3.9413, 0, 32.4649, -1.1213, -1.6723, 39.7973, -3.9413, 0, 32.4649, -1.1213, 0, 32.4649, -1.1213, 0, 39.7973, -3.9413, 0, 39.7973, -3.9413, 1.6723, 32.4649, -1.1213, 0, 39.7973, -3.9413, 1.6723, 32.4649, -1.1213, 1.6723, 32.4649, -1.1213, 1.6723, 39.7973, -3.9413, 1.6723, 39.7973, -3.9413, 3.3446, 32.4649, -1.1213, 1.6723, 39.7973, -3.9413, 3.3446, 32.4649, -1.1213, 3.3446, 32.4649, -1.1213, 3.3446, 39.7973, -3.9413, 3.3446, 39.7973, -3.9413, 5.0169997, 32.4649, -1.1213, 3.3446, 39.7973, -3.9413, 5.0169997, 32.4649, -1.1213, 5.0169997, 32.4649, -1.1213, 5.0169997, 39.7973, -3.9413, 5.0169997, 39.7973, -3.9413, 6.6893, 32.4649, -1.1213, 5.0169997, 39.7973, -3.9413, 6.6893, 32.4649, -1.1213, 6.6893, 32.4649, -1.1213, 6.6893, 39.7973, -3.9413, 6.6893, 39.7973, -3.9413, 8.3616, 32.4649, -1.1213, 6.6893, 39.7973, -3.9413, 8.3616, 32.4649, -1.1213, 8.3616, 32.4649, -1.1213, 8.3616, 39.7973, -3.9413, 8.3616, 39.7973, -3.9413, 10.033899, 32.4649, -1.1213, 8.3616, 39.7973, -3.9413, 10.033899, 32.4649, -1.1213, 10.033899, 32.4649, -1.1213, 10.033899, 39.7973, -3.9413, 10.033899, 39.7973, -3.9413, 11.7063, 32.4649, -1.1213, 10.033899, 39.7973, -3.9413, 11.7063, 32.4649, -1.1213, 11.7063, 32.4649, -1.1213, 11.7063, 39.7973, -3.9413, 11.7063, 39.7973, -3.9413, 13.3786, 32.4649, -1.1213, 11.7063, 39.7973, -3.9413, 13.3786, 32.4649, -1.1213, 13.3786, 32.4649, -1.1213, 13.3786, 39.7973, -3.9413, 13.3786, 39.7973, -3.9413, 15.0508995, 32.4649, -1.1213, 13.3786, 39.7973, -3.9413, 15.0508995, 32.4649, -1.1213, 15.0508995, 32.4649, -1.1213, 15.0508995, 39.7973, -3.9413, 15.0508995, 39.7973, -3.9413, 16.7232, 32.4649, -1.1213, 15.0508995, 39.7973, -3.9413, 16.7232, 32.4649, -1.1213, 16.7232, 32.4649, -1.1213, 16.7232, 39.7973, -3.9413, 16.7232, 39.7973, -3.9413, 18.3955, 32.4649, -1.1213, 16.7232, 39.7973, -3.9413, 18.3955, 32.4649, -1.1213, 18.3955, 32.4649, -1.1213, 18.3955, 39.7973, -3.9413, 18.3955, 39.7973, -3.9413, 20.0679, 32.4649, -1.1213, 18.3955, 39.7973, -3.9413, 20.0679, 32.4649, -1.1213, 20.0679, 32.4649, -1.1213, 20.0679, 39.7973, -3.9413, 20.0679, 39.7973, -3.9413, 21.7402, 32.4649, -1.1213, 20.0679, 39.7973, -3.9413, 21.7402, 32.4649, -1.1213, 21.7402, 32.4649, -1.1213, 21.7402, 39.7973, -3.9413, 21.7402, 39.7973, -3.9413, 23.412498, 32.4649, -1.1213, 21.7402, 39.7973, -3.9413, 23.412498, 32.4649, -1.1213, 23.412498, 32.4649, -1.1213, 23.412498, 39.7973, -3.9413, 23.412498, 39.7973, -3.9413, 25.084799, 32.4649, -1.1213, 23.412498, 39.7973, -3.9413, 25.084799, 32.4649, -1.1213, 25.084799, 32.4649, -1.1213, 25.084799, 39.7973, -3.9413, 25.084799, 39.7973, -3.9413, 26.7572, 32.4649, -1.1213, 25.084799, 39.7973, -3.9413, 26.7572, 32.4649, -1.1213, 26.7572, 26.7572, -0.2593, 16.7232, 29.679499, -0.6044, 18.3955, 26.7572, -0.2593, 18.3955, 26.7572, -0.2593, 18.3955, 29.679499, -0.6044, 18.3955, 29.679499, -0.6044, 20.0679, 26.7572, -0.2593, 16.7232, 29.679499, -0.6044, 16.7232, 29.679499, -0.6044, 18.3955, 26.7572, -0.2593, 18.3955, 29.679499, -0.6044, 20.0679, 26.7572, -0.2593, 20.0679, 26.7572, -0.2593, 15.0508995, 29.679499, -0.6044, 16.7232, 26.7572, -0.2593, 16.7232, 26.7572, -0.2593, 20.0679, 29.679499, -0.6044, 20.0679, 29.679499, -0.6044, 21.7402, 26.7572, -0.2593, 15.0508995, 29.679499, -0.6044, 15.0508995, 29.679499, -0.6044, 16.7232, 26.7572, -0.2593, 20.0679, 29.679499, -0.6044, 21.7402, 26.7572, -0.2593, 21.7402, 26.7572, -0.2593, 13.3786, 29.679499, -0.6044, 15.0508995, 26.7572, -0.2593, 15.0508995, 26.7572, -0.2593, 21.7402, 29.679499, -0.6044, 21.7402, 29.679499, -0.6044, 23.412498, 26.7572, -0.2593, 13.3786, 29.679499, -0.6044, 13.3786, 29.679499, -0.6044, 15.0508995, 26.7572, -0.2593, 21.7402, 29.679499, -0.6044, 23.412498, 26.7572, -0.2593, 23.412498, 26.7572, -0.2593, 11.7063, 29.679499, -0.6044, 13.3786, 26.7572, -0.2593, 13.3786, 26.7572, -0.2593, 23.412498, 29.679499, -0.6044, 23.412498, 29.679499, -0.6044, 25.084799, 26.7572, -0.2593, 11.7063, 29.679499, -0.6044, 11.7063, 29.679499, -0.6044, 13.3786, 26.7572, -0.2593, 23.412498, 29.679499, -0.6044, 25.084799, 26.7572, -0.2593, 25.084799, 26.7572, -0.2593, 25.084799, 29.679499, -0.6044, 25.084799, 29.679499, -0.6044, 26.7572, 26.7572, -0.2593, 25.084799, 29.679499, -0.6044, 26.7572, 26.7572, -0.2593, 26.7572, 26.7572, -0.2593, 10.033899, 29.679499, -0.6044, 11.7063, 26.7572, -0.2593, 11.7063, 26.7572, -0.2593, 10.033899, 29.679499, -0.6044, 10.033899, 29.679499, -0.6044, 11.7063, 26.7572, -0.2593, 8.3616, 29.679499, -0.6044, 10.033899, 26.7572, -0.2593, 10.033899, 26.7572, -0.2593, 8.3616, 29.679499, -0.6044, 8.3616, 29.679499, -0.6044, 10.033899, 26.7572, -0.2593, 6.6893, 29.679499, -0.6044, 8.3616, 26.7572, -0.2593, 8.3616, 26.7572, -0.2593, 6.6893, 29.679499, -0.6044, 6.6893, 29.679499, -0.6044, 8.3616, 26.7572, -0.2593, 5.0169997, 29.679499, -0.6044, 6.6893, 26.7572, -0.2593, 6.6893, 26.7572, -0.2593, 5.0169997, 29.679499, -0.6044, 5.0169997, 29.679499, -0.6044, 6.6893, 26.7572, -0.2593, 3.3446, 29.679499, -0.6044, 5.0169997, 26.7572, -0.2593, 5.0169997, 26.7572, -0.2593, 3.3446, 29.679499, -0.6044, 3.3446, 29.679499, -0.6044, 5.0169997, 26.7572, -0.2593, 1.6723, 29.679499, -0.6044, 3.3446, 26.7572, -0.2593, 3.3446, 26.7572, -0.2593, 1.6723, 29.679499, -0.6044, 1.6723, 29.679499, -0.6044, 3.3446, 26.7572, -0.2593, 0, 29.679499, -0.6044, 1.6723, 26.7572, -0.2593, 1.6723, 26.7572, -0.2593, 0, 29.679499, -0.6044, 0, 29.679499, -0.6044, 1.6723, 26.7572, -0.2593, -1.6723, 29.679499, -0.6044, 0, 26.7572, -0.2593, 0, 26.7572, -0.2593, -1.6723, 29.679499, -0.6044, -1.6723, 29.679499, -0.6044, 0, 26.7572, -0.2593, -3.3446, 29.679499, -0.6044, -1.6723, 26.7572, -0.2593, -1.6723, 26.7572, -0.2593, -3.3446, 29.679499, -0.6044, -3.3446, 29.679499, -0.6044, -1.6723, 26.7572, -0.2593, -5.0169997, 29.679499, -0.6044, -3.3446, 26.7572, -0.2593, -3.3446, 26.7572, -0.2593, -5.0169997, 29.679499, -0.6044, -5.0169997, 29.679499, -0.6044, -3.3446, 26.7572, -0.2593, -6.6893, 29.679499, -0.6044, -5.0169997, 26.7572, -0.2593, -5.0169997, 26.7572, -0.2593, -6.6893, 29.679499, -0.6044, -6.6893, 29.679499, -0.6044, -5.0169997, 26.7572, -0.2593, -8.3616, 29.679499, -0.6044, -6.6893, 26.7572, -0.2593, -6.6893, 26.7572, -0.2593, -8.3616, 29.679499, -0.6044, -8.3616, 29.679499, -0.6044, -6.6893, 26.7572, -0.2593, -10.033899, 29.679499, -0.6044, -8.3616, 26.7572, -0.2593, -8.3616, 26.7572, -0.2593, -10.033899, 29.679499, -0.6044, -10.033899, 29.679499, -0.6044, -8.3616, 26.7572, -0.2593, -11.7063, 29.679499, -0.6044, -10.033899, 26.7572, -0.2593, -10.033899, 26.7572, -0.2593, -11.7063, 29.679499, -0.6044, -11.7063, 29.679499, -0.6044, -10.033899, 26.7572, -0.2593, -13.3786, 29.679499, -0.6044, -11.7063, 26.7572, -0.2593, -11.7063, 26.7572, -0.2593, -13.3786, 29.679499, -0.6044, -13.3786, 29.679499, -0.6044, -11.7063, 26.7572, -0.2593, -15.0508995, 29.679499, -0.6044, -13.3786, 26.7572, -0.2593, -13.3786, 26.7572, -0.2593, -15.0508995, 29.679499, -0.6044, -15.0508995, 29.679499, -0.6044, -13.3786, 26.7572, -0.2593, -16.7232, 29.679499, -0.6044, -15.0508995, 26.7572, -0.2593, -15.0508995, 26.7572, -0.2593, -16.7232, 29.679499, -0.6044, -16.7232, 29.679499, -0.6044, -15.0508995, 26.7572, -0.2593, -18.3955, 29.679499, -0.6044, -16.7232, 26.7572, -0.2593, -16.7232, 26.7572, -0.2593, -18.3955, 29.679499, -0.6044, -18.3955, 29.679499, -0.6044, -16.7232, 26.7572, -0.2593, -20.0679, 29.679499, -0.6044, -18.3955, 26.7572, -0.2593, -18.3955, 26.7572, -0.2593, -20.0679, 29.679499, -0.6044, -20.0679, 29.679499, -0.6044, -18.3955, 26.7572, -0.2593, -21.7402, 29.679499, -0.6044, -20.0679, 26.7572, -0.2593, -20.0679, 26.7572, -0.2593, -21.7402, 29.679499, -0.6044, -21.7402, 29.679499, -0.6044, -20.0679, 26.7572, -0.2593, -23.412498, 29.679499, -0.6044, -21.7402, 26.7572, -0.2593, -21.7402, 26.7572, -0.2593, -23.412498, 29.679499, -0.6044, -23.412498, 29.679499, -0.6044, -21.7402, 26.7572, -0.2593, -25.084799, 29.679499, -0.6044, -23.412498, 26.7572, -0.2593, -23.412498, 26.7572, -0.2593, -25.084799, 29.679499, -0.6044, -25.084799, 29.679499, -0.6044, -23.412498, 26.7572, -0.2593, -26.7572, 29.679499, -0.6044, -25.084799, 26.7572, -0.2593, -25.084799, 26.7572, -0.2593, -26.7572, 29.679499, -0.6044, -26.7572, 29.679499, -0.6044, -25.084799)
[sub_resource type="PlaneMesh" id="PondWaterMesh"]
-size = Vector2(11.5, 8.5)
-
-[sub_resource type="BoxShape3D" id="OceanWestShape"]
-size = Vector3(17, 2, 80)
-
-[sub_resource type="BoxShape3D" id="OceanEastShape"]
-size = Vector3(42, 2, 80)
-
-[sub_resource type="BoxShape3D" id="OceanNorthSouthShape"]
-size = Vector3(48, 2, 18)
+resource_local_to_scene = true
+size = Vector2(16.1, 12.3675)
[sub_resource type="BoxShape3D" id="PondFishingShape"]
-size = Vector3(11.5, 4, 8.5)
+resource_local_to_scene = true
+size = Vector3(16.1, 4, 12.3675)
+
+[sub_resource type="BoxShape3D" id="PondRecoveryShape"]
+resource_local_to_scene = true
+size = Vector3(16.1, 4.8, 12.3675)
+
+[sub_resource type="PlaneMesh" id="OceanWaterMesh"]
+resource_local_to_scene = true
+size = Vector2(10000, 10000)
+
+[sub_resource type="BoxShape3D" id="OceanWestShape"]
+resource_local_to_scene = true
+size = Vector3(20.820662814, 2, 97.979589711)
+
+[sub_resource type="BoxShape3D" id="OceanEastShape"]
+resource_local_to_scene = true
+size = Vector3(51.439284598, 2, 97.979589711)
+
+[sub_resource type="BoxShape3D" id="OceanNorthShape"]
+resource_local_to_scene = true
+size = Vector3(58.787753827, 2, 22.045407685)
+
+[sub_resource type="BoxShape3D" id="OceanSouthShape"]
+resource_local_to_scene = true
+size = Vector3(58.787753827, 2, 22.045407685)
+
+[sub_resource type="BoxShape3D" id="OceanCenterShape"]
+resource_local_to_scene = true
+size = Vector3(58.787753827, 2, 53.888774341)
[sub_resource type="BoxShape3D" id="OceanWestRecoveryShape"]
+resource_local_to_scene = true
size = Vector3(17, 5, 80)
[sub_resource type="BoxShape3D" id="OceanEastRecoveryShape"]
+resource_local_to_scene = true
size = Vector3(42, 5, 80)
-[sub_resource type="BoxShape3D" id="OceanNorthSouthRecoveryShape"]
+[sub_resource type="BoxShape3D" id="OceanNorthRecoveryShape"]
+resource_local_to_scene = true
size = Vector3(48, 5, 18)
-[sub_resource type="BoxShape3D" id="PondRecoveryShape"]
-size = Vector3(11.5, 4.8, 8.5)
+[sub_resource type="BoxShape3D" id="OceanSouthRecoveryShape"]
+resource_local_to_scene = true
+size = Vector3(48, 5, 18)
-[node name="StarterIslandRegion" type="Node3D"]
+[sub_resource type="Resource" id="ShorelineRibbonConfigPond"]
+script = ExtResource("22_shoreline_config")
+terrain_source = NodePath("../Terrain/Visual/starter_island")
+water_height = 2.51
+generation_bounds = Rect2(-17.45, -4.08375, 16.1, 12.3675)
+water_reference = Vector2(-9.4, 2.1)
+water_is_inside = true
+output_resource_path = "res://world/generated/shorelines/starter_pond_shoreline.tres"
+
+[sub_resource type="Resource" id="ShorelineRibbonConfigOcean"]
+script = ExtResource("22_shoreline_config")
+terrain_source = NodePath("../Terrain/Visual/starter_island")
+water_type = 1
+water_height = -0.45
+generation_bounds = Rect2(-42, -50, 108, 100)
+water_reference = Vector2(0, 0)
+water_is_inside = false
+output_resource_path = "res://world/generated/shorelines/starter_ocean_shoreline.tres"
+simplification_tolerance = 0.55
+smoothing_iterations = 3
+resample_spacing = 0.16
+corner_rounding_distance = 2.4
+
+[node name="StarterIslandRegion" type="Node3D" unique_id=1830415700]
script = ExtResource("1_region")
region_id = &"starter_island"
+fishable_water_root = NodePath("WaterBodies")
+water_recovery_root = NodePath("WaterBodies")
-[node name="Visuals" type="Node3D" parent="."]
+[node name="Terrain" type="Node3D" parent="." unique_id=1130051521]
-[node name="StarterIslandModel" parent="Visuals" instance=ExtResource("2_island")]
+[node name="Visual" parent="Terrain" unique_id=933263069 instance=ExtResource("2_island")]
-[node name="OceanWater" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(12, -0.45, 0)
-mesh = SubResource("OceanWaterMesh")
-material_override = SubResource("WaterMaterial")
+[node name="Collision" type="StaticBody3D" parent="Terrain" unique_id=1495743259]
-[node name="PondWater" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-9.4, 2.1, 2.1)
+[node name="Shape" type="CollisionShape3D" parent="Terrain/Collision" unique_id=674742331]
+shape = SubResource("ConcavePolygonShape3D_4e4k5")
+
+[node name="ShorelineRibbonBaker" type="Node" parent="."]
+script = ExtResource("21_shoreline_baker")
+water_bodies = Array[ExtResource("22_shoreline_config")]([SubResource("ShorelineRibbonConfigPond"), SubResource("ShorelineRibbonConfigOcean")])
+
+[node name="ShorelineRibbons" type="Node3D" parent="."]
+
+[node name="Ocean" type="MeshInstance3D" parent="ShorelineRibbons"]
+visible = false
+cast_shadow = 0
+mesh = ExtResource("17_ocean_shoreline")
+
+[node name="WaterBodies" type="Node3D" parent="." unique_id=1417012172]
+
+[node name="Pond" type="Node3D" parent="WaterBodies" unique_id=995477450]
+position = Vector3(-9.4, 2.51, 2.1)
+script = ExtResource("10_water_body")
+surface_size = Vector2(16.1, 12.3675)
+water_material = ExtResource("19_fresh_water")
+fish_pool = ExtResource("12_pond_pool")
+location_tags = Array[StringName]([&"starter_pond"])
+selection_priority = 1
+
+[node name="VisualWater" type="MeshInstance3D" parent="WaterBodies/Pond" unique_id=1034265960]
+material_override = ExtResource("19_fresh_water")
mesh = SubResource("PondWaterMesh")
-material_override = SubResource("WaterMaterial")
-[node name="StaticCollision" type="Node3D" parent="."]
-
-[node name="Terrain" type="StaticBody3D" parent="StaticCollision"]
-
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/Terrain"]
-
-[node name="FishingWater" type="Node3D" parent="."]
-
-[node name="PondWater" type="Area3D" parent="FishingWater"]
-position = Vector3(-9.4, 2.1, 2.1)
+[node name="FishingRegion" type="Area3D" parent="WaterBodies/Pond" unique_id=1991633849]
collision_layer = 4
collision_mask = 0
monitoring = false
script = ExtResource("3_water")
location_tags = Array[StringName]([&"starter_pond"])
-fish_pool = ExtResource("4_pool")
+water_type = 0
+fish_pool = ExtResource("12_pond_pool")
selection_priority = 1
-surface_height = 2.1
+surface_height_mode = 1
-[node name="Shape" type="CollisionShape3D" parent="FishingWater/PondWater"]
+[node name="Shape" type="CollisionShape3D" parent="WaterBodies/Pond/FishingRegion" unique_id=1185360438]
shape = SubResource("PondFishingShape")
-[node name="OceanWater" type="Area3D" parent="FishingWater"]
-position = Vector3(0, -0.45, 0)
+[node name="RecoveryRegion" type="Area3D" parent="WaterBodies/Pond" unique_id=773886739]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -2.4, 0)
+collision_layer = 8
+collision_mask = 2
+script = ExtResource("5_trigger")
+surface_height_mode = 1
+
+[node name="Shape" type="CollisionShape3D" parent="WaterBodies/Pond/RecoveryRegion" unique_id=1718808476]
+shape = SubResource("PondRecoveryShape")
+
+[node name="Ocean" type="Node3D" parent="WaterBodies" unique_id=552655799]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, -0.45, 0)
+
+[node name="VisualWater" type="MeshInstance3D" parent="WaterBodies/Ocean" unique_id=1176408937]
+material_override = ExtResource("13_water")
+mesh = SubResource("OceanWaterMesh")
+script = ExtResource("20_surface_motion")
+
+[node name="FishingRegions" type="Node3D" parent="WaterBodies/Ocean" unique_id=205598473]
+
+[node name="OceanFishingRegion" type="Area3D" parent="WaterBodies/Ocean/FishingRegions" unique_id=740261133]
collision_layer = 4
collision_mask = 0
monitoring = false
script = ExtResource("3_water")
location_tags = Array[StringName]([&"coast", &"ocean"])
+water_type = 1
fish_pool = ExtResource("4_pool")
-surface_height = -0.45
+surface_height_mode = 1
-[node name="WestShape" type="CollisionShape3D" parent="FishingWater/OceanWater"]
-position = Vector3(-33.5, 0, 0)
+[node name="WestShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=357406062]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -55.613519213, -1, 0)
shape = SubResource("OceanWestShape")
-[node name="EastShape" type="CollisionShape3D" parent="FishingWater/OceanWater"]
-position = Vector3(44, 0, 0)
+[node name="EastShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=772224738]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 39.30420832, -1, 0)
shape = SubResource("OceanEastShape")
-[node name="NorthShape" type="CollisionShape3D" parent="FishingWater/OceanWater"]
-position = Vector3(-1, 0, 31)
-shape = SubResource("OceanNorthSouthShape")
+[node name="NorthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=646147646]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15.809310892, -1, 37.967091013)
+shape = SubResource("OceanNorthShape")
-[node name="SouthShape" type="CollisionShape3D" parent="FishingWater/OceanWater"]
-position = Vector3(-1, 0, -31)
-shape = SubResource("OceanNorthSouthShape")
+[node name="SouthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=894083854]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15.809310892, -1, -37.967091013)
+shape = SubResource("OceanSouthShape")
-[node name="WaterRecovery" type="Node3D" parent="."]
+[node name="CenterShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion"]
+position = Vector3(-15.809310892, -1, 0)
+shape = SubResource("OceanCenterShape")
-[node name="PondRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(-9.4, -0.3, 2.1)
+[node name="RecoveryRegions" type="Node3D" parent="WaterBodies/Ocean" unique_id=463706703]
+
+[node name="OceanRecoveryRegion" type="Area3D" parent="WaterBodies/Ocean/RecoveryRegions" unique_id=922834963]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -2.5, 0)
collision_layer = 8
collision_mask = 2
script = ExtResource("5_trigger")
-surface_height = 2.1
+surface_height_mode = 1
-[node name="Shape" type="CollisionShape3D" parent="WaterRecovery/PondRecovery"]
-shape = SubResource("PondRecoveryShape")
-
-[node name="OceanRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(0, -2.95, 0)
-collision_layer = 8
-collision_mask = 2
-script = ExtResource("5_trigger")
-surface_height = -0.45
-
-[node name="WestShape" type="CollisionShape3D" parent="WaterRecovery/OceanRecovery"]
-position = Vector3(-33.5, 0, 0)
+[node name="WestShape" type="CollisionShape3D" parent="WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" unique_id=1183368645]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -45.5, 0, 0)
shape = SubResource("OceanWestRecoveryShape")
-[node name="EastShape" type="CollisionShape3D" parent="WaterRecovery/OceanRecovery"]
-position = Vector3(44, 0, 0)
+[node name="EastShape" type="CollisionShape3D" parent="WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" unique_id=1742419808]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 32, 0, 0)
shape = SubResource("OceanEastRecoveryShape")
-[node name="NorthShape" type="CollisionShape3D" parent="WaterRecovery/OceanRecovery"]
-position = Vector3(-1, 0, 31)
-shape = SubResource("OceanNorthSouthRecoveryShape")
+[node name="NorthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" unique_id=1844920260]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -13, 0, 31)
+shape = SubResource("OceanNorthRecoveryShape")
-[node name="SouthShape" type="CollisionShape3D" parent="WaterRecovery/OceanRecovery"]
-position = Vector3(-1, 0, -31)
-shape = SubResource("OceanNorthSouthRecoveryShape")
+[node name="SouthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" unique_id=1316495770]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -13, 0, -31)
+shape = SubResource("OceanSouthRecoveryShape")
-[node name="SafeRespawns" type="Node3D" parent="."]
+[node name="SafeRespawns" type="Node3D" parent="." unique_id=81822111]
-[node name="SpawnPlateau" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(0, 3.95, 13)
+[node name="SpawnPlateau" type="Marker3D" parent="SafeRespawns" unique_id=105678210]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.95, 13)
script = ExtResource("6_safe")
-[node name="PondEast" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-0.5, 3.95, 2)
+[node name="PondEast" type="Marker3D" parent="SafeRespawns" unique_id=235246560]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.5, 3.95, 2)
script = ExtResource("6_safe")
-[node name="PondSouth" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-9, 3.95, 11.5)
+[node name="PondSouth" type="Marker3D" parent="SafeRespawns" unique_id=1893590577]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9, 3.95, 11.5)
script = ExtResource("6_safe")
-[node name="ShopApproach" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(13.5, 3.95, 6)
+[node name="ShopApproach" type="Marker3D" parent="SafeRespawns" unique_id=1683733783]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13.5, 3.95, 6)
script = ExtResource("6_safe")
-[node name="SouthPlateau" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(5, 3.95, 15)
+[node name="SouthPlateau" type="Marker3D" parent="SafeRespawns" unique_id=220262614]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 3.95, 15)
script = ExtResource("6_safe")
-[node name="EastShoreApproach" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(15, 3.95, -11)
+[node name="EastShoreApproach" type="Marker3D" parent="SafeRespawns" unique_id=1294444639]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 15, 3.95, -11)
script = ExtResource("6_safe")
-[node name="PlayerSpawn" type="Marker3D" parent="."]
-position = Vector3(0, 3.95, 13)
+[node name="PlayerSpawn" type="Marker3D" parent="." unique_id=1296729902]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.95, 13)
-[node name="Interactables" type="Node3D" parent="."]
+[node name="Interactables" type="Node3D" parent="." unique_id=26695257]
-[node name="FishingShopWorld" parent="Interactables" instance=ExtResource("7_shop")]
-position = Vector3(8, 3.42, 6)
-rotation_degrees = Vector3(0, -25, 0)
-
-[node name="PelicanCoolerPerch" parent="Interactables" instance=ExtResource("8_pelican")]
-position = Vector3(18, 0.35, -16)
-rotation_degrees = Vector3(0, -35, 0)
+[node name="FishingShopWorld" parent="Interactables" unique_id=1255373524 instance=ExtResource("7_shop")]
+transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9976046, 5.976795, 3.42, 4.299163)
diff --git a/world/regions/starter_pond_region.tscn b/world/regions/starter_pond_region.tscn
deleted file mode 100644
index f484beb..0000000
--- a/world/regions/starter_pond_region.tscn
+++ /dev/null
@@ -1,114 +0,0 @@
-[gd_scene load_steps=14 format=3]
-
-[ext_resource type="Script" path="res://world/regions/graybox_region.gd" id="1_region"]
-[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_water"]
-[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="3_pool"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="4_trigger"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="5_safe"]
-
-[sub_resource type="BoxShape3D" id="BankNSShape"]
-size = Vector3(36, 6, 8)
-[sub_resource type="BoxShape3D" id="BankEWShape"]
-size = Vector3(6, 6, 20)
-[sub_resource type="BoxMesh" id="BankNSMesh"]
-size = Vector3(36, 6, 8)
-[sub_resource type="BoxMesh" id="BankEWMesh"]
-size = Vector3(6, 6, 20)
-[sub_resource type="PlaneMesh" id="WaterMesh"]
-size = Vector2(24, 20)
-[sub_resource type="BoxShape3D" id="WaterShape"]
-size = Vector3(24, 0.2, 20)
-[sub_resource type="BoxShape3D" id="RecoveryShape"]
-size = Vector3(24, 5, 20)
-[sub_resource type="StandardMaterial3D" id="GrassMaterial"]
-albedo_color = Color(0.43, 0.75, 0.34, 1)
-[sub_resource type="StandardMaterial3D" id="WaterMaterial"]
-transparency = 1
-albedo_color = Color(0.16, 0.58, 0.78, 0.78)
-roughness = 0.25
-
-[node name="StarterPondRegion" type="Node3D"]
-script = ExtResource("1_region")
-region_id = &"starter_pond"
-
-[node name="Visuals" type="Node3D" parent="."]
-[node name="NorthBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, 14)
-mesh = SubResource("BankNSMesh")
-material_override = SubResource("GrassMaterial")
-[node name="SouthBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, -14)
-mesh = SubResource("BankNSMesh")
-material_override = SubResource("GrassMaterial")
-[node name="WestBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-15, -3, 0)
-mesh = SubResource("BankEWMesh")
-material_override = SubResource("GrassMaterial")
-[node name="EastBank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(15, -3, 0)
-mesh = SubResource("BankEWMesh")
-material_override = SubResource("GrassMaterial")
-[node name="Water" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.2, 0)
-mesh = SubResource("WaterMesh")
-material_override = SubResource("WaterMaterial")
-[node name="PondLabel" type="Label3D" parent="Visuals"]
-position = Vector3(-14, 2.4, 11)
-text = "pond"
-font_size = 34
-outline_size = 7
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="Node3D" parent="."]
-[node name="NorthBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/NorthBank"]
-position = Vector3(0, -3, 14)
-shape = SubResource("BankNSShape")
-[node name="SouthBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/SouthBank"]
-position = Vector3(0, -3, -14)
-shape = SubResource("BankNSShape")
-[node name="WestBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestBank"]
-position = Vector3(-15, -3, 0)
-shape = SubResource("BankEWShape")
-[node name="EastBank" type="StaticBody3D" parent="StaticCollision"]
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/EastBank"]
-position = Vector3(15, -3, 0)
-shape = SubResource("BankEWShape")
-
-[node name="FishingWater" type="Node3D" parent="."]
-[node name="StarterPondWater" type="Area3D" parent="FishingWater"]
-position = Vector3(0, 0.2, 0)
-collision_layer = 4
-collision_mask = 0
-monitoring = false
-script = ExtResource("2_water")
-location_tags = Array[StringName]([&"starter_pond"])
-fish_pool = ExtResource("3_pool")
-[node name="Shape" type="CollisionShape3D" parent="FishingWater/StarterPondWater"]
-shape = SubResource("WaterShape")
-
-[node name="WaterRecovery" type="Node3D" parent="."]
-[node name="StarterPondRecovery" type="Area3D" parent="WaterRecovery"]
-position = Vector3(0, -2.3, 0)
-collision_layer = 8
-collision_mask = 2
-script = ExtResource("4_trigger")
-surface_height = 0.2
-[node name="Shape" type="CollisionShape3D" parent="WaterRecovery/StarterPondRecovery"]
-shape = SubResource("RecoveryShape")
-
-[node name="SafeRespawns" type="Node3D" parent="."]
-[node name="EastShore" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(15, 0.58, 4)
-rotation_degrees = Vector3(0, 90, 0)
-script = ExtResource("5_safe")
-[node name="NorthShore" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(0, 0.58, 14)
-rotation_degrees = Vector3(0, 180, 0)
-script = ExtResource("5_safe")
-[node name="SouthShore" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(0, 0.58, -14)
-script = ExtResource("5_safe")
diff --git a/world/regions/village_region.tscn b/world/regions/village_region.tscn
deleted file mode 100644
index 10a4527..0000000
--- a/world/regions/village_region.tscn
+++ /dev/null
@@ -1,179 +0,0 @@
-[gd_scene load_steps=16 format=3]
-
-[ext_resource type="Script" path="res://world/regions/graybox_region.gd" id="1_region"]
-[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="2_safe"]
-[ext_resource type="PackedScene" path="res://world/interactables/fishing_shop_world.tscn" id="3_shop"]
-[ext_resource type="PackedScene" path="res://world/interactables/pelican_buyer_world.tscn" id="4_pelican"]
-
-[sub_resource type="BoxShape3D" id="GroundShape"]
-size = Vector3(48, 6, 36)
-
-[sub_resource type="BoxShape3D" id="BuildingShape"]
-size = Vector3(8, 5, 7)
-
-[sub_resource type="BoxMesh" id="GroundMesh"]
-size = Vector3(48, 6, 36)
-
-[sub_resource type="BoxMesh" id="PlazaMesh"]
-size = Vector3(22, 0.02, 18)
-
-[sub_resource type="BoxMesh" id="BuildingMesh"]
-size = Vector3(8, 5, 7)
-
-[sub_resource type="CylinderMesh" id="RoofMesh"]
-top_radius = 0
-bottom_radius = 5.5
-height = 3
-radial_segments = 4
-
-[sub_resource type="CylinderMesh" id="TowerMesh"]
-top_radius = 2.4
-bottom_radius = 2.4
-height = 8
-
-[sub_resource type="CylinderMesh" id="TowerTankMesh"]
-top_radius = 3.2
-bottom_radius = 3.2
-height = 3.2
-
-[sub_resource type="BoxMesh" id="PavilionRoofMesh"]
-size = Vector3(9, 0.35, 7)
-
-[sub_resource type="BoxMesh" id="PavilionPostMesh"]
-size = Vector3(0.3, 3.2, 0.3)
-
-[sub_resource type="StandardMaterial3D" id="GrassMaterial"]
-albedo_color = Color(0.37, 0.68, 0.3, 1)
-
-[sub_resource type="StandardMaterial3D" id="PlazaMaterial"]
-albedo_color = Color(0.76, 0.69, 0.54, 1)
-
-[sub_resource type="StandardMaterial3D" id="BuildingMaterial"]
-albedo_color = Color(0.92, 0.55, 0.34, 1)
-
-[sub_resource type="StandardMaterial3D" id="RoofMaterial"]
-albedo_color = Color(0.2, 0.46, 0.53, 1)
-
-[sub_resource type="StandardMaterial3D" id="LandmarkMaterial"]
-albedo_color = Color(0.95, 0.82, 0.28, 1)
-
-[node name="VillageRegion" type="Node3D"]
-script = ExtResource("1_region")
-region_id = &"village"
-
-[node name="Visuals" type="Node3D" parent="."]
-
-[node name="Ground" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, -3, 0)
-mesh = SubResource("GroundMesh")
-material_override = SubResource("GrassMaterial")
-
-[node name="Plaza" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(0, 0.011, 0)
-mesh = SubResource("PlazaMesh")
-material_override = SubResource("PlazaMaterial")
-
-[node name="WestBuilding" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-17, 2.5, 9)
-mesh = SubResource("BuildingMesh")
-material_override = SubResource("BuildingMaterial")
-
-[node name="WestRoof" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-17, 6.1, 9)
-rotation_degrees = Vector3(0, 45, 0)
-mesh = SubResource("RoofMesh")
-material_override = SubResource("RoofMaterial")
-
-[node name="NorthBuilding" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-7, 2.5, 13)
-mesh = SubResource("BuildingMesh")
-material_override = SubResource("BuildingMaterial")
-
-[node name="NorthRoof" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-7, 6.1, 13)
-rotation_degrees = Vector3(0, 45, 0)
-mesh = SubResource("RoofMesh")
-material_override = SubResource("RoofMaterial")
-
-[node name="WaterTower" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(18, 4, 11)
-mesh = SubResource("TowerMesh")
-material_override = SubResource("RoofMaterial")
-
-[node name="WaterTank" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(18, 8.5, 11)
-mesh = SubResource("TowerTankMesh")
-material_override = SubResource("LandmarkMaterial")
-
-[node name="PavilionRoof" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-12, 3.3, -10)
-mesh = SubResource("PavilionRoofMesh")
-material_override = SubResource("LandmarkMaterial")
-
-[node name="PavilionPostNW" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-15.5, 1.6, -12.5)
-mesh = SubResource("PavilionPostMesh")
-material_override = SubResource("RoofMaterial")
-
-[node name="PavilionPostSE" type="MeshInstance3D" parent="Visuals"]
-position = Vector3(-8.5, 1.6, -7.5)
-mesh = SubResource("PavilionPostMesh")
-material_override = SubResource("RoofMaterial")
-
-[node name="VillageLabel" type="Label3D" parent="Visuals"]
-position = Vector3(0, 3.2, 7)
-text = "village"
-font_size = 42
-outline_size = 8
-billboard = 1
-no_depth_test = true
-
-[node name="StaticCollision" type="Node3D" parent="."]
-
-[node name="Ground" type="StaticBody3D" parent="StaticCollision"]
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticCollision/Ground"]
-position = Vector3(0, -3, 0)
-shape = SubResource("GroundShape")
-
-[node name="WestBuilding" type="StaticBody3D" parent="StaticCollision"]
-
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/WestBuilding"]
-position = Vector3(-17, 2.5, 9)
-shape = SubResource("BuildingShape")
-
-[node name="NorthBuilding" type="StaticBody3D" parent="StaticCollision"]
-
-[node name="Shape" type="CollisionShape3D" parent="StaticCollision/NorthBuilding"]
-position = Vector3(-7, 2.5, 13)
-shape = SubResource("BuildingShape")
-
-[node name="FishingWater" type="Node3D" parent="."]
-
-[node name="WaterRecovery" type="Node3D" parent="."]
-
-[node name="SafeRespawns" type="Node3D" parent="."]
-
-[node name="VillageCenter" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(0, 0.58, 2)
-script = ExtResource("2_safe")
-
-[node name="VillageWest" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(-19, 0.58, -2)
-rotation_degrees = Vector3(0, -90, 0)
-script = ExtResource("2_safe")
-
-[node name="VillageSouth" type="Marker3D" parent="SafeRespawns"]
-position = Vector3(5, 0.58, -14)
-rotation_degrees = Vector3(0, 180, 0)
-script = ExtResource("2_safe")
-
-[node name="Interactables" type="Node3D" parent="."]
-
-[node name="FishingShopWorld" parent="Interactables" instance=ExtResource("3_shop")]
-position = Vector3(12, 0, 3)
-rotation_degrees = Vector3(0, -25, 0)
-
-[node name="PelicanCoolerPerch" parent="Interactables" instance=ExtResource("4_pelican")]
-position = Vector3(-19, 0, -9)
-rotation_degrees = Vector3(0, 20, 0)
diff --git a/world/regions/graybox_region.gd b/world/regions/world_region.gd
similarity index 69%
rename from world/regions/graybox_region.gd
rename to world/regions/world_region.gd
index 3edff84..a8c560c 100644
--- a/world/regions/graybox_region.gd
+++ b/world/regions/world_region.gd
@@ -1,4 +1,5 @@
-class_name GrayboxRegion
+@tool
+class_name WorldRegion
extends Node3D
@export var region_id: StringName
@@ -12,10 +13,7 @@ func get_fishable_water_regions() -> Array[FishableWaterRegion]:
var root: Node = get_node_or_null(fishable_water_root)
if root == null:
return regions
- for child: Node in root.get_children():
- var water: FishableWaterRegion = child as FishableWaterRegion
- if water != null:
- regions.append(water)
+ _collect_fishable_water_regions(root, regions)
return regions
@@ -24,10 +22,7 @@ func get_water_recovery_triggers() -> Array[PlayerWaterTrigger]:
var root: Node = get_node_or_null(water_recovery_root)
if root == null:
return triggers
- for child: Node in root.get_children():
- var trigger: PlayerWaterTrigger = child as PlayerWaterTrigger
- if trigger != null:
- triggers.append(trigger)
+ _collect_water_recovery_triggers(root, triggers)
return triggers
@@ -41,3 +36,25 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
if point != null:
points.append(point)
return points
+
+
+func _collect_fishable_water_regions(
+ root: Node,
+ regions: Array[FishableWaterRegion],
+) -> void:
+ for child: Node in root.get_children():
+ var water := child as FishableWaterRegion
+ if water != null:
+ regions.append(water)
+ _collect_fishable_water_regions(child, regions)
+
+
+func _collect_water_recovery_triggers(
+ root: Node,
+ triggers: Array[PlayerWaterTrigger],
+) -> void:
+ for child: Node in root.get_children():
+ var trigger := child as PlayerWaterTrigger
+ if trigger != null:
+ triggers.append(trigger)
+ _collect_water_recovery_triggers(child, triggers)
diff --git a/world/regions/graybox_region.gd.uid b/world/regions/world_region.gd.uid
similarity index 100%
rename from world/regions/graybox_region.gd.uid
rename to world/regions/world_region.gd.uid
diff --git a/world/shoreline_ambience.gd b/world/shoreline_ambience.gd
new file mode 100644
index 0000000..81e9c65
--- /dev/null
+++ b/world/shoreline_ambience.gd
@@ -0,0 +1,157 @@
+class_name ShorelineAmbience
+extends Node
+
+@export_range(0.0, 20.0, 0.1) var near_distance: float = 2.0
+@export_range(1.0, 100.0, 0.5) var far_distance: float = 24.0
+@export_range(-40.0, 12.0, 0.5) var near_volume_db: float = 1.0
+@export_range(-80.0, 0.0, 0.5) var far_volume_db: float = -18.0
+@export_range(0.05, 1.0, 0.05) var distance_update_interval: float = 0.2
+@export_range(0.1, 20.0, 0.1) var volume_smoothing_speed: float = 4.0
+
+@onready var _waves_audio: AudioStreamPlayer = %WavesAudio
+
+var _listener: Node3D
+var _shoreline_segments: Array[PackedVector2Array] = []
+var _distance_update_remaining: float = 0.0
+var _target_volume_db: float = -80.0
+var _is_active := false
+
+
+func _ready() -> void:
+ _waves_audio.bus = &"Environment"
+ _configure_audio_loop(_waves_audio.stream)
+ _waves_audio.volume_db = -80.0
+ set_process(false)
+
+
+func configure(listener: Node3D, shoreline_mesh: MeshInstance3D) -> void:
+ _listener = listener
+ _shoreline_segments = _extract_shoreline_segments(shoreline_mesh)
+ _distance_update_remaining = 0.0
+ if _shoreline_segments.is_empty():
+ push_warning("Saltwater shoreline ambience has no coastline geometry.")
+
+
+func set_active(active: bool) -> void:
+ _is_active = active
+ set_process(active)
+ if active:
+ _distance_update_remaining = 0.0
+ _update_target_volume()
+ _waves_audio.volume_db = _target_volume_db
+ if not _waves_audio.playing and _waves_audio.stream != null:
+ _waves_audio.play()
+ else:
+ _waves_audio.stop()
+ _waves_audio.volume_db = -80.0
+
+
+func _process(delta: float) -> void:
+ if not _is_active:
+ return
+ _distance_update_remaining -= delta
+ if _distance_update_remaining <= 0.0:
+ _distance_update_remaining = distance_update_interval
+ _update_target_volume()
+ _waves_audio.volume_db = lerpf(
+ _waves_audio.volume_db,
+ _target_volume_db,
+ 1.0 - exp(-volume_smoothing_speed * delta),
+ )
+
+
+func _update_target_volume() -> void:
+ if _listener == null or _shoreline_segments.is_empty():
+ _target_volume_db = -80.0
+ return
+ var listener_position := Vector2(
+ _listener.global_position.x,
+ _listener.global_position.z,
+ )
+ var distance := distance_to_segments(listener_position, _shoreline_segments)
+ var blend := clampf(
+ inverse_lerp(near_distance, maxf(near_distance + 0.01, far_distance), distance),
+ 0.0,
+ 1.0,
+ )
+ _target_volume_db = lerpf(near_volume_db, far_volume_db, smoothstep(0.0, 1.0, blend))
+
+
+func _extract_shoreline_segments(
+ shoreline_mesh: MeshInstance3D,
+) -> Array[PackedVector2Array]:
+ var segments: Array[PackedVector2Array] = []
+ var seen_segments: Dictionary = {}
+ if shoreline_mesh == null or shoreline_mesh.mesh == null:
+ return segments
+ for surface_index in shoreline_mesh.mesh.get_surface_count():
+ var arrays := shoreline_mesh.mesh.surface_get_arrays(surface_index)
+ var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
+ var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
+ if indices.is_empty():
+ continue
+ for index_offset in range(0, indices.size() - 2, 3):
+ var triangle := PackedVector2Array()
+ for corner in 3:
+ var vertex := shoreline_mesh.to_global(
+ vertices[indices[index_offset + corner]]
+ )
+ triangle.append(Vector2(vertex.x, vertex.z))
+ _append_unique_segment(segments, seen_segments, triangle[0], triangle[1])
+ _append_unique_segment(segments, seen_segments, triangle[1], triangle[2])
+ _append_unique_segment(segments, seen_segments, triangle[2], triangle[0])
+ return segments
+
+
+func _append_unique_segment(
+ segments: Array[PackedVector2Array],
+ seen_segments: Dictionary,
+ start: Vector2,
+ end: Vector2,
+) -> void:
+ if start.distance_squared_to(end) <= 0.000001:
+ return
+ # The baked ribbon repeats shared triangle edges. Quantized canonical keys
+ # keep only distinct segments without an O(n²) startup pass.
+ var first := start
+ var second := end
+ if first.x > second.x or (is_equal_approx(first.x, second.x) and first.y > second.y):
+ first = end
+ second = start
+ var key := "%d,%d:%d,%d" % [
+ roundi(first.x * 10000.0),
+ roundi(first.y * 10000.0),
+ roundi(second.x * 10000.0),
+ roundi(second.y * 10000.0),
+ ]
+ if seen_segments.has(key):
+ return
+ seen_segments[key] = true
+ segments.append(PackedVector2Array([start, end]))
+
+
+static func distance_to_segments(
+ point: Vector2,
+ segments: Array[PackedVector2Array],
+) -> float:
+ var closest_squared := INF
+ for segment in segments:
+ if segment.size() < 2:
+ continue
+ var closest := Geometry2D.get_closest_point_to_segment(
+ point,
+ segment[0],
+ segment[1],
+ )
+ closest_squared = minf(closest_squared, point.distance_squared_to(closest))
+ return sqrt(closest_squared) if closest_squared < INF else INF
+
+
+func _configure_audio_loop(stream: AudioStream) -> void:
+ var wav := stream as AudioStreamWAV
+ if wav == null:
+ return
+ wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
+ if wav.loop_end <= wav.loop_begin:
+ wav.loop_begin = 0
+ wav.loop_end = int(round(wav.get_length() * wav.mix_rate))
diff --git a/world/shoreline_ambience.gd.uid b/world/shoreline_ambience.gd.uid
new file mode 100644
index 0000000..3188ec7
--- /dev/null
+++ b/world/shoreline_ambience.gd.uid
@@ -0,0 +1 @@
+uid://b0c3o2vy76fg3
diff --git a/world/test_world.gd b/world/test_world.gd
index ce389f7..a3ea397 100644
--- a/world/test_world.gd
+++ b/world/test_world.gd
@@ -15,11 +15,13 @@ const FishingShopInteractionType = preload(
@onready var _fishing_shop: FishingShopInteractionType = (
_starter_island.get_fishing_shop()
)
+@onready var _world_environment: WorldEnvironment = $Environment/WorldEnvironment
+@onready var _sun: DirectionalLight3D = $Environment/Sun
func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
var triggers: Array[PlayerWaterTrigger] = []
- for region: GrayboxRegion in _get_regions():
+ for region: WorldRegion in _get_regions():
triggers.append_array(region.get_water_recovery_triggers())
triggers.append(_below_world_failsafe)
return triggers
@@ -27,7 +29,7 @@ func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
var points: Array[SafeRespawnPoint] = []
- for region: GrayboxRegion in _get_regions():
+ for region: WorldRegion in _get_regions():
points.append_array(region.get_safe_respawn_points())
return points
@@ -40,21 +42,29 @@ func get_player_spawn_transform() -> Transform3D:
return _starter_island.get_player_spawn_transform()
+func get_world_environment() -> WorldEnvironment:
+ return _world_environment
+
+
+func get_sun() -> DirectionalLight3D:
+ return _sun
+
+
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
var waters: Array[FishableWaterRegion] = []
- for region: GrayboxRegion in _get_regions():
+ for region: WorldRegion in _get_regions():
waters.append_array(region.get_fishable_water_regions())
return waters
-func get_pelican_convenience_landmark() -> Node3D:
- return _starter_island.get_pelican_landmark()
+func get_saltwater_shoreline_mesh() -> MeshInstance3D:
+ return _starter_island.get_saltwater_shoreline_mesh()
-func _get_regions() -> Array[GrayboxRegion]:
- var regions: Array[GrayboxRegion] = []
+func _get_regions() -> Array[WorldRegion]:
+ var regions: Array[WorldRegion] = []
for child: Node in _regions_root.get_children():
- var region: GrayboxRegion = child as GrayboxRegion
+ var region: WorldRegion = child as WorldRegion
if region != null:
regions.append(region)
return regions
diff --git a/world/test_world.tscn b/world/test_world.tscn
index b5d4f20..b6345fb 100644
--- a/world/test_world.tscn
+++ b/world/test_world.tscn
@@ -2,23 +2,17 @@
[ext_resource type="Script" path="res://world/test_world.gd" id="1_world"]
[ext_resource type="PackedScene" path="res://world/regions/starter_island_region.tscn" id="2_island"]
-[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="3_water_trigger"]
-
-[sub_resource type="Environment" id="Environment"]
-background_mode = 1
-background_color = Color(0.48, 0.74, 0.92, 1)
-ambient_light_source = 3
-ambient_light_color = Color(0.72, 0.78, 0.84, 1)
-ambient_light_energy = 0.72
+[ext_resource type="Script" path="res://world/below_world_failsafe.gd" id="3_water_trigger"]
+[ext_resource type="Environment" path="res://world/environment/netfishing_environment.tres" id="4_environment"]
[sub_resource type="BoxShape3D" id="NorthSouthBoundsShape"]
-size = Vector3(120, 14, 2)
+size = Vector3(168.8772, 14, 2)
[sub_resource type="BoxShape3D" id="EastWestBoundsShape"]
-size = Vector3(2, 14, 90)
+size = Vector3(2, 14, 126.45079)
[sub_resource type="BoxShape3D" id="FailsafeShape"]
-size = Vector3(118, 4, 88)
+size = Vector3(166.8772, 4, 124.45079)
[node name="TestWorld" type="Node3D"]
script = ExtResource("1_world")
@@ -26,12 +20,15 @@ script = ExtResource("1_world")
[node name="Environment" type="Node3D" parent="."]
[node name="WorldEnvironment" type="WorldEnvironment" parent="Environment"]
-environment = SubResource("Environment")
+environment = ExtResource("4_environment")
[node name="Sun" type="DirectionalLight3D" parent="Environment"]
rotation_degrees = Vector3(-54, -32, 0)
-light_energy = 1.1
-shadow_enabled = true
+light_color = Color(0.88, 0.94, 0.96, 1)
+light_energy = 0.1
+shadow_enabled = false
+shadow_opacity = 0.58
+shadow_blur = 2.0
[node name="Regions" type="Node3D" parent="."]
@@ -46,31 +43,31 @@ collision_mask = 2
script = ExtResource("3_water_trigger")
surface_height = 0.3
-[node name="FailsafeShape" type="CollisionShape3D" parent="Safety/BelowWorldFailsafe"]
+[node name="Coverage" type="CollisionShape3D" parent="Safety/BelowWorldFailsafe"]
shape = SubResource("FailsafeShape")
[node name="WorldBounds" type="Node3D" parent="."]
[node name="North" type="StaticBody3D" parent="WorldBounds"]
-position = Vector3(12, 5, 45)
+position = Vector3(12, 5, 63.225395)
[node name="Shape" type="CollisionShape3D" parent="WorldBounds/North"]
shape = SubResource("NorthSouthBoundsShape")
[node name="South" type="StaticBody3D" parent="WorldBounds"]
-position = Vector3(12, 5, -45)
+position = Vector3(12, 5, -63.225395)
[node name="Shape" type="CollisionShape3D" parent="WorldBounds/South"]
shape = SubResource("NorthSouthBoundsShape")
[node name="West" type="StaticBody3D" parent="WorldBounds"]
-position = Vector3(-48, 5, 0)
+position = Vector3(-72.4386, 5, 0)
[node name="Shape" type="CollisionShape3D" parent="WorldBounds/West"]
shape = SubResource("EastWestBoundsShape")
[node name="East" type="StaticBody3D" parent="WorldBounds"]
-position = Vector3(72, 5, 0)
+position = Vector3(96.4386, 5, 0)
[node name="Shape" type="CollisionShape3D" parent="WorldBounds/East"]
shape = SubResource("EastWestBoundsShape")
diff --git a/world/water/shoreline_ribbon_baker.gd b/world/water/shoreline_ribbon_baker.gd
new file mode 100644
index 0000000..a88b6e4
--- /dev/null
+++ b/world/water/shoreline_ribbon_baker.gd
@@ -0,0 +1,139 @@
+@tool
+class_name ShorelineRibbonBaker
+extends Node
+
+@export var water_bodies: Array[ShorelineRibbonConfig] = []
+@export_enum("Off", "Raw", "Simplified", "Smoothed") var debug_path_stage := 0
+@export_tool_button("Rebuild Shoreline Ribbons")
+var rebuild_shorelines: Callable = rebuild_all
+
+
+func rebuild_all() -> Array[Dictionary]:
+ var results: Array[Dictionary] = []
+ for configuration: ShorelineRibbonConfig in water_bodies:
+ var result := _rebuild(configuration)
+ if result.is_empty():
+ return []
+ results.append(result)
+ _update_debug_display(results)
+ return results
+
+
+func _rebuild(configuration: ShorelineRibbonConfig) -> Dictionary:
+ if configuration == null:
+ push_error("Shoreline ribbon configuration is missing.")
+ return {}
+ if configuration.water_type != WaterType.Type.SALT_WATER:
+ return {
+ "skipped": true,
+ "water_type": configuration.water_type,
+ "output_path": configuration.output_resource_path,
+ }
+ var source := get_node_or_null(configuration.terrain_source) as Node3D
+ if source == null:
+ push_error(
+ "Shoreline terrain source was not found: %s"
+ % configuration.terrain_source
+ )
+ return {}
+ if configuration.output_resource_path.is_empty():
+ push_error("Shoreline output resource path is empty.")
+ return {}
+ var faces := _terrain_faces(source)
+ if faces.is_empty():
+ push_error("Configured shoreline terrain source exposes no triangles.")
+ return {}
+ var result := ShorelineRibbonGenerator.generate(
+ faces,
+ configuration.water_height,
+ configuration.generation_bounds,
+ configuration.water_reference,
+ configuration.water_is_inside,
+ configuration.simplification_tolerance,
+ configuration.smoothing_iterations,
+ configuration.resample_spacing,
+ configuration.corner_rounding_distance,
+ )
+ var output_directory := configuration.output_resource_path.get_base_dir()
+ DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_directory))
+ var save_error := ResourceSaver.save(
+ result["mesh"], configuration.output_resource_path
+ )
+ if save_error != OK:
+ push_error(
+ "Failed to save %s: %s"
+ % [configuration.output_resource_path, error_string(save_error)]
+ )
+ return {}
+ result["output_path"] = configuration.output_resource_path
+ result["water_height"] = configuration.water_height
+ result["water_type"] = configuration.water_type
+ result["skipped"] = false
+ return result
+
+
+func _update_debug_display(results: Array[Dictionary]) -> void:
+ var previous := get_node_or_null("_ShorelinePathDebug")
+ if previous != null:
+ previous.queue_free()
+ if not Engine.is_editor_hint() or debug_path_stage == 0:
+ return
+ var debug_mesh := ImmediateMesh.new()
+ var debug_material := StandardMaterial3D.new()
+ debug_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ debug_material.albedo_color = Color(1.0, 0.35, 0.65, 1.0)
+ var stage_key: String = [
+ "", "debug_raw_paths", "debug_simplified_paths", "debug_smoothed_paths"
+ ][debug_path_stage]
+ for result: Dictionary in results:
+ if result.get("skipped", false):
+ continue
+ var height := float(result["water_height"]) + 0.06
+ for path_data: Dictionary in result[stage_key]:
+ var points: PackedVector2Array = path_data["points"]
+ if points.size() < 2:
+ continue
+ debug_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP, debug_material)
+ for point: Vector2 in points:
+ debug_mesh.surface_add_vertex(Vector3(point.x, height, point.y))
+ if path_data["closed"]:
+ debug_mesh.surface_add_vertex(Vector3(points[0].x, height, points[0].y))
+ debug_mesh.surface_end()
+ var debug_instance := MeshInstance3D.new()
+ debug_instance.name = "_ShorelinePathDebug"
+ debug_instance.mesh = debug_mesh
+ add_child(debug_instance)
+
+
+func _terrain_faces(source: Node3D) -> PackedVector3Array:
+ var result := PackedVector3Array()
+ if source is CollisionShape3D:
+ var collision_shape := source as CollisionShape3D
+ var concave_shape := collision_shape.shape as ConcavePolygonShape3D
+ if concave_shape == null:
+ return result
+ for vertex: Vector3 in concave_shape.get_faces():
+ result.append(_to_map_space(source, vertex))
+ return result
+ if source is MeshInstance3D:
+ var mesh_instance := source as MeshInstance3D
+ if mesh_instance.mesh == null:
+ return result
+ for surface: int in mesh_instance.mesh.get_surface_count():
+ var arrays := mesh_instance.mesh.surface_get_arrays(surface)
+ var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
+ var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
+ if indices.is_empty():
+ for vertex: Vector3 in vertices:
+ result.append(_to_map_space(source, vertex))
+ else:
+ for index: int in indices:
+ result.append(_to_map_space(source, vertices[index]))
+ return result
+
+
+func _to_map_space(source: Node3D, vertex: Vector3) -> Vector3:
+ var map_root := get_parent() as Node3D
+ if map_root == null:
+ return source.to_global(vertex)
+ return map_root.to_local(source.to_global(vertex))
diff --git a/world/water/shoreline_ribbon_baker.gd.uid b/world/water/shoreline_ribbon_baker.gd.uid
new file mode 100644
index 0000000..2991de4
--- /dev/null
+++ b/world/water/shoreline_ribbon_baker.gd.uid
@@ -0,0 +1 @@
+uid://bj704rvwwh77b
diff --git a/world/water/shoreline_ribbon_config.gd b/world/water/shoreline_ribbon_config.gd
new file mode 100644
index 0000000..682bfed
--- /dev/null
+++ b/world/water/shoreline_ribbon_config.gd
@@ -0,0 +1,15 @@
+class_name ShorelineRibbonConfig
+extends Resource
+
+@export_node_path("CollisionShape3D", "MeshInstance3D") var terrain_source: NodePath
+@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER
+@export var water_height := 0.0
+@export var generation_bounds := Rect2()
+@export var water_reference := Vector2.ZERO
+@export var water_is_inside := true
+@export_file("*.tres") var output_resource_path := ""
+@export_group("Optional Smoothing Overrides")
+@export var simplification_tolerance := -1.0
+@export_range(-1, 4, 1) var smoothing_iterations := -1
+@export var resample_spacing := -1.0
+@export var corner_rounding_distance := -1.0
diff --git a/world/water/shoreline_ribbon_config.gd.uid b/world/water/shoreline_ribbon_config.gd.uid
new file mode 100644
index 0000000..6a563e3
--- /dev/null
+++ b/world/water/shoreline_ribbon_config.gd.uid
@@ -0,0 +1 @@
+uid://c8hk3eojujyax
diff --git a/world/water/shoreline_ribbon_generator.gd b/world/water/shoreline_ribbon_generator.gd
new file mode 100644
index 0000000..9708b06
--- /dev/null
+++ b/world/water/shoreline_ribbon_generator.gd
@@ -0,0 +1,636 @@
+class_name ShorelineRibbonGenerator
+extends RefCounted
+
+const WATER_PLANE_EPSILON := 0.001
+const ENDPOINT_MERGE_TOLERANCE := 0.03
+const LOOP_CLOSURE_TOLERANCE := 0.06
+const MINIMUM_FRAGMENT_LENGTH := 2.0
+const SIMPLIFICATION_TOLERANCE := 0.55
+const SMOOTHING_ITERATIONS := 3
+const RESAMPLE_SPACING := 0.16
+const CORNER_ROUNDING_DISTANCE := 2.4
+const CORNER_ROUNDING_START_DEGREES := 24.0
+const CORNER_ROUNDING_FULL_DEGREES := 78.0
+const MAXIMUM_JOIN_SCALE := 1.0
+const RIBBON_REACH_GROWTH_PER_METER := 0.52
+const RIBBON_WIDTH := 1.35
+const LAND_INSET := 0.12
+const SURFACE_OFFSET := 0.018
+
+
+static func generate(
+ faces: PackedVector3Array,
+ water_height: float,
+ bounds: Rect2,
+ water_reference: Vector2,
+ water_is_inside: bool,
+ simplification_override := -1.0,
+ smoothing_iterations_override := -1,
+ resample_spacing_override := -1.0,
+ corner_rounding_override := -1.0
+) -> Dictionary:
+ var simplification := (
+ SIMPLIFICATION_TOLERANCE
+ if simplification_override < 0.0
+ else simplification_override
+ )
+ var smoothing_iterations := (
+ SMOOTHING_ITERATIONS
+ if smoothing_iterations_override < 0
+ else smoothing_iterations_override
+ )
+ var resample_spacing := (
+ RESAMPLE_SPACING
+ if resample_spacing_override < 0.0
+ else resample_spacing_override
+ )
+ var corner_rounding := (
+ CORNER_ROUNDING_DISTANCE
+ if corner_rounding_override < 0.0
+ else corner_rounding_override
+ )
+ var segments := _extract_segments(faces, water_height, bounds)
+ var raw_paths := _stitch_segments(segments)
+ var processed_paths: Array[Dictionary] = []
+ var simplified_paths: Array[Dictionary] = []
+ var raw_point_count := 0
+ var simplified_point_count := 0
+ var smoothed_point_count := 0
+ for path_data: Dictionary in raw_paths:
+ var raw_points: PackedVector2Array = path_data["points"]
+ var closed: bool = path_data["closed"]
+ if _path_length(raw_points, closed) < MINIMUM_FRAGMENT_LENGTH:
+ continue
+ var simplified := _simplify(raw_points, closed, simplification)
+ simplified_paths.append({"points": simplified, "closed": closed})
+ var rounded := _round_sharp_corners(
+ simplified,
+ closed,
+ corner_rounding,
+ )
+ var smoothed := _chaikin(rounded, closed, smoothing_iterations)
+ var resampled := _resample(smoothed, closed, resample_spacing)
+ if resampled.size() < (3 if closed else 2):
+ continue
+ raw_point_count += raw_points.size()
+ simplified_point_count += simplified.size()
+ smoothed_point_count += resampled.size()
+ processed_paths.append({"points": resampled, "closed": closed})
+ var mesh := _build_mesh(
+ processed_paths,
+ water_height,
+ water_reference,
+ water_is_inside
+ )
+ return {
+ "mesh": mesh,
+ "segment_count": segments.size(),
+ "loop_count": processed_paths.size(),
+ "raw_point_count": raw_point_count,
+ "simplified_point_count": simplified_point_count,
+ "smoothed_point_count": smoothed_point_count,
+ "triangle_count": _mesh_triangle_count(mesh),
+ "debug_raw_paths": raw_paths,
+ "debug_simplified_paths": simplified_paths,
+ "debug_smoothed_paths": processed_paths,
+ }
+
+
+static func _extract_segments(
+ faces: PackedVector3Array,
+ water_height: float,
+ bounds: Rect2
+) -> Array[PackedVector2Array]:
+ var segments: Array[PackedVector2Array] = []
+ for index: int in range(0, faces.size(), 3):
+ var triangle: Array[Vector3] = [
+ faces[index], faces[index + 1], faces[index + 2]
+ ]
+ var hits := PackedVector2Array()
+ for edge: int in 3:
+ var a := triangle[edge]
+ var b := triangle[(edge + 1) % 3]
+ var distance_a := a.y - water_height
+ var distance_b := b.y - water_height
+ if (
+ absf(distance_a) <= WATER_PLANE_EPSILON
+ and absf(distance_b) <= WATER_PLANE_EPSILON
+ ):
+ continue
+ if not (
+ distance_a * distance_b < 0.0
+ or absf(distance_a) <= WATER_PLANE_EPSILON
+ or absf(distance_b) <= WATER_PLANE_EPSILON
+ ):
+ continue
+ var denominator := distance_a - distance_b
+ var amount := (
+ 0.0
+ if absf(denominator) <= WATER_PLANE_EPSILON
+ else distance_a / denominator
+ )
+ var hit3 := a.lerp(b, clampf(amount, 0.0, 1.0))
+ var hit := Vector2(hit3.x, hit3.z)
+ if bounds.has_point(hit) and not _contains_near(hits, hit):
+ hits.append(hit)
+ if (
+ hits.size() == 2
+ and hits[0].distance_to(hits[1]) > WATER_PLANE_EPSILON
+ ):
+ segments.append(hits)
+ return segments
+
+
+static func _stitch_segments(
+ segments: Array[PackedVector2Array]
+) -> Array[Dictionary]:
+ var point_by_key: Dictionary = {}
+ var adjacency: Dictionary = {}
+ var unused_edges: Dictionary = {}
+ for segment: PackedVector2Array in segments:
+ var a_key := _point_key(segment[0])
+ var b_key := _point_key(segment[1])
+ if a_key == b_key:
+ continue
+ point_by_key[a_key] = segment[0]
+ point_by_key[b_key] = segment[1]
+ if not adjacency.has(a_key):
+ adjacency[a_key] = []
+ if not adjacency.has(b_key):
+ adjacency[b_key] = []
+ var edge_key := _edge_key(a_key, b_key)
+ if unused_edges.has(edge_key):
+ continue
+ (adjacency[a_key] as Array).append(b_key)
+ (adjacency[b_key] as Array).append(a_key)
+ unused_edges[edge_key] = true
+
+ var starts: Array = []
+ for key: Vector2i in adjacency:
+ if (adjacency[key] as Array).size() != 2:
+ starts.append(key)
+ for key: Vector2i in adjacency:
+ if not starts.has(key):
+ starts.append(key)
+
+ var paths: Array[Dictionary] = []
+ for start: Vector2i in starts:
+ while _has_unused_neighbor(start, adjacency, unused_edges):
+ var walked := _walk_path(start, point_by_key, adjacency, unused_edges)
+ if (walked["points"] as PackedVector2Array).size() >= 2:
+ paths.append(walked)
+ return paths
+
+
+static func _walk_path(
+ start: Vector2i,
+ point_by_key: Dictionary,
+ adjacency: Dictionary,
+ unused_edges: Dictionary
+) -> Dictionary:
+ var points := PackedVector2Array()
+ var previous := Vector2i(2147483647, 2147483647)
+ var current := start
+ var closed := false
+ var guard := unused_edges.size() + 2
+ while guard > 0:
+ guard -= 1
+ points.append(point_by_key[current])
+ var next_key := Vector2i(2147483647, 2147483647)
+ for candidate: Vector2i in adjacency[current]:
+ if candidate == previous and (adjacency[current] as Array).size() > 1:
+ continue
+ if unused_edges.get(_edge_key(current, candidate), false):
+ next_key = candidate
+ break
+ if next_key.x == 2147483647:
+ for candidate: Vector2i in adjacency[current]:
+ if unused_edges.get(_edge_key(current, candidate), false):
+ next_key = candidate
+ break
+ if next_key.x == 2147483647:
+ break
+ unused_edges[_edge_key(current, next_key)] = false
+ previous = current
+ current = next_key
+ if current == start:
+ closed = true
+ break
+ return {"points": points, "closed": closed}
+
+
+static func _simplify(
+ points: PackedVector2Array,
+ closed: bool,
+ tolerance: float
+) -> PackedVector2Array:
+ if points.size() <= (4 if closed else 2):
+ return points
+ if not closed:
+ return _rdp_open(points, tolerance)
+ var split_a := 0
+ var split_b := 1
+ var greatest_distance := 0.0
+ for a: int in points.size():
+ for b: int in range(a + 1, points.size()):
+ var distance := points[a].distance_squared_to(points[b])
+ if distance > greatest_distance:
+ greatest_distance = distance
+ split_a = a
+ split_b = b
+ var first_arc := _closed_arc(points, split_a, split_b)
+ var second_arc := _closed_arc(points, split_b, split_a)
+ var first_result := _rdp_open(first_arc, tolerance)
+ var second_result := _rdp_open(second_arc, tolerance)
+ var result := PackedVector2Array()
+ for index: int in first_result.size() - 1:
+ result.append(first_result[index])
+ for index: int in second_result.size() - 1:
+ result.append(second_result[index])
+ return result if result.size() >= 4 else points
+
+
+static func _rdp_open(
+ points: PackedVector2Array,
+ tolerance: float
+) -> PackedVector2Array:
+ if points.size() <= 2:
+ return points
+ var greatest_distance := 0.0
+ var split_index := 0
+ for index: int in range(1, points.size() - 1):
+ var distance := _point_segment_distance(
+ points[index], points[0], points[points.size() - 1]
+ )
+ if distance > greatest_distance:
+ greatest_distance = distance
+ split_index = index
+ if greatest_distance <= tolerance:
+ return PackedVector2Array([points[0], points[points.size() - 1]])
+ var left := _rdp_open(points.slice(0, split_index + 1), tolerance)
+ var right := _rdp_open(points.slice(split_index), tolerance)
+ var result := PackedVector2Array()
+ for index: int in left.size() - 1:
+ result.append(left[index])
+ result.append_array(right)
+ return result
+
+
+static func _closed_arc(
+ points: PackedVector2Array,
+ start: int,
+ finish: int
+) -> PackedVector2Array:
+ var result := PackedVector2Array()
+ var index := start
+ result.append(points[index])
+ while index != finish:
+ index = (index + 1) % points.size()
+ result.append(points[index])
+ return result
+
+
+static func _chaikin(
+ points: PackedVector2Array,
+ closed: bool,
+ iterations: int
+) -> PackedVector2Array:
+ var result := points
+ for _iteration: int in iterations:
+ var next := PackedVector2Array()
+ if not closed:
+ next.append(result[0])
+ var edge_count := result.size() if closed else result.size() - 1
+ for index: int in edge_count:
+ var a := result[index]
+ var b := result[(index + 1) % result.size()]
+ next.append(a.lerp(b, 0.25))
+ next.append(a.lerp(b, 0.75))
+ if not closed:
+ next.append(result[result.size() - 1])
+ result = next
+ return result
+
+
+static func _round_sharp_corners(
+ points: PackedVector2Array,
+ closed: bool,
+ rounding_distance: float,
+) -> PackedVector2Array:
+ if rounding_distance <= WATER_PLANE_EPSILON or points.size() < 3:
+ return points
+ var result := PackedVector2Array()
+ for index: int in points.size():
+ if not closed and (index == 0 or index == points.size() - 1):
+ result.append(points[index])
+ continue
+ var previous := points[(index - 1 + points.size()) % points.size()]
+ var point := points[index]
+ var following := points[(index + 1) % points.size()]
+ var incoming_vector := point - previous
+ var outgoing_vector := following - point
+ var incoming_length := incoming_vector.length()
+ var outgoing_length := outgoing_vector.length()
+ if (
+ incoming_length <= WATER_PLANE_EPSILON
+ or outgoing_length <= WATER_PLANE_EPSILON
+ ):
+ result.append(point)
+ continue
+ var incoming := incoming_vector / incoming_length
+ var outgoing := outgoing_vector / outgoing_length
+ var turn_degrees := rad_to_deg(
+ acos(clampf(incoming.dot(outgoing), -1.0, 1.0))
+ )
+ var corner_weight := smoothstep(
+ CORNER_ROUNDING_START_DEGREES,
+ CORNER_ROUNDING_FULL_DEGREES,
+ turn_degrees,
+ )
+ var cut_distance := minf(
+ rounding_distance * corner_weight,
+ minf(incoming_length, outgoing_length) * 0.44,
+ )
+ if cut_distance <= WATER_PLANE_EPSILON:
+ result.append(point)
+ continue
+ var entry := point - incoming * cut_distance
+ var exit := point + outgoing * cut_distance
+ var curve_steps := maxi(3, ceili(cut_distance / 0.22))
+ for step: int in curve_steps + 1:
+ var amount := float(step) / float(curve_steps)
+ var first := entry.lerp(point, amount)
+ var second := point.lerp(exit, amount)
+ var rounded_point := first.lerp(second, amount)
+ if (
+ result.is_empty()
+ or result[result.size() - 1].distance_to(rounded_point)
+ > WATER_PLANE_EPSILON
+ ):
+ result.append(rounded_point)
+ return result
+
+
+static func _resample(
+ points: PackedVector2Array,
+ closed: bool,
+ spacing: float
+) -> PackedVector2Array:
+ var total_length := _path_length(points, closed)
+ if total_length <= spacing:
+ return points
+ var count := maxi(roundi(total_length / spacing), 3 if closed else 2)
+ var actual_spacing := total_length / float(count if closed else count - 1)
+ var result := PackedVector2Array()
+ var edge := 0
+ var edge_start_distance := 0.0
+ var edge_length := points[0].distance_to(points[1])
+ for sample: int in count:
+ var target := actual_spacing * sample
+ while target > edge_start_distance + edge_length and edge < points.size() - 1:
+ edge_start_distance += edge_length
+ edge += 1
+ if edge >= points.size() - 1:
+ edge_length = points[edge].distance_to(points[0]) if closed else 0.0
+ else:
+ edge_length = points[edge].distance_to(points[edge + 1])
+ var next_index := (edge + 1) % points.size()
+ var amount := (
+ 0.0
+ if edge_length <= WATER_PLANE_EPSILON
+ else (target - edge_start_distance) / edge_length
+ )
+ result.append(points[edge].lerp(points[next_index], clampf(amount, 0.0, 1.0)))
+ return result
+
+
+static func _build_mesh(
+ paths: Array[Dictionary],
+ water_height: float,
+ water_reference: Vector2,
+ water_is_inside: bool
+) -> ArrayMesh:
+ var vertices := PackedVector3Array()
+ var normals := PackedVector3Array()
+ var uvs := PackedVector2Array()
+ var indices := PackedInt32Array()
+ for path_data: Dictionary in paths:
+ var points: PackedVector2Array = path_data["points"]
+ var closed: bool = path_data["closed"]
+ var closed_water_side := (
+ _closed_path_water_side(points, water_is_inside)
+ if closed
+ else 0.0
+ )
+ var base_index := vertices.size()
+ var water_normals := PackedVector2Array()
+ var join_scales := PackedFloat32Array()
+ var water_reaches := PackedFloat32Array()
+ for index: int in points.size():
+ var previous := points[(index - 1 + points.size()) % points.size()]
+ var following := points[(index + 1) % points.size()]
+ if not closed:
+ previous = points[maxi(index - 1, 0)]
+ following = points[mini(index + 1, points.size() - 1)]
+ var point := points[index]
+ var incoming := previous.direction_to(point)
+ var outgoing := point.direction_to(following)
+ if incoming.is_zero_approx():
+ incoming = outgoing
+ if outgoing.is_zero_approx():
+ outgoing = incoming
+ var incoming_normal := Vector2(-incoming.y, incoming.x)
+ var outgoing_normal := Vector2(-outgoing.y, outgoing.x)
+ var water_normal := incoming_normal + outgoing_normal
+ if water_normal.is_zero_approx():
+ water_normal = outgoing_normal
+ water_normal = water_normal.normalized()
+ if closed:
+ water_normal *= closed_water_side
+ else:
+ var toward_reference := point.direction_to(water_reference)
+ if (
+ (water_is_inside and water_normal.dot(toward_reference) < 0.0)
+ or (not water_is_inside and water_normal.dot(toward_reference) > 0.0)
+ ):
+ water_normal = -water_normal
+ var join_scale := minf(
+ 1.0 / maxf(absf(water_normal.dot(outgoing_normal)), 0.55),
+ MAXIMUM_JOIN_SCALE,
+ )
+ var water_reach := _safe_water_reach(
+ incoming,
+ outgoing,
+ water_normal,
+ previous.distance_to(point),
+ point.distance_to(following),
+ (RIBBON_WIDTH - LAND_INSET) * join_scale,
+ )
+ water_normals.append(water_normal)
+ join_scales.append(join_scale)
+ water_reaches.append(water_reach)
+ water_reaches = _smooth_water_reaches(
+ points,
+ water_reaches,
+ closed,
+ )
+ var path_distance := 0.0
+ for index: int in points.size():
+ if index > 0:
+ path_distance += points[index - 1].distance_to(points[index])
+ var point := points[index]
+ var water_normal := water_normals[index]
+ var join_scale := join_scales[index]
+ var land_point := (
+ point - water_normal * LAND_INSET * join_scale
+ )
+ var water_point := (
+ point
+ + water_normal * water_reaches[index]
+ )
+ vertices.append(Vector3(land_point.x, water_height + SURFACE_OFFSET, land_point.y))
+ vertices.append(Vector3(water_point.x, water_height + SURFACE_OFFSET, water_point.y))
+ normals.append(Vector3.UP)
+ normals.append(Vector3.UP)
+ uvs.append(Vector2(path_distance, 0.0))
+ uvs.append(Vector2(path_distance, 1.0))
+ var edge_count := points.size() if closed else points.size() - 1
+ for index: int in edge_count:
+ var next := (index + 1) % points.size()
+ var a := base_index + index * 2
+ var b := a + 1
+ var c := base_index + next * 2
+ var d := c + 1
+ indices.append_array(PackedInt32Array([a, c, b, b, c, d]))
+ var arrays := []
+ arrays.resize(Mesh.ARRAY_MAX)
+ arrays[Mesh.ARRAY_VERTEX] = vertices
+ arrays[Mesh.ARRAY_NORMAL] = normals
+ arrays[Mesh.ARRAY_TEX_UV] = uvs
+ arrays[Mesh.ARRAY_INDEX] = indices
+ var mesh := ArrayMesh.new()
+ if not vertices.is_empty():
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
+ return mesh
+
+
+static func _safe_water_reach(
+ incoming: Vector2,
+ outgoing: Vector2,
+ water_normal: Vector2,
+ incoming_length: float,
+ outgoing_length: float,
+ desired_reach: float,
+) -> float:
+ var turn_cross := incoming.cross(outgoing)
+ var water_side := incoming.cross(water_normal)
+ if turn_cross * water_side <= 0.0:
+ return desired_reach
+ var turn_angle := acos(clampf(incoming.dot(outgoing), -1.0, 1.0))
+ if turn_angle <= WATER_PLANE_EPSILON:
+ return desired_reach
+ var radius := minf(incoming_length, outgoing_length) / maxf(
+ 2.0 * sin(turn_angle * 0.5),
+ WATER_PLANE_EPSILON,
+ )
+ return minf(desired_reach, maxf(radius * 0.58, 0.24))
+
+
+static func _smooth_water_reaches(
+ points: PackedVector2Array,
+ reaches: PackedFloat32Array,
+ closed: bool,
+) -> PackedFloat32Array:
+ var result := reaches.duplicate()
+ if result.size() < 2:
+ return result
+ for _pass: int in 3:
+ var forward_start := 0 if closed else 1
+ for index: int in range(forward_start, result.size()):
+ var previous := (index - 1 + result.size()) % result.size()
+ var allowed := (
+ result[previous]
+ + points[previous].distance_to(points[index])
+ * RIBBON_REACH_GROWTH_PER_METER
+ )
+ result[index] = minf(result[index], allowed)
+ var backward_start := result.size() - 1 if closed else result.size() - 2
+ for index: int in range(backward_start, -1, -1):
+ var following := (index + 1) % result.size()
+ var allowed := (
+ result[following]
+ + points[index].distance_to(points[following])
+ * RIBBON_REACH_GROWTH_PER_METER
+ )
+ result[index] = minf(result[index], allowed)
+ return result
+
+
+static func _closed_path_water_side(
+ points: PackedVector2Array,
+ water_is_inside: bool,
+) -> float:
+ var signed_area_twice := 0.0
+ for index: int in points.size():
+ signed_area_twice += points[index].cross(
+ points[(index + 1) % points.size()]
+ )
+ var interior_side := 1.0 if signed_area_twice >= 0.0 else -1.0
+ return interior_side if water_is_inside else -interior_side
+
+
+static func _point_key(point: Vector2) -> Vector2i:
+ return Vector2i(
+ roundi(point.x / ENDPOINT_MERGE_TOLERANCE),
+ roundi(point.y / ENDPOINT_MERGE_TOLERANCE)
+ )
+
+
+static func _edge_key(a: Vector2i, b: Vector2i) -> String:
+ if a.x < b.x or (a.x == b.x and a.y <= b.y):
+ return "%d:%d|%d:%d" % [a.x, a.y, b.x, b.y]
+ return "%d:%d|%d:%d" % [b.x, b.y, a.x, a.y]
+
+
+static func _has_unused_neighbor(
+ key: Vector2i,
+ adjacency: Dictionary,
+ unused_edges: Dictionary
+) -> bool:
+ for neighbor: Vector2i in adjacency[key]:
+ if unused_edges.get(_edge_key(key, neighbor), false):
+ return true
+ return false
+
+
+static func _contains_near(points: PackedVector2Array, point: Vector2) -> bool:
+ for existing: Vector2 in points:
+ if existing.distance_to(point) <= WATER_PLANE_EPSILON:
+ return true
+ return false
+
+
+static func _point_segment_distance(point: Vector2, a: Vector2, b: Vector2) -> float:
+ var segment := b - a
+ if segment.length_squared() <= WATER_PLANE_EPSILON:
+ return point.distance_to(a)
+ var amount := clampf((point - a).dot(segment) / segment.length_squared(), 0.0, 1.0)
+ return point.distance_to(a + segment * amount)
+
+
+static func _path_length(points: PackedVector2Array, closed: bool) -> float:
+ var result := 0.0
+ for index: int in points.size() - 1:
+ result += points[index].distance_to(points[index + 1])
+ if closed and points.size() > 2:
+ result += points[points.size() - 1].distance_to(points[0])
+ return result
+
+
+static func _mesh_triangle_count(mesh: ArrayMesh) -> int:
+ if mesh.get_surface_count() == 0:
+ return 0
+ var arrays := mesh.surface_get_arrays(0)
+ return floori(
+ float((arrays[Mesh.ARRAY_INDEX] as PackedInt32Array).size()) / 3.0
+ )
diff --git a/world/water/shoreline_ribbon_generator.gd.uid b/world/water/shoreline_ribbon_generator.gd.uid
new file mode 100644
index 0000000..2edffe8
--- /dev/null
+++ b/world/water/shoreline_ribbon_generator.gd.uid
@@ -0,0 +1 @@
+uid://dhp7ctspabll
diff --git a/world/water/water_type.gd b/world/water/water_type.gd
new file mode 100644
index 0000000..6f8587f
--- /dev/null
+++ b/world/water/water_type.gd
@@ -0,0 +1,26 @@
+class_name WaterType
+extends RefCounted
+
+enum Type {
+ FRESH_WATER,
+ SALT_WATER,
+ OTHER,
+}
+
+const FRESH_WATER_MASK := 1 << Type.FRESH_WATER
+const SALT_WATER_MASK := 1 << Type.SALT_WATER
+const ALL_FISHABLE_MASK := FRESH_WATER_MASK | SALT_WATER_MASK
+
+
+static func mask_for(type: Type) -> int:
+ return 1 << int(type) if type != Type.OTHER else 0
+
+
+static func label(type: Type) -> String:
+ match type:
+ Type.FRESH_WATER:
+ return "Fresh Water"
+ Type.SALT_WATER:
+ return "Salt Water"
+ _:
+ return "Other"
diff --git a/world/water/water_type.gd.uid b/world/water/water_type.gd.uid
new file mode 100644
index 0000000..ba6b0e4
--- /dev/null
+++ b/world/water/water_type.gd.uid
@@ -0,0 +1 @@
+uid://nao2xukkyoc8
diff --git a/world/water_body.tscn b/world/water_body.tscn
new file mode 100644
index 0000000..77eb8f1
--- /dev/null
+++ b/world/water_body.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=8 format=3]
+
+[ext_resource type="Script" path="res://world/water_body_authoring.gd" id="1_authoring"]
+[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_fishing"]
+[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="3_recovery"]
+[ext_resource type="Material" path="res://world/materials/stylized_water.tres" id="4_water"]
+
+[sub_resource type="PlaneMesh" id="WaterSurfaceMesh"]
+resource_local_to_scene = true
+size = Vector2(10, 10)
+
+[sub_resource type="BoxShape3D" id="FishingVolumeShape"]
+resource_local_to_scene = true
+size = Vector3(10, 4, 10)
+
+[sub_resource type="BoxShape3D" id="RecoveryVolumeShape"]
+resource_local_to_scene = true
+size = Vector3(10, 4.8, 10)
+
+[node name="WaterBody" type="Node3D"]
+script = ExtResource("1_authoring")
+water_material = ExtResource("4_water")
+
+[node name="VisualWater" type="MeshInstance3D" parent="."]
+cast_shadow = 0
+material_override = ExtResource("4_water")
+mesh = SubResource("WaterSurfaceMesh")
+
+[node name="FishingRegion" type="Area3D" parent="."]
+collision_layer = 4
+collision_mask = 0
+monitoring = false
+script = ExtResource("2_fishing")
+surface_height_mode = 1
+
+[node name="Shape" type="CollisionShape3D" parent="FishingRegion"]
+position = Vector3(0, -2, 0)
+shape = SubResource("FishingVolumeShape")
+
+[node name="RecoveryRegion" type="Area3D" parent="."]
+position = Vector3(0, -2.4, 0)
+collision_layer = 8
+collision_mask = 2
+script = ExtResource("3_recovery")
+surface_height_mode = 1
+
+[node name="Shape" type="CollisionShape3D" parent="RecoveryRegion"]
+shape = SubResource("RecoveryVolumeShape")
diff --git a/world/water_body_authoring.gd b/world/water_body_authoring.gd
new file mode 100644
index 0000000..70ead79
--- /dev/null
+++ b/world/water_body_authoring.gd
@@ -0,0 +1,175 @@
+@tool
+class_name WaterBodyAuthoring
+extends Node3D
+
+const FishPoolType = preload("res://fish/fish_pool.gd")
+
+@export_group("Surface")
+## Visible water footprint in meters.
+@export_custom(
+ PROPERTY_HINT_RANGE,
+ "0.1,200.0,0.1,or_greater,suffix:m",
+)
+var surface_size: Vector2 = Vector2(10.0, 10.0):
+ set(value):
+ surface_size = Vector2(maxf(value.x, 0.1), maxf(value.y, 0.1))
+ _sync_owned_nodes()
+## Material applied to the visible water surface.
+@export var water_material: Material:
+ set(value):
+ water_material = value
+ _sync_owned_nodes()
+
+@export_group("Fishing Coverage")
+@export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m")
+var fishing_depth: float = 4.0:
+ set(value):
+ fishing_depth = maxf(value, 0.1)
+ _sync_owned_nodes()
+@export var fish_pool: FishPoolType:
+ set(value):
+ fish_pool = value
+ _sync_owned_nodes()
+@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER:
+ set(value):
+ water_type = value
+ _sync_owned_nodes()
+@export var location_tags: Array[StringName] = []:
+ set(value):
+ location_tags = value
+ _sync_owned_nodes()
+@export var selection_priority: int = 0:
+ set(value):
+ selection_priority = value
+ _sync_owned_nodes()
+
+@export_group("Recovery Coverage")
+@export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m")
+var recovery_depth: float = 4.8:
+ set(value):
+ recovery_depth = maxf(value, 0.1)
+ _sync_owned_nodes()
+
+@export_group("Owned Nodes")
+@export_node_path("MeshInstance3D")
+var visual_water_path: NodePath = ^"VisualWater"
+@export_node_path("CollisionShape3D")
+var fishing_shape_path: NodePath = ^"FishingRegion/Shape"
+@export_node_path("Area3D")
+var fishing_region_path: NodePath = ^"FishingRegion"
+@export_node_path("Area3D")
+var recovery_region_path: NodePath = ^"RecoveryRegion"
+@export_node_path("CollisionShape3D")
+var recovery_shape_path: NodePath = ^"RecoveryRegion/Shape"
+
+
+func _ready() -> void:
+ _sync_owned_nodes()
+ if Engine.is_editor_hint():
+ update_configuration_warnings()
+
+
+func _sync_owned_nodes() -> void:
+ if not is_inside_tree():
+ return
+ var visual_water := get_node_or_null(visual_water_path) as MeshInstance3D
+ if visual_water != null:
+ var plane_mesh := visual_water.mesh as PlaneMesh
+ if plane_mesh != null:
+ plane_mesh.size = surface_size
+ visual_water.material_override = water_material
+
+ var fishing_shape_node := (
+ get_node_or_null(fishing_shape_path) as CollisionShape3D
+ )
+ var fishing_region := (
+ get_node_or_null(fishing_region_path) as FishableWaterRegion
+ )
+ if fishing_region != null:
+ fishing_region.fish_pool = fish_pool
+ fishing_region.water_type = water_type
+ fishing_region.location_tags = location_tags.duplicate()
+ fishing_region.selection_priority = selection_priority
+ fishing_region.surface_height_mode = (
+ FishableWaterRegion.SurfaceHeightMode.PARENT_GLOBAL_Y
+ )
+ if fishing_shape_node != null:
+ var fishing_shape := fishing_shape_node.shape as BoxShape3D
+ if fishing_shape != null:
+ # The authored root is the one water-surface height. Keep the fishable
+ # volume entirely below that plane so visible water and interaction
+ # can be moved together without a second height to tune.
+ fishing_shape_node.position.y = -fishing_depth * 0.5
+ fishing_shape.size = Vector3(
+ surface_size.x,
+ fishing_depth,
+ surface_size.y
+ )
+
+ var recovery_region := (
+ get_node_or_null(recovery_region_path) as Area3D
+ )
+ if recovery_region != null:
+ recovery_region.position.y = -recovery_depth * 0.5
+ var recovery_shape_node := (
+ get_node_or_null(recovery_shape_path) as CollisionShape3D
+ )
+ if recovery_shape_node != null:
+ var recovery_shape := recovery_shape_node.shape as BoxShape3D
+ if recovery_shape != null:
+ recovery_shape.size = Vector3(
+ surface_size.x,
+ recovery_depth,
+ surface_size.y
+ )
+
+ if Engine.is_editor_hint():
+ update_configuration_warnings()
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings := PackedStringArray()
+ if not scale.is_equal_approx(Vector3.ONE):
+ warnings.append(
+ "Keep root scale at 1; resize water with Surface Size."
+ )
+ var visual_water := get_node_or_null(visual_water_path) as MeshInstance3D
+ if visual_water == null or not visual_water.mesh is PlaneMesh:
+ warnings.append("VisualWater must provide a PlaneMesh.")
+ var fishing_shape := get_node_or_null(fishing_shape_path) as CollisionShape3D
+ if fishing_shape == null or not fishing_shape.shape is BoxShape3D:
+ warnings.append("FishingRegion must provide a BoxShape3D.")
+ var recovery_region := (
+ get_node_or_null(recovery_region_path) as PlayerWaterTrigger
+ )
+ if recovery_region == null:
+ warnings.append("RecoveryRegion must use PlayerWaterTrigger.")
+ elif (
+ recovery_region.surface_height_mode
+ != PlayerWaterTrigger.SurfaceHeightMode.PARENT_GLOBAL_Y
+ ):
+ warnings.append(
+ "RecoveryRegion must derive surface height from its parent."
+ )
+ var recovery_shape := (
+ get_node_or_null(recovery_shape_path) as CollisionShape3D
+ )
+ if recovery_shape == null or not recovery_shape.shape is BoxShape3D:
+ warnings.append("RecoveryRegion must provide a BoxShape3D.")
+ var fishing_region := get_node_or_null(
+ fishing_region_path
+ ) as FishableWaterRegion
+ if fishing_region == null:
+ warnings.append("FishingRegion must use FishableWaterRegion.")
+ elif (
+ fishing_region.surface_height_mode
+ != FishableWaterRegion.SurfaceHeightMode.PARENT_GLOBAL_Y
+ ):
+ warnings.append(
+ "FishingRegion must derive surface height from its parent."
+ )
+ if water_material == null:
+ warnings.append("Assign a water material.")
+ if fish_pool == null:
+ warnings.append("Assign a fish pool.")
+ return warnings
diff --git a/world/water_body_authoring.gd.uid b/world/water_body_authoring.gd.uid
new file mode 100644
index 0000000..9eec9cb
--- /dev/null
+++ b/world/water_body_authoring.gd.uid
@@ -0,0 +1 @@
+uid://dtcgnxl1kmdu1
diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd
index 1333b6d..9e11556 100644
--- a/world/water_recovery_controller.gd
+++ b/world/water_recovery_controller.gd
@@ -2,6 +2,8 @@ class_name WaterRecoveryController
extends Node
signal recovery_starting
+signal recovery_finished
+signal local_respawn_completed(entry_position: Vector3)
enum RecoveryState {
IDLE,
@@ -165,6 +167,7 @@ func _respawn_player() -> void:
_player.global_transform = respawn_transform
_player.restore_gameplay_orientation_after_recovery()
_player.velocity = Vector3.ZERO
+ local_respawn_completed.emit(_entry_position)
func _finish_recovery() -> void:
@@ -175,3 +178,4 @@ func _finish_recovery() -> void:
_fishing_spot.end_water_recovery()
state = RecoveryState.IDLE
_bob_elapsed = 0.0
+ recovery_finished.emit()
diff --git a/world/water_surface_motion.gd b/world/water_surface_motion.gd
new file mode 100644
index 0000000..992f9cb
--- /dev/null
+++ b/world/water_surface_motion.gd
@@ -0,0 +1,68 @@
+class_name WaterSurfaceMotion
+extends MeshInstance3D
+
+const DEFAULT_AMPLITUDE: float = 0.055
+const DEFAULT_CYCLE_SECONDS: float = 7.0
+const DEFAULT_SECONDARY_SWELL: float = 0.18
+const DEFAULT_PHASE_OFFSET: float = 0.0
+
+@export_range(0.0, 0.2, 0.005, "suffix:m") var amplitude: float = (
+ DEFAULT_AMPLITUDE
+)
+@export_range(1.0, 30.0, 0.1, "suffix:s") var cycle_seconds: float = (
+ DEFAULT_CYCLE_SECONDS
+)
+@export_range(0.0, 0.5, 0.01) var secondary_swell: float = (
+ DEFAULT_SECONDARY_SWELL
+)
+@export_range(0.0, TAU, 0.01, "radians") var phase_offset: float = 0.0
+
+var _base_height: float
+
+
+func _ready() -> void:
+ _base_height = position.y
+ set_process(amplitude > 0.0)
+
+
+func _process(_delta: float) -> void:
+ position.y = _base_height + calculate_height_offset(
+ _current_time_seconds(),
+ amplitude,
+ cycle_seconds,
+ secondary_swell,
+ phase_offset,
+ )
+
+
+static func get_default_height_offset() -> float:
+ return calculate_height_offset(
+ _current_time_seconds(),
+ DEFAULT_AMPLITUDE,
+ DEFAULT_CYCLE_SECONDS,
+ DEFAULT_SECONDARY_SWELL,
+ DEFAULT_PHASE_OFFSET,
+ )
+
+
+static func calculate_height_offset(
+ time_seconds: float,
+ motion_amplitude: float,
+ motion_cycle_seconds: float,
+ motion_secondary_swell: float,
+ motion_phase_offset: float,
+) -> float:
+ var safe_cycle := maxf(motion_cycle_seconds, 0.001)
+ var phase := (
+ fposmod(time_seconds, safe_cycle) / safe_cycle * TAU
+ + motion_phase_offset
+ )
+ var swell := (
+ sin(phase)
+ + sin(phase * 2.0 + 1.1) * motion_secondary_swell
+ ) / (1.0 + motion_secondary_swell)
+ return swell * motion_amplitude
+
+
+static func _current_time_seconds() -> float:
+ return float(Time.get_ticks_msec()) * 0.001
diff --git a/world/water_surface_motion.gd.uid b/world/water_surface_motion.gd.uid
new file mode 100644
index 0000000..65746a9
--- /dev/null
+++ b/world/water_surface_motion.gd.uid
@@ -0,0 +1 @@
+uid://drdjm443o35tc
diff --git a/world/world_character_display.gd b/world/world_character_display.gd
new file mode 100644
index 0000000..a145f3b
--- /dev/null
+++ b/world/world_character_display.gd
@@ -0,0 +1,137 @@
+class_name WorldCharacterDisplay
+extends Node3D
+
+const CharacterCustomizationCatalogType = preload(
+ "res://progression/character_customization_catalog.gd"
+)
+const PlayerVisualPresenterType = preload(
+ "res://player/player_visual_presenter.gd"
+)
+
+@export_enum("round", "pointy") var species_id: String = "round"
+@export_enum(
+ "white",
+ "gray",
+ "charcoal",
+ "brown",
+ "orange",
+ "yellow",
+ "green",
+ "teal",
+) var fur_color_id: String = "white"
+@export_enum(
+ "none",
+ "pointy_long",
+ "pointy_short",
+ "pointy_wide",
+) var ears_id: String = "none"
+@export_enum(
+ "none",
+ "bear",
+ "bunny",
+ "cat",
+ "fox",
+ "gator",
+ "pointy",
+) var tail_id: String = "none"
+@export var eyes_id: String = "simple_shine"
+@export var nose_id: String = "dog_round"
+@export var mouth_id: String = "three"
+@export_category("Head Look")
+@export var head_look_enabled: bool = false
+@export_range(0.5, 10.0, 0.1, "suffix:m") var head_look_distance: float = 4.0
+@export_range(0.5, 3.0, 0.05, "suffix:m") var look_target_height: float = 1.25
+@export_range(0.0, 80.0, 1.0, "degrees") var head_yaw_limit: float = 50.0
+@export_range(0.0, 45.0, 1.0, "degrees") var head_pitch_limit: float = 22.0
+@export_range(0.05, 2.0, 0.05, "suffix:s") var head_blend_seconds: float = 0.3
+
+var _head_look: LookAtModifier3D
+var _skeleton: Skeleton3D
+var _look_target: Node3D
+var _look_target_proxy: Node3D
+var _head_look_requested: bool = false
+
+
+func _ready() -> void:
+ var appearance := CharacterCustomizationCatalogType.default_snapshot()
+ appearance["species"] = species_id
+ appearance["fur_pattern"] = fur_color_id
+ appearance["ears"] = ears_id
+ appearance["tail"] = tail_id
+ appearance["eyes"] = eyes_id
+ appearance["nose"] = nose_id
+ appearance["mouth"] = mouth_id
+ PlayerVisualPresenterType.apply_appearance(self, appearance)
+ _prepare_head_look()
+
+
+func _process(delta: float) -> void:
+ if _head_look == null or _look_target_proxy == null:
+ return
+ var target_is_valid := (
+ _look_target != null and is_instance_valid(_look_target)
+ )
+ if target_is_valid:
+ _look_target_proxy.global_position = (
+ _look_target.global_position + Vector3.UP * look_target_height
+ )
+ var target_influence := (
+ 1.0 if _head_look_requested and target_is_valid else 0.0
+ )
+ _head_look.influence = move_toward(
+ _head_look.influence,
+ target_influence,
+ delta / maxf(head_blend_seconds, 0.001),
+ )
+
+
+func set_head_look_target(target: Node3D) -> void:
+ _look_target = target
+
+
+func set_head_look_active(active: bool) -> void:
+ _head_look_requested = active
+
+
+func get_head_anchor_position() -> Vector3:
+ if _skeleton == null:
+ return global_position + Vector3.UP * 1.8
+ var head_index := _skeleton.find_bone("head")
+ if head_index < 0:
+ return global_position + Vector3.UP * 1.8
+ var head_pose := _skeleton.get_bone_global_pose(head_index)
+ return (
+ (_skeleton.global_transform * head_pose).origin
+ + Vector3.UP * 0.85
+ )
+
+
+func _prepare_head_look() -> void:
+ if not head_look_enabled:
+ set_process(false)
+ return
+ _skeleton = find_child("Skeleton3D", true, false) as Skeleton3D
+ if _skeleton == null or _skeleton.find_bone("head") < 0:
+ push_warning("World character head-look requires a head bone.")
+ set_process(false)
+ return
+ _look_target_proxy = Node3D.new()
+ _look_target_proxy.name = "HeadLookTarget"
+ add_child(_look_target_proxy)
+ _head_look = LookAtModifier3D.new()
+ _head_look.name = "HeadLookModifier"
+ _head_look.bone_name = "head"
+ _head_look.use_secondary_rotation = true
+ _head_look.use_angle_limitation = true
+ _head_look.symmetry_limitation = true
+ _head_look.primary_limit_angle = deg_to_rad(head_yaw_limit)
+ _head_look.secondary_limit_angle = deg_to_rad(head_pitch_limit)
+ _head_look.primary_damp_threshold = 0.65
+ _head_look.secondary_damp_threshold = 0.65
+ _head_look.duration = head_blend_seconds
+ _head_look.influence = 0.0
+ _head_look.set("origin_from", 1)
+ _head_look.set("origin_bone_name", "head")
+ _skeleton.add_child(_head_look)
+ _head_look.target_node = _head_look.get_path_to(_look_target_proxy)
+ set_process(true)
diff --git a/world/world_character_display.gd.uid b/world/world_character_display.gd.uid
new file mode 100644
index 0000000..0873153
--- /dev/null
+++ b/world/world_character_display.gd.uid
@@ -0,0 +1 @@
+uid://dqsehnkidygg7
diff --git a/world/world_prop.gd b/world/world_prop.gd
new file mode 100644
index 0000000..2ef1019
--- /dev/null
+++ b/world/world_prop.gd
@@ -0,0 +1,62 @@
+@tool
+class_name WorldProp
+extends Node3D
+
+@export_group("Owned Nodes")
+@export_node_path("Node3D")
+var visual_root_path: NodePath = ^"Visual"
+@export_node_path("Node3D")
+var collision_root_path: NodePath
+@export_node_path("Area3D")
+var interaction_area_path: NodePath
+@export_node_path("Marker3D")
+var entrance_marker_path: NodePath
+
+@export_group("Scaling")
+## Enable only when this prop explicitly supports non-uniform root scaling.
+@export var allow_non_uniform_scale: bool = false
+
+
+func _ready() -> void:
+ if not Engine.is_editor_hint():
+ return
+ set_notify_transform(true)
+ update_configuration_warnings()
+
+
+func _notification(what: int) -> void:
+ if Engine.is_editor_hint() and what == NOTIFICATION_TRANSFORM_CHANGED:
+ update_configuration_warnings()
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings := PackedStringArray()
+ if get_node_or_null(visual_root_path) == null:
+ warnings.append("Assign a valid Visual root.")
+ if (
+ not collision_root_path.is_empty()
+ and get_node_or_null(collision_root_path) == null
+ ):
+ warnings.append("Assign a valid Collision root.")
+ if (
+ not interaction_area_path.is_empty()
+ and get_node_or_null(interaction_area_path) == null
+ ):
+ warnings.append("Assign a valid InteractionArea.")
+ if (
+ not entrance_marker_path.is_empty()
+ and get_node_or_null(entrance_marker_path) == null
+ ):
+ warnings.append("Assign a valid EntranceMarker.")
+ if not allow_non_uniform_scale and not _has_uniform_scale():
+ warnings.append(
+ "Use uniform root scale so visuals and collision stay aligned."
+ )
+ return warnings
+
+
+func _has_uniform_scale() -> bool:
+ return (
+ is_equal_approx(scale.x, scale.y)
+ and is_equal_approx(scale.y, scale.z)
+ )
diff --git a/world/world_prop.gd.uid b/world/world_prop.gd.uid
new file mode 100644
index 0000000..5deb43a
--- /dev/null
+++ b/world/world_prop.gd.uid
@@ -0,0 +1 @@
+uid://c1p4583452rpa
diff --git a/world/world_time_service.gd b/world/world_time_service.gd
new file mode 100644
index 0000000..c3186b4
--- /dev/null
+++ b/world/world_time_service.gd
@@ -0,0 +1,176 @@
+class_name WorldTimeService
+extends Node
+
+signal time_changed(time_hours: float, phase: Phase)
+signal phase_changed(phase: Phase)
+signal authoritative_time_set(time_hours: float)
+signal natural_time_advanced(hours: float)
+
+enum Phase {
+ DAWN,
+ DAY,
+ DUSK,
+ NIGHT,
+}
+
+const HOURS_PER_DAY: float = 24.0
+const REAL_SECONDS_PER_CYCLE: float = 60.0 * 60.0
+const HOURS_PER_REAL_SECOND: float = HOURS_PER_DAY / REAL_SECONDS_PER_CYCLE
+const DAY_START_HOUR: float = 8.0
+const NIGHT_START_HOUR: float = 20.0
+const TRANSITION_HALF_HOURS: float = 0.5
+const DAWN_START_HOUR: float = DAY_START_HOUR - TRANSITION_HALF_HOURS
+const DAWN_END_HOUR: float = DAY_START_HOUR + TRANSITION_HALF_HOURS
+const DUSK_START_HOUR: float = NIGHT_START_HOUR - TRANSITION_HALF_HOURS
+const DUSK_END_HOUR: float = NIGHT_START_HOUR + TRANSITION_HALF_HOURS
+const DEFAULT_START_HOUR: float = DAY_START_HOUR
+
+var _time_hours: float = DEFAULT_START_HOUR
+var _persistent_time_hours: float = DEFAULT_START_HOUR
+var _persistence_tracking_enabled: bool = false
+var _running: bool = false
+var _phase: Phase = Phase.DAWN
+var _last_emitted_clock_minute: int = floori(DEFAULT_START_HOUR * 60.0)
+
+
+func _ready() -> void:
+ set_process(false)
+
+
+func _process(delta: float) -> void:
+ advance_time(delta)
+
+
+func begin_session(start_hour: float = DEFAULT_START_HOUR) -> void:
+ _running = true
+ set_process(true)
+ _set_time_hours(start_hour, true)
+
+
+func end_session() -> void:
+ _running = false
+ set_process(false)
+ _set_time_hours(DEFAULT_START_HOUR, true)
+
+
+func advance_time(real_seconds: float) -> void:
+ if not _running or real_seconds <= 0.0:
+ return
+ var advanced_hours := real_seconds * HOURS_PER_REAL_SECOND
+ _set_time_hours(
+ _time_hours + advanced_hours,
+ false,
+ )
+ natural_time_advanced.emit(advanced_hours)
+
+
+func synchronize_time(authoritative_time_hours: float) -> void:
+ if not is_finite(authoritative_time_hours):
+ return
+ _set_time_hours(authoritative_time_hours, true)
+
+
+func set_authoritative_time(time_hours: float) -> bool:
+ if (
+ not is_finite(time_hours)
+ or time_hours < 0.0
+ or time_hours >= HOURS_PER_DAY
+ ):
+ return false
+ _set_time_hours(time_hours, true)
+ authoritative_time_set.emit(_time_hours)
+ return true
+
+
+func set_persistence_tracking_enabled(enabled: bool) -> void:
+ if _persistence_tracking_enabled == enabled:
+ return
+ if _persistence_tracking_enabled:
+ _persistent_time_hours = _time_hours
+ _persistence_tracking_enabled = enabled
+
+
+func restore_persistent_time_hours(time_hours: float) -> bool:
+ if (
+ not is_finite(time_hours)
+ or time_hours < 0.0
+ or time_hours >= HOURS_PER_DAY
+ ):
+ return false
+ _persistent_time_hours = time_hours
+ if _persistence_tracking_enabled:
+ _set_time_hours(_persistent_time_hours, true)
+ return true
+
+
+func get_persistent_time_hours() -> float:
+ return _persistent_time_hours
+
+
+func get_time_hours() -> float:
+ return _time_hours
+
+
+func get_phase() -> Phase:
+ return _phase
+
+
+func is_night_period() -> bool:
+ return _time_hours < DAY_START_HOUR or _time_hours >= NIGHT_START_HOUR
+
+
+func is_transition() -> bool:
+ return _phase in [Phase.DAWN, Phase.DUSK]
+
+
+func get_clock_text() -> String:
+ return format_clock_time(_time_hours)
+
+
+static func phase_for_hour(time_hours: float) -> Phase:
+ var hour: float = _normalized_hour(time_hours)
+ if hour >= DAWN_START_HOUR and hour < DAWN_END_HOUR:
+ return Phase.DAWN
+ if hour >= DAWN_END_HOUR and hour < DUSK_START_HOUR:
+ return Phase.DAY
+ if hour >= DUSK_START_HOUR and hour < DUSK_END_HOUR:
+ return Phase.DUSK
+ return Phase.NIGHT
+
+
+static func format_clock_time(time_hours: float) -> String:
+ var hour: float = _normalized_hour(time_hours)
+ var total_minutes: int = floori(hour * 60.0)
+ var hour_24: int = floori(float(total_minutes) / 60.0)
+ var minute: int = total_minutes % 60
+ var hour_12: int = hour_24 % 12
+ if hour_12 == 0:
+ hour_12 = 12
+ return "%d:%02d %s" % [
+ hour_12,
+ minute,
+ "am" if hour_24 < 12 else "pm",
+ ]
+
+
+func _set_time_hours(time_hours: float, force_emit: bool) -> void:
+ var normalized: float = _normalized_hour(time_hours)
+ var next_phase: Phase = phase_for_hour(normalized)
+ var phase_was_changed: bool = next_phase != _phase
+ var clock_minute: int = floori(normalized * 60.0)
+ var clock_minute_changed: bool = (
+ clock_minute != _last_emitted_clock_minute
+ )
+ _time_hours = normalized
+ _phase = next_phase
+ if _persistence_tracking_enabled:
+ _persistent_time_hours = normalized
+ if phase_was_changed:
+ phase_changed.emit(_phase)
+ if force_emit or clock_minute_changed or phase_was_changed:
+ _last_emitted_clock_minute = clock_minute
+ time_changed.emit(_time_hours, _phase)
+
+
+static func _normalized_hour(time_hours: float) -> float:
+ return fposmod(time_hours, HOURS_PER_DAY)
diff --git a/world/world_time_service.gd.uid b/world/world_time_service.gd.uid
new file mode 100644
index 0000000..29e3163
--- /dev/null
+++ b/world/world_time_service.gd.uid
@@ -0,0 +1 @@
+uid://5lceyulaglpf
diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd
new file mode 100644
index 0000000..99b0429
--- /dev/null
+++ b/world/world_time_visual_controller.gd
@@ -0,0 +1,676 @@
+class_name WorldTimeVisualController
+extends Node
+
+const UPDATE_INTERVAL_SECONDS: float = 0.1
+const SUN_YAW_DEGREES: float = -32.0
+const WEATHER_TRANSITION_SECONDS: float = 10.0
+const RAIN_EMITTER_OFFSET := Vector3(0.0, 7.0, 0.0)
+const RAIN_EMISSION_EXTENTS := Vector3(13.0, 1.0, 13.0)
+const RAIN_VISIBILITY_AABB := AABB(
+ Vector3(-13.5, -9.0, -13.5),
+ Vector3(27.0, 12.0, 27.0),
+)
+const RAIN_PARTICLE_AMOUNT: int = 2240
+const RAIN_VELOCITY_MIN: float = 16.0
+const RAIN_VELOCITY_MAX: float = 20.0
+const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014)
+const FOG_DAYLIGHT_SCENE_BRIGHTNESS: float = 0.42
+const FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS: float = 0.42
+const FOG_DAYLIGHT_WATER_BRIGHTNESS: float = 0.42
+const FOG_DAYLIGHT_POST_BRIGHTNESS: float = 0.50
+const RAIN_DAYLIGHT_POST_BRIGHTNESS: float = 0.52
+const DEFAULT_SURFACE_HIGHLIGHT_FLOOR: float = 0.72
+const FOGGY_NIGHT_SURFACE_HIGHLIGHT_FLOOR: float = 0.25
+const FOGGY_NIGHT_WATER_NEAR_FOG_AMOUNT: float = 1.0
+const FOGGY_DARK_WATER_FOG_CORRECTION := Color(1.15, 1.025, 1.0, 1.0)
+const FOGGY_BRIGHT_WATER_FOG_CORRECTION := Color(0.99, 1.005, 1.01, 1.0)
+const RAIN_WATER_FOG_COLOR := Color(0.025, 0.038, 0.045)
+const SALT_WATER_MATERIAL: ShaderMaterial = preload(
+ "res://world/materials/stylized_water.tres"
+)
+const FRESH_WATER_MATERIAL: ShaderMaterial = preload(
+ "res://world/materials/stylized_water_fresh.tres"
+)
+const LocalStormCloudLayerType = preload(
+ "res://world/environment/local_storm_cloud_layer.gd"
+)
+
+const DAY_SKY_TOP := Color(0.204, 0.498, 0.643)
+const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847)
+const DAY_GROUND_BOTTOM := Color(0.157, 0.361, 0.439)
+const DAY_GROUND_HORIZON := Color(0.549, 0.749, 0.765)
+const DAY_AMBIENT := Color(0.82, 0.90, 0.92)
+const DAY_FOG := Color(0.549, 0.749, 0.765)
+const DAY_SUN := Color(0.88, 0.94, 0.96)
+const DAY_SUN_DISC := Color(1.0, 0.86, 0.46)
+
+const NIGHT_SKY_TOP := Color(0.026, 0.050, 0.105)
+const NIGHT_SKY_HORIZON := Color(0.10, 0.17, 0.28)
+const NIGHT_GROUND_BOTTOM := Color(0.020, 0.040, 0.080)
+const NIGHT_GROUND_HORIZON := Color(0.075, 0.135, 0.22)
+const NIGHT_AMBIENT := Color(0.28, 0.35, 0.48)
+const NIGHT_FOG := Color(0.13, 0.21, 0.32)
+const NIGHT_SUN := Color(0.35, 0.44, 0.62)
+const NIGHT_WATER_TINT := Color(0.34, 0.48, 0.58)
+
+const WARM_SKY_TOP := Color(0.31, 0.30, 0.42)
+const WARM_SKY_HORIZON := Color(0.96, 0.48, 0.22)
+const WARM_GROUND_BOTTOM := Color(0.20, 0.14, 0.16)
+const WARM_GROUND_HORIZON := Color(0.82, 0.34, 0.18)
+const WARM_AMBIENT := Color(0.92, 0.58, 0.40)
+const WARM_FOG := Color(0.74, 0.39, 0.27)
+const WARM_SUN := Color(1.0, 0.58, 0.30)
+const WARM_WATER_TINT := Color(0.78, 0.62, 0.58)
+const WARM_SUN_DISC := Color(1.0, 0.45, 0.16)
+const MOON_DISC := Color(0.78, 0.88, 1.0)
+const DAY_CLOUD_LIGHT := Color(0.70, 0.76, 0.77)
+const DAY_CLOUD_SHADOW := Color(0.31, 0.38, 0.40)
+const NIGHT_CLOUD_LIGHT := Color(0.18, 0.22, 0.30)
+const NIGHT_CLOUD_SHADOW := Color(0.06, 0.09, 0.14)
+const WARM_CLOUD_LIGHT := Color(0.76, 0.55, 0.45)
+const WARM_CLOUD_SHADOW := Color(0.31, 0.23, 0.24)
+const STORM_CLOUD_SHADOW := Color(0.08, 0.12, 0.14)
+const DAY_LOWER_CLOUD := Color(0.22, 0.25, 0.26)
+const NIGHT_LOWER_CLOUD := Color(0.08, 0.10, 0.14)
+const WARM_LOWER_CLOUD := Color(0.22, 0.16, 0.16)
+
+var _time_service: WorldTimeService
+var _weather_service: WorldWeatherService
+var _world_environment: WorldEnvironment
+var _sun: DirectionalLight3D
+var _rain_target: Node3D
+var _rain_camera_provider: Callable
+var _environment: Environment
+var _sky_material: ShaderMaterial
+var _rain: GPUParticles3D
+var _storm_clouds: LocalStormCloudLayer
+var _elapsed: float = 0.0
+var _weather_from: WorldWeatherService.Weather = (
+ WorldWeatherService.Weather.SUNNY
+)
+var _weather_to: WorldWeatherService.Weather = (
+ WorldWeatherService.Weather.SUNNY
+)
+var _weather_transition: float = 1.0
+
+
+func setup(
+ time_service: WorldTimeService,
+ world_environment: WorldEnvironment,
+ sun: DirectionalLight3D,
+ weather_service: WorldWeatherService = null,
+ rain_target: Node3D = null,
+ rain_camera_provider: Callable = Callable(),
+) -> void:
+ _time_service = time_service
+ _weather_service = weather_service
+ _world_environment = world_environment
+ _sun = sun
+ _rain_target = rain_target
+ _rain_camera_provider = rain_camera_provider
+ if not _prepare_runtime_environment():
+ set_process(false)
+ return
+ _prepare_rain()
+ _prepare_storm_clouds()
+ if _weather_service != null:
+ _weather_from = _weather_service.get_weather()
+ _weather_to = _weather_from
+ _weather_transition = 1.0
+ if not _weather_service.weather_changed.is_connected(
+ _on_weather_changed
+ ):
+ _weather_service.weather_changed.connect(_on_weather_changed)
+ set_process(true)
+ _apply_time(_time_service.get_time_hours())
+
+
+func _process(delta: float) -> void:
+ if _time_service == null:
+ return
+ _update_rain_position()
+ if _weather_transition < 1.0:
+ _weather_transition = minf(
+ _weather_transition + delta / WEATHER_TRANSITION_SECONDS,
+ 1.0,
+ )
+ _elapsed += delta
+ if _elapsed < UPDATE_INTERVAL_SECONDS:
+ return
+ _elapsed = 0.0
+ _apply_time(_time_service.get_time_hours())
+
+
+func apply_time_immediately(time_hours: float) -> void:
+ _apply_time(time_hours)
+
+
+func apply_weather_immediately(
+ weather: WorldWeatherService.Weather,
+) -> void:
+ _weather_from = weather
+ _weather_to = weather
+ _weather_transition = 1.0
+ if _time_service != null:
+ _apply_time(_time_service.get_time_hours())
+
+
+func _prepare_runtime_environment() -> bool:
+ if (
+ _time_service == null
+ or _world_environment == null
+ or _world_environment.environment == null
+ or _sun == null
+ ):
+ push_error("World time visuals require an environment and sun.")
+ return false
+ _environment = _world_environment.environment.duplicate(true) as Environment
+ if _environment == null or _environment.sky == null:
+ push_error("World time visuals could not duplicate the world environment.")
+ return false
+ var runtime_sky: Sky = _environment.sky.duplicate(true) as Sky
+ if runtime_sky == null or runtime_sky.sky_material == null:
+ push_error("World time visuals require a sky material.")
+ return false
+ _sky_material = (
+ runtime_sky.sky_material.duplicate(true) as ShaderMaterial
+ )
+ if _sky_material == null:
+ push_error("World time visuals require the NETfishing sky material.")
+ return false
+ runtime_sky.sky_material = _sky_material
+ _environment.sky = runtime_sky
+ _world_environment.environment = _environment
+ return true
+
+
+func _prepare_rain() -> void:
+ _rain = GPUParticles3D.new()
+ _rain.name = "LocalRain"
+ _rain.amount = RAIN_PARTICLE_AMOUNT
+ _rain.amount_ratio = 0.0
+ _rain.lifetime = 1.25
+ _rain.fixed_fps = 30
+ _rain.local_coords = false
+ _rain.visibility_aabb = RAIN_VISIBILITY_AABB
+ var process_material := ParticleProcessMaterial.new()
+ process_material.emission_shape = (
+ ParticleProcessMaterial.EMISSION_SHAPE_BOX
+ )
+ process_material.emission_box_extents = RAIN_EMISSION_EXTENTS
+ process_material.direction = Vector3.DOWN
+ process_material.spread = 5.0
+ process_material.initial_velocity_min = RAIN_VELOCITY_MIN
+ process_material.initial_velocity_max = RAIN_VELOCITY_MAX
+ process_material.gravity = Vector3(0.0, -2.0, 0.0)
+ _rain.process_material = process_material
+ var drop_mesh := BoxMesh.new()
+ drop_mesh.size = RAIN_DROP_SIZE
+ var drop_material := StandardMaterial3D.new()
+ drop_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ drop_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ drop_material.albedo_color = Color(0.48, 0.78, 0.90, 0.72)
+ drop_mesh.material = drop_material
+ _rain.draw_pass_1 = drop_mesh
+ add_child(_rain)
+ _update_rain_position()
+
+
+func _prepare_storm_clouds() -> void:
+ _storm_clouds = LocalStormCloudLayerType.new()
+ _storm_clouds.name = "LocalStormClouds"
+ add_child(_storm_clouds)
+ _storm_clouds.setup(_rain_target)
+
+
+func _apply_time(time_hours: float) -> void:
+ if _environment == null or _sky_material == null or _sun == null:
+ return
+ var hour: float = fposmod(time_hours, WorldTimeService.HOURS_PER_DAY)
+ var daylight: float = _daylight_amount(hour)
+ var warmth: float = _transition_warmth(hour)
+ var sky_top: Color = _blended_color(
+ NIGHT_SKY_TOP, DAY_SKY_TOP, WARM_SKY_TOP, daylight, warmth
+ )
+ var sky_horizon: Color = _blended_color(
+ NIGHT_SKY_HORIZON,
+ DAY_SKY_HORIZON,
+ WARM_SKY_HORIZON,
+ daylight,
+ warmth,
+ )
+ var ground_bottom: Color = _blended_color(
+ NIGHT_GROUND_BOTTOM,
+ DAY_GROUND_BOTTOM,
+ WARM_GROUND_BOTTOM,
+ daylight,
+ warmth,
+ )
+ var ground_horizon: Color = _blended_color(
+ NIGHT_GROUND_HORIZON,
+ DAY_GROUND_HORIZON,
+ WARM_GROUND_HORIZON,
+ daylight,
+ warmth,
+ )
+ var ambient: Color = _blended_color(
+ NIGHT_AMBIENT, DAY_AMBIENT, WARM_AMBIENT, daylight, warmth
+ )
+ var fog_color: Color = _blended_color(
+ NIGHT_FOG, DAY_FOG, WARM_FOG, daylight, warmth
+ )
+ var foggy_fog_amount: float = _weather_value(0.0, 0.0, 0.0, 1.0)
+ var rain_fog_amount: float = _weather_value(0.0, 0.0, 1.0, 0.0)
+ var fog_daylight_amount: float = daylight * foggy_fog_amount
+ var rain_daylight_amount: float = daylight * rain_fog_amount
+ var weather_fog_amount: float = minf(
+ rain_fog_amount + foggy_fog_amount,
+ 1.0,
+ )
+ var foggy_horizon_occlusion := _foggy_horizon_occlusion_amount(hour)
+ _environment.fog_enabled = weather_fog_amount > 0.001
+ var fog_scene_brightness: float = lerpf(
+ 1.0,
+ FOG_DAYLIGHT_SCENE_BRIGHTNESS,
+ fog_daylight_amount,
+ )
+ _sky_material.set_shader_parameter(
+ "sky_top_color", sky_top
+ )
+ _sky_material.set_shader_parameter(
+ "sky_horizon_color",
+ sky_horizon,
+ )
+ _sky_material.set_shader_parameter(
+ "ground_bottom_color",
+ ground_bottom,
+ )
+ _sky_material.set_shader_parameter(
+ "ground_horizon_color",
+ ground_horizon,
+ )
+ _sky_material.set_shader_parameter("fog_horizon_color", fog_color)
+ _sky_material.set_shader_parameter(
+ "fog_horizon_occlusion",
+ foggy_fog_amount * foggy_horizon_occlusion,
+ )
+ _apply_sky_clouds(daylight, warmth)
+ _environment.background_energy_multiplier = (
+ lerpf(0.52, 0.85, daylight)
+ * _weather_value(1.0, 0.82, 0.66, 1.0)
+ * fog_scene_brightness
+ )
+ _environment.ambient_light_color = ambient
+ _environment.ambient_light_energy = (
+ lerpf(0.66, 1.08, daylight)
+ * _weather_value(1.0, 0.88, 0.76, 1.0)
+ * fog_scene_brightness
+ )
+ _environment.fog_light_color = fog_color
+ var fog_light_energy := (
+ lerpf(0.56, 0.82, daylight)
+ * _weather_value(1.0, 0.92, 0.82, 1.0)
+ * lerpf(
+ 1.0,
+ FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS,
+ fog_daylight_amount,
+ )
+ )
+ _environment.fog_light_energy = fog_light_energy
+ _environment.fog_aerial_perspective = _weather_value(
+ 0.35, 0.48, 0.62, 0.0
+ )
+ _environment.fog_sky_affect = _weather_value(
+ 0.35, 0.48, 0.68, 1.0
+ )
+ _environment.fog_depth_curve = _weather_value(
+ 1.6, 1.5, 1.4, 1.18
+ )
+ _environment.fog_depth_begin = _weather_value(
+ 42.0, 32.0, 20.0, 3.0
+ )
+ _environment.fog_depth_end = _weather_value(
+ 170.0, 140.0, 95.0, 18.0
+ )
+ _apply_water_environment(
+ daylight,
+ warmth,
+ rain_fog_amount,
+ foggy_fog_amount,
+ foggy_horizon_occlusion,
+ _fog_render_color(fog_color, fog_light_energy),
+ )
+ _environment.adjustment_brightness = (
+ lerpf(0.93, 0.98, daylight)
+ * _weather_value(1.0, 0.97, 0.92, 1.0)
+ * lerpf(
+ 1.0,
+ FOG_DAYLIGHT_POST_BRIGHTNESS,
+ fog_daylight_amount,
+ )
+ * lerpf(
+ 1.0,
+ RAIN_DAYLIGHT_POST_BRIGHTNESS,
+ rain_daylight_amount,
+ )
+ )
+ _environment.adjustment_saturation = (
+ lerpf(0.82, 0.91, daylight)
+ * _weather_value(1.0, 0.88, 0.78, 1.0)
+ )
+ _sun.rotation_degrees = Vector3(
+ -360.0 * fposmod(hour - WorldTimeService.DAY_START_HOUR, 24.0) / 24.0,
+ SUN_YAW_DEGREES,
+ 0.0,
+ )
+ _sky_material.set_shader_parameter(
+ "sun_direction", _sun.global_transform.basis.z.normalized()
+ )
+ _sky_material.set_shader_parameter(
+ "sun_color", DAY_SUN_DISC.lerp(WARM_SUN_DISC, warmth)
+ )
+ _sky_material.set_shader_parameter(
+ "sun_visibility",
+ daylight * _weather_value(1.0, 0.65, 0.28, 1.0),
+ )
+ _sky_material.set_shader_parameter(
+ "moon_direction", -_sun.global_transform.basis.z.normalized()
+ )
+ _sky_material.set_shader_parameter("moon_color", MOON_DISC)
+ _sky_material.set_shader_parameter(
+ "moon_visibility",
+ (1.0 - daylight) * _weather_value(1.0, 0.72, 0.36, 1.0),
+ )
+ _sun.light_color = _blended_color(
+ NIGHT_SUN, DAY_SUN, WARM_SUN, daylight, warmth
+ )
+ _sun.light_energy = (
+ lerpf(0.0, 0.10, daylight)
+ * _weather_value(1.0, 0.55, 0.25, 0.30)
+ )
+ _update_rain_amount()
+
+
+func _apply_water_environment(
+ daylight: float,
+ warmth: float,
+ rain_fog_amount: float,
+ foggy_fog_amount: float,
+ foggy_horizon_occlusion: float,
+ fog_render_color: Color,
+) -> void:
+ var water_tint := _blended_color(
+ NIGHT_WATER_TINT,
+ Color.WHITE,
+ WARM_WATER_TINT,
+ daylight,
+ warmth,
+ )
+ var water_brightness := (
+ lerpf(0.34, 1.0, daylight)
+ * _weather_value(1.0, 0.90, 0.78, 1.0)
+ * lerpf(
+ 1.0,
+ FOG_DAYLIGHT_WATER_BRIGHTNESS,
+ daylight * _weather_value(0.0, 0.0, 0.0, 1.0),
+ )
+ )
+ var weather_fog_amount: float = minf(
+ rain_fog_amount + foggy_fog_amount,
+ 1.0,
+ )
+ var foggy_color_weight: float = (
+ foggy_fog_amount / weather_fog_amount
+ if weather_fog_amount > 0.001
+ else 0.0
+ )
+ var water_fog_color := RAIN_WATER_FOG_COLOR.lerp(
+ fog_render_color,
+ foggy_color_weight,
+ )
+ var fog_correction_amount := (
+ foggy_fog_amount * foggy_horizon_occlusion
+ )
+ var bright_fog_amount := maxf(daylight, warmth)
+ var fog_color_correction := FOGGY_DARK_WATER_FOG_CORRECTION.lerp(
+ FOGGY_BRIGHT_WATER_FOG_CORRECTION,
+ bright_fog_amount,
+ )
+ water_fog_color.r *= lerpf(
+ 1.0,
+ fog_color_correction.r,
+ fog_correction_amount,
+ )
+ water_fog_color.g *= lerpf(
+ 1.0,
+ fog_color_correction.g,
+ fog_correction_amount,
+ )
+ water_fog_color.b *= lerpf(
+ 1.0,
+ fog_color_correction.b,
+ fog_correction_amount,
+ )
+ var foggy_surface_highlight_floor := lerpf(
+ FOGGY_NIGHT_SURFACE_HIGHLIGHT_FLOOR,
+ DEFAULT_SURFACE_HIGHLIGHT_FLOOR,
+ daylight,
+ )
+ var surface_highlight_floor := lerpf(
+ DEFAULT_SURFACE_HIGHLIGHT_FLOOR,
+ foggy_surface_highlight_floor,
+ foggy_fog_amount,
+ )
+ var water_near_fog_amount := (
+ FOGGY_NIGHT_WATER_NEAR_FOG_AMOUNT
+ * foggy_fog_amount
+ * foggy_horizon_occlusion
+ )
+ for material: ShaderMaterial in [
+ SALT_WATER_MATERIAL,
+ FRESH_WATER_MATERIAL,
+ ]:
+ material.set_shader_parameter("environment_tint", water_tint)
+ material.set_shader_parameter(
+ "environment_brightness",
+ water_brightness,
+ )
+ material.set_shader_parameter("weather_fog_color", water_fog_color)
+ material.set_shader_parameter(
+ "surface_highlight_night_floor",
+ surface_highlight_floor,
+ )
+ material.set_shader_parameter(
+ "weather_fog_amount",
+ weather_fog_amount,
+ )
+ material.set_shader_parameter(
+ "weather_fog_near_amount",
+ water_near_fog_amount,
+ )
+ material.set_shader_parameter(
+ "weather_fog_begin",
+ _weather_value(42.0, 32.0, 20.0, 3.0),
+ )
+ material.set_shader_parameter(
+ "weather_fog_end",
+ _weather_value(170.0, 140.0, 95.0, 18.0),
+ )
+ material.set_shader_parameter(
+ "weather_fog_curve",
+ _weather_value(1.6, 1.5, 1.4, 1.18),
+ )
+
+
+func _fog_render_color(fog_color: Color, fog_light_energy: float) -> Color:
+ var linear_fog_color := fog_color.srgb_to_linear()
+ linear_fog_color.r *= fog_light_energy
+ linear_fog_color.g *= fog_light_energy
+ linear_fog_color.b *= fog_light_energy
+ linear_fog_color.a = 1.0
+ return linear_fog_color.linear_to_srgb()
+
+
+func _foggy_horizon_occlusion_amount(hour: float) -> float:
+ # Day and dusk already blend cleanly. During night and most of dawn, make
+ # the manually fogged transparent water fully occluding so the dark scene
+ # below it cannot reintroduce a horizon band. Brief edge fades avoid a pop
+ # where the dawn/night phases meet their neighboring phases.
+ if hour >= WorldTimeService.DUSK_END_HOUR - 0.1:
+ return smoothstep(
+ WorldTimeService.DUSK_END_HOUR - 0.1,
+ WorldTimeService.DUSK_END_HOUR,
+ hour,
+ )
+ if hour < WorldTimeService.DAWN_START_HOUR:
+ return 1.0
+ if hour < WorldTimeService.DAWN_END_HOUR:
+ return 1.0 - smoothstep(
+ WorldTimeService.DAWN_END_HOUR - 0.1,
+ WorldTimeService.DAWN_END_HOUR,
+ hour,
+ )
+ return 0.0
+
+
+func _apply_sky_clouds(daylight: float, warmth: float) -> void:
+ var cloud_light := _blended_color(
+ NIGHT_CLOUD_LIGHT,
+ DAY_CLOUD_LIGHT,
+ WARM_CLOUD_LIGHT,
+ daylight,
+ warmth,
+ )
+ var cloud_shadow := _blended_color(
+ NIGHT_CLOUD_SHADOW,
+ DAY_CLOUD_SHADOW,
+ WARM_CLOUD_SHADOW,
+ daylight,
+ warmth,
+ )
+ var storm_amount: float = _weather_value(0.0, 0.18, 0.55, 0.0)
+ cloud_light = cloud_light.lerp(cloud_shadow, storm_amount * 0.35)
+ cloud_shadow = cloud_shadow.lerp(
+ STORM_CLOUD_SHADOW,
+ storm_amount * 0.10,
+ )
+ var lower_cloud_color := _blended_color(
+ NIGHT_LOWER_CLOUD,
+ DAY_LOWER_CLOUD,
+ WARM_LOWER_CLOUD,
+ daylight,
+ warmth,
+ )
+ _sky_material.set_shader_parameter(
+ "cloud_coverage",
+ _weather_value(0.0, 0.58, 0.985, 0.0),
+ )
+ _sky_material.set_shader_parameter(
+ "cloud_opacity",
+ _weather_value(0.0, 0.76, 1.0, 0.0),
+ )
+ _sky_material.set_shader_parameter("cloud_light_color", cloud_light)
+ _sky_material.set_shader_parameter("cloud_shadow_color", cloud_shadow)
+ if _storm_clouds != null:
+ _storm_clouds.set_storm_amount(
+ _weather_value(0.0, 0.0, 1.0, 0.0),
+ lower_cloud_color,
+ )
+
+
+func _on_weather_changed(
+ weather: WorldWeatherService.Weather,
+ _seconds_remaining: float,
+) -> void:
+ if weather == _weather_to:
+ return
+ _weather_from = _weather_to
+ _weather_to = weather
+ _weather_transition = 0.0
+
+
+func _weather_value(
+ sunny: float,
+ cloudy: float,
+ rainy: float,
+ foggy: float,
+) -> float:
+ return lerpf(
+ _weather_state_value(_weather_from, sunny, cloudy, rainy, foggy),
+ _weather_state_value(_weather_to, sunny, cloudy, rainy, foggy),
+ _weather_transition,
+ )
+
+
+func _weather_state_value(
+ weather: WorldWeatherService.Weather,
+ sunny: float,
+ cloudy: float,
+ rainy: float,
+ foggy: float,
+) -> float:
+ match weather:
+ WorldWeatherService.Weather.CLOUDY:
+ return cloudy
+ WorldWeatherService.Weather.RAINY:
+ return rainy
+ WorldWeatherService.Weather.FOGGY:
+ return foggy
+ return sunny
+
+
+func _update_rain_amount() -> void:
+ if _rain == null:
+ return
+ var rain_amount: float = _weather_value(0.0, 0.0, 1.0, 0.0)
+ _rain.amount_ratio = rain_amount
+ _rain.emitting = rain_amount > 0.01
+
+
+func _update_rain_position() -> void:
+ if _rain == null or _rain_target == null:
+ return
+ var anchor_position: Vector3 = _rain_target.global_position
+ if _rain_camera_provider.is_valid():
+ var camera_candidate: Variant = _rain_camera_provider.call()
+ if camera_candidate is Camera3D:
+ var active_camera := camera_candidate as Camera3D
+ if is_instance_valid(active_camera):
+ anchor_position = active_camera.global_position
+ _rain.global_position = anchor_position + RAIN_EMITTER_OFFSET
+
+
+func _daylight_amount(hour: float) -> float:
+ if hour >= WorldTimeService.DAWN_START_HOUR and hour < WorldTimeService.DAWN_END_HOUR:
+ return smoothstep(
+ WorldTimeService.DAWN_START_HOUR,
+ WorldTimeService.DAWN_END_HOUR,
+ hour,
+ )
+ if hour >= WorldTimeService.DAWN_END_HOUR and hour < WorldTimeService.DUSK_START_HOUR:
+ return 1.0
+ if hour >= WorldTimeService.DUSK_START_HOUR and hour < WorldTimeService.DUSK_END_HOUR:
+ return 1.0 - smoothstep(
+ WorldTimeService.DUSK_START_HOUR,
+ WorldTimeService.DUSK_END_HOUR,
+ hour,
+ )
+ return 0.0
+
+
+func _transition_warmth(hour: float) -> float:
+ if hour >= WorldTimeService.DAWN_START_HOUR and hour < WorldTimeService.DAWN_END_HOUR:
+ return 1.0 - absf(hour - WorldTimeService.DAY_START_HOUR) / WorldTimeService.TRANSITION_HALF_HOURS
+ if hour >= WorldTimeService.DUSK_START_HOUR and hour < WorldTimeService.DUSK_END_HOUR:
+ return 1.0 - absf(hour - WorldTimeService.NIGHT_START_HOUR) / WorldTimeService.TRANSITION_HALF_HOURS
+ return 0.0
+
+
+func _blended_color(
+ night_color: Color,
+ day_color: Color,
+ warm_color: Color,
+ daylight: float,
+ warmth: float,
+) -> Color:
+ return night_color.lerp(day_color, daylight).lerp(warm_color, warmth)
diff --git a/world/world_time_visual_controller.gd.uid b/world/world_time_visual_controller.gd.uid
new file mode 100644
index 0000000..52bbfc3
--- /dev/null
+++ b/world/world_time_visual_controller.gd.uid
@@ -0,0 +1 @@
+uid://svirs8vbypad
diff --git a/world/world_weather_service.gd b/world/world_weather_service.gd
new file mode 100644
index 0000000..4fce78e
--- /dev/null
+++ b/world/world_weather_service.gd
@@ -0,0 +1,392 @@
+class_name WorldWeatherService
+extends Node
+
+signal weather_changed(weather: Weather, seconds_remaining: float)
+
+enum Weather {
+ SUNNY,
+ CLOUDY,
+ RAINY,
+ FOGGY,
+}
+
+const DEFAULT_WEATHER: Weather = Weather.SUNNY
+const SUNNY_DURATION_RANGE := Vector2(480.0, 900.0)
+const CLOUDY_DURATION_RANGE := Vector2(300.0, 720.0)
+const RAINY_DURATION_RANGE := Vector2(300.0, 600.0)
+const FOGGY_DURATION_RANGE := Vector2(300.0, 600.0)
+const MAX_PERSISTED_SECONDS: float = 1800.0
+const DAILY_PLAN_SEGMENT_HOURS: float = 2.0
+const DAILY_PLAN_SEGMENT_COUNT: int = 12
+
+var _weather: Weather = DEFAULT_WEATHER
+var _seconds_remaining: float = SUNNY_DURATION_RANGE.x
+var _persistent_weather: Weather = DEFAULT_WEATHER
+var _persistent_seconds_remaining: float = SUNNY_DURATION_RANGE.x
+var _has_persistent_state: bool = false
+var _persistence_tracking_enabled: bool = false
+var _running_authority: bool = false
+var _rng := RandomNumberGenerator.new()
+var _daily_plan_id: String = ""
+var _daily_schedule: Array[Dictionary] = []
+var _world_time: WorldTimeService
+var _manual_override_weather: Weather = DEFAULT_WEATHER
+var _manual_override_segment_index: int = -1
+
+
+func _ready() -> void:
+ set_process(false)
+
+
+func _process(delta: float) -> void:
+ advance_weather(delta)
+
+
+func begin_authoritative_session(seed_value: int) -> void:
+ _rng.seed = seed_value
+ _running_authority = true
+ set_process(true)
+ _clear_manual_override()
+ if _daily_schedule.is_empty() or _world_time == null:
+ if _has_persistent_state:
+ _set_weather(
+ _persistent_weather,
+ _persistent_seconds_remaining,
+ true,
+ )
+ else:
+ _set_weather(
+ DEFAULT_WEATHER,
+ _roll_duration(DEFAULT_WEATHER),
+ true,
+ )
+ else:
+ _update_scheduled_weather(true)
+
+
+func begin_remote_session() -> void:
+ _running_authority = false
+ set_process(false)
+ _clear_manual_override()
+ _set_weather(DEFAULT_WEATHER, SUNNY_DURATION_RANGE.x, true)
+
+
+func end_session() -> void:
+ _running_authority = false
+ set_process(false)
+ _clear_manual_override()
+ _set_weather(DEFAULT_WEATHER, SUNNY_DURATION_RANGE.x, true)
+
+
+func advance_weather(real_seconds: float) -> void:
+ if not _running_authority or real_seconds <= 0.0:
+ return
+ if not _daily_schedule.is_empty() and _world_time != null:
+ _update_scheduled_weather(false)
+ return
+ _seconds_remaining -= real_seconds
+ while _seconds_remaining <= 0.0:
+ var overrun: float = -_seconds_remaining
+ var next_weather: Weather = _choose_next_weather()
+ _set_weather(next_weather, _roll_duration(next_weather), true)
+ _seconds_remaining -= overrun
+ if _persistence_tracking_enabled:
+ _store_persistent_state()
+
+
+func apply_authoritative_snapshot(
+ weather: Weather,
+ seconds_remaining: float,
+) -> void:
+ if not is_valid_weather(int(weather)) or not is_finite(seconds_remaining):
+ return
+ _set_weather(weather, maxf(seconds_remaining, 0.0), false)
+
+
+func set_authoritative_weather(weather: Weather) -> bool:
+ if not _running_authority or not is_valid_weather(int(weather)):
+ return false
+ var duration: float = _roll_duration(weather)
+ if not _daily_schedule.is_empty() and _world_time != null:
+ _manual_override_weather = weather
+ _manual_override_segment_index = _current_daily_segment_index()
+ duration = _current_daily_segment_seconds_remaining(
+ _manual_override_segment_index
+ )
+ else:
+ _clear_manual_override()
+ _set_weather(weather, duration, true)
+ return true
+
+
+func set_persistence_tracking_enabled(enabled: bool) -> void:
+ if _persistence_tracking_enabled == enabled:
+ return
+ if _persistence_tracking_enabled:
+ _store_persistent_state()
+ _persistence_tracking_enabled = enabled
+
+
+func restore_persistent_state(
+ weather: Weather,
+ seconds_remaining: float,
+) -> bool:
+ if not is_valid_persistent_state(weather, seconds_remaining):
+ return false
+ _persistent_weather = weather
+ _persistent_seconds_remaining = seconds_remaining
+ _has_persistent_state = true
+ if _persistence_tracking_enabled:
+ _set_weather(
+ _persistent_weather,
+ _persistent_seconds_remaining,
+ true,
+ )
+ return true
+
+
+func reset_persistent_state() -> void:
+ _has_persistent_state = false
+ _persistent_weather = DEFAULT_WEATHER
+ _persistent_seconds_remaining = SUNNY_DURATION_RANGE.x
+ if _persistence_tracking_enabled and _running_authority:
+ _set_weather(
+ DEFAULT_WEATHER,
+ _roll_duration(DEFAULT_WEATHER),
+ true,
+ )
+
+
+func has_persistent_state() -> bool:
+ return _has_persistent_state
+
+
+func get_persistent_weather() -> Weather:
+ return _persistent_weather
+
+
+func get_persistent_seconds_remaining() -> float:
+ return _persistent_seconds_remaining
+
+
+func get_weather() -> Weather:
+ return _weather
+
+
+func get_seconds_remaining() -> float:
+ return _seconds_remaining
+
+
+func is_raining() -> bool:
+ return _weather == Weather.RAINY
+
+
+func is_foggy() -> bool:
+ return _weather == Weather.FOGGY
+
+
+func get_weather_name() -> String:
+ return weather_name(_weather)
+
+
+func configure_daily_plan(
+ plan_id: String,
+ schedule: Array,
+ world_time: WorldTimeService,
+) -> bool:
+ if (
+ plan_id.is_empty()
+ or not is_valid_daily_plan_schedule(schedule)
+ or world_time == null
+ ):
+ return false
+ _daily_plan_id = plan_id
+ _daily_schedule.clear()
+ _clear_manual_override()
+ for value: Variant in schedule:
+ _daily_schedule.append((value as Dictionary).duplicate(true))
+ _world_time = world_time
+ if _running_authority:
+ _update_scheduled_weather(true)
+ return true
+
+
+func clear_daily_plan() -> void:
+ _daily_plan_id = ""
+ _daily_schedule.clear()
+ _world_time = null
+ _clear_manual_override()
+
+
+func get_daily_plan_id() -> String:
+ return _daily_plan_id
+
+
+static func is_valid_weather(value: int) -> bool:
+ return value >= Weather.SUNNY and value <= Weather.FOGGY
+
+
+static func is_valid_persistent_state(
+ weather: Weather,
+ seconds_remaining: float,
+) -> bool:
+ return (
+ is_valid_weather(int(weather))
+ and is_finite(seconds_remaining)
+ and seconds_remaining >= 0.0
+ and seconds_remaining <= MAX_PERSISTED_SECONDS
+ )
+
+
+static func is_valid_daily_plan_schedule(value: Variant) -> bool:
+ if typeof(value) != TYPE_ARRAY:
+ return false
+ var schedule: Array = value
+ if schedule.size() != DAILY_PLAN_SEGMENT_COUNT:
+ return false
+ for entry_value: Variant in schedule:
+ if typeof(entry_value) != TYPE_DICTIONARY:
+ return false
+ var entry: Dictionary = entry_value
+ if (
+ typeof(entry.get("start_hour")) not in [TYPE_FLOAT, TYPE_INT]
+ or not is_finite(float(entry.get("start_hour", -1.0)))
+ or float(entry.get("start_hour", -1.0)) < 0.0
+ or float(entry.get("start_hour", -1.0)) >= 24.0
+ or not _is_bounded_integer(
+ entry.get("weather"), Weather.SUNNY, Weather.FOGGY
+ )
+ or not is_valid_weather(int(entry.get("weather", -1)))
+ ):
+ return false
+ return true
+
+
+static func _is_bounded_integer(
+ value: Variant,
+ minimum: int,
+ maximum: int,
+) -> bool:
+ if typeof(value) == TYPE_INT:
+ return int(value) >= minimum and int(value) <= maximum
+ if typeof(value) != TYPE_FLOAT:
+ return false
+ var number: float = float(value)
+ return (
+ is_finite(number)
+ and number >= float(minimum)
+ and number <= float(maximum)
+ and is_equal_approx(number, round(number))
+ )
+
+
+static func weather_name(weather: Weather) -> String:
+ match weather:
+ Weather.SUNNY:
+ return "sunny"
+ Weather.CLOUDY:
+ return "cloudy"
+ Weather.RAINY:
+ return "rainy"
+ Weather.FOGGY:
+ return "foggy"
+ return "unknown"
+
+
+func _set_weather(
+ weather: Weather,
+ seconds_remaining: float,
+ force_emit: bool,
+) -> void:
+ var changed: bool = weather != _weather
+ _weather = weather
+ _seconds_remaining = maxf(seconds_remaining, 0.0)
+ if _persistence_tracking_enabled:
+ _store_persistent_state()
+ if force_emit or changed:
+ weather_changed.emit(_weather, _seconds_remaining)
+
+
+func _store_persistent_state() -> void:
+ _persistent_weather = _weather
+ _persistent_seconds_remaining = _seconds_remaining
+ _has_persistent_state = true
+
+
+func _choose_next_weather() -> Weather:
+ match _weather:
+ Weather.SUNNY:
+ return Weather.CLOUDY
+ Weather.RAINY, Weather.FOGGY:
+ return Weather.CLOUDY
+ Weather.CLOUDY:
+ var roll: float = _rng.randf()
+ if roll < 0.45:
+ return Weather.SUNNY
+ if roll < 0.80:
+ return Weather.RAINY
+ return Weather.FOGGY
+ return Weather.SUNNY
+
+
+func _roll_duration(weather: Weather) -> float:
+ var duration_range: Vector2 = _duration_range(weather)
+ return _rng.randf_range(duration_range.x, duration_range.y)
+
+
+func _duration_range(weather: Weather) -> Vector2:
+ match weather:
+ Weather.SUNNY:
+ return SUNNY_DURATION_RANGE
+ Weather.CLOUDY:
+ return CLOUDY_DURATION_RANGE
+ Weather.RAINY:
+ return RAINY_DURATION_RANGE
+ Weather.FOGGY:
+ return FOGGY_DURATION_RANGE
+ return SUNNY_DURATION_RANGE
+
+
+func _update_scheduled_weather(force_emit: bool) -> void:
+ if _daily_schedule.is_empty() or _world_time == null:
+ return
+ var segment_index: int = _current_daily_segment_index()
+ var entry: Dictionary = _daily_schedule[segment_index]
+ var weather: Weather = int(entry.get("weather", DEFAULT_WEATHER)) as Weather
+ if _manual_override_segment_index == segment_index:
+ weather = _manual_override_weather
+ elif _manual_override_segment_index >= 0:
+ _clear_manual_override()
+ var seconds_remaining: float = _current_daily_segment_seconds_remaining(
+ segment_index
+ )
+ _set_weather(weather, seconds_remaining, force_emit)
+
+
+func _current_daily_segment_index() -> int:
+ var elapsed_hours: float = fposmod(
+ _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR,
+ WorldTimeService.HOURS_PER_DAY,
+ )
+ return clampi(
+ floori(elapsed_hours / DAILY_PLAN_SEGMENT_HOURS),
+ 0,
+ _daily_schedule.size() - 1,
+ )
+
+
+func _current_daily_segment_seconds_remaining(segment_index: int) -> float:
+ var elapsed_hours: float = fposmod(
+ _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR,
+ WorldTimeService.HOURS_PER_DAY,
+ )
+ var next_boundary: float = (
+ float(segment_index + 1) * DAILY_PLAN_SEGMENT_HOURS
+ )
+ var hours_remaining: float = maxf(next_boundary - elapsed_hours, 0.0)
+ return hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND
+
+
+func _clear_manual_override() -> void:
+ _manual_override_weather = DEFAULT_WEATHER
+ _manual_override_segment_index = -1
diff --git a/world/world_weather_service.gd.uid b/world/world_weather_service.gd.uid
new file mode 100644
index 0000000..8eed48d
--- /dev/null
+++ b/world/world_weather_service.gd.uid
@@ -0,0 +1 @@
+uid://v4c54isq6w74