Compare commits

...

100 commits

Author SHA1 Message Date
Atrik
6b64de08dd Add test for queries with visibility checks 2026-08-12 16:26:23 +02:00
Atrik
75ee553fab Enable attacks on foes visible through shared LOS
Adds baseRange parameter to parabolic queries for combined
2D + parabolic detection. StandGround and Chase stances now
use attack range (parabolic) with vision as baseRange, allowing
units to attack enemies visible through friendly vision.
Buildings benefit as well via BuildingAI.
2026-08-12 16:26:22 +02:00
Atrik
accfd151ed Filter out hidden targets in CanAttack
Units should not be able to attack entities with "hidden" visibility.
Visible and fogged (mirage/retainInFog) targets remain attackable.
2026-08-12 16:26:22 +02:00
Atrik
41076070a6 Add tests for CanEverReachTarget and its helpers 2026-08-12 16:26:22 +02:00
Atrik
a373c0e4ef Prevent targeting unreachable turreted entities
Introduce CanEverReachTarget in the Attack component to check whether
a target is geometrically reachable at all, accounting for height
offsets and turreted units inside buildings.

For non-parabolic attacks (e.g. melee), a simple 3D distance check
from the closest approach point is used.

For parabolic attacks (e.g. ranged), the parabolic range formula
is used to determine if the height difference is surmountable
from the closest horizontal distance to the target.

Add GetClosestApproachDistanceToTurretPoint to TurretHolder
to estimate the minimum horizontal distance to a turret point,
using its local offset and the holder's obstruction size.
Passable buildings are not considered obstacles.

Fixes units on the ground trying to attack unreachable units on walls
or towers when the projectile's arc cannot reach the required height.
2026-08-12 16:26:22 +02:00
Atrik
67b52e53be Add GetEffectiveAttackRange wrapper
Wraps RangeManager.GetEffectiveParabolicRange for unified range
resolution. Maintains symmetry with GetRange(), improves readability,
and consistently translates NEVER_IN_RANGE.

Simplifies callers in UnitAI and Attack.
2026-08-12 16:26:22 +02:00
Atrik
0f8a9f4faf Fix range target detection for StandGround stance
StandGround units now use parabolic range queries to detect enemies,
accounting for terrain elevation and height offsets. This ensures
units on hills or when 'turreted', can detect enemies
that are in parabolic range but outside flat range.

Previously, StandGround detection used flat circular queries,
causing units to miss enemies in their elevation-buffed range.
Other stances are unaffected since they chase targets anyway.
2026-08-12 16:26:22 +02:00
Atrik
79c94c99cb Rename cmp to rangeManager for clarity 2026-08-12 16:26:22 +02:00
Atrik
192454f737 Add some tests for GetEffectiveParabolicRange 2026-08-12 16:26:22 +02:00
Atrik
438f874292 Fix terrain elevation not affecting attack range
The parabolic range formula in GetEffectiveParabolicRange was only
computating height differences from manual HeightOffset values,
completely ignoring actual terrain elevation.
This meant units on hills received no tactical advantage
despite the UI stat tooltip correctly showing extended ranges.

Fixes #8889
2026-08-12 15:49:07 +02:00
Atrik
0123a28018 Fix renamed entities on TurretPoint
Oversights from fb1c0d2a82

Fixes #8808
2026-08-12 15:49:07 +02:00
Atrik
e34bdc87f4 Remove dead territory ownership code 2026-08-12 15:49:07 +02:00
Jonny McCullagh
1c2dbf2eb0 Add more diverse quotes
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
2026-08-10 10:53:00 +02:00
Ralph Sennhauser
df72c1aad1
Support scroll-rotate in Atlas
Split mouse wheel scroll handling into horizontal and vertical. Keep
vertical scroll for zooming and use horizontal to rotate around the y
axis replicating the behaviour of the main game.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-10 09:00:07 +02:00
Dunedan
40ab70b804
Inline the pre-commit/action Github action
As the latest version of pre-commit is broken right now
(https://github.com/pre-commit/pre-commit/issues/3737) and the official
pre-commit Github action doesn't allow specifying an older pre-commit
version to use, this inlines the logic of the pre-commit action into our
action.
2026-08-09 07:20:49 +02:00
Stan
f90804c78f
Add macOS hint for game mode.
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
2026-08-07 14:09:25 +02:00
phosit
d6bdf51d83
Remove CStr::FromInt64$
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
For the usecase it's better to use `fmt::format`.
2026-08-06 18:03:05 +02:00
phosit
ae936e8177
Remove CStr::FromUInt
There is `std::to_string` which does the same. For most uses it's better
to use `fmt::format`.
2026-08-06 18:03:05 +02:00
phosit
06e199b12a
Remove CStr::FromInt
There is `std::to_string` which does the same. For most uses it's better
to use `fmt::format`.
2026-08-06 18:03:05 +02:00
ShadowOfHassen
84567e66c2 Fix catafalque aura typos
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-08-06 13:30:42 +02:00
Vladislav Belov
c4e7364802
Removes unused BuildColor4ub from ModelRenderer
We don't need to calculate shading on CPU since we always use shaders.

Refs: a9c27b412b
2026-08-06 11:32:02 +02:00
Vladislav Belov
867bdf318b
Fixes missing material reference in ModelRenderer
Missed in 18a16aa135
2026-08-06 10:31:16 +02:00
Vladislav Belov
e64a138f92
Fixes outdated state on disabling GPU skinning 2026-08-04 23:06:12 +02:00
Vladislav Belov
4a7b7e7df1
Removes smart pointers from ModelRenderer
We don't need to use pointers because we know lifetimes of all
renderers. And its list statically defined. A new renderer should follow
DOD and use a separate submission list with a separate inplace renderer.
2026-08-04 23:06:10 +02:00
Vladislav Belov
fa90ddc8b6
Refactors ModelRenderer and makes it closer to DOD
Separates submitted models and moder renderer and makes relations
explicit. Now it's clear that we don't need separate ModelRenderer and
ModelVertexRenderer.
2026-08-04 23:06:09 +02:00
Vladislav Belov
764a420797
Merges ModelRenderer and ShaderModelRenderer
We use only shaders to render models so we don't need a separate class.

BatchModelRenderer was renamed in 6bc33fe8bd.
m_Renderer became unused in 0346ba1b18.
2026-08-04 23:06:07 +02:00
Vladislav Belov
8a2bb10827
Renames ShaderModelRenderer instances 2026-08-04 23:06:06 +02:00
Vladislav Belov
af9d102126
Removes legacy logic of reused ModelRendererPtr
It became unused in 4a69e9117e.
2026-08-04 23:06:04 +02:00
Ralph Sennhauser
822092fcb4
Fix set camera tooltip in Atlas player section
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
Refs: fe192fe58f
Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-04 17:22:21 +02:00
Ralph Sennhauser
6e79dc2f70
Use dynamic events for Cinema Section
Replace static events with dynamic events and name delete path function
more aptly.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-04 17:04:22 +02:00
Dunedan
6fdcd68172
Update the actions used by the lint workflow
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-08-04 10:09:22 +02:00
Dunedan
e4f4030a5e
Update the actions used by the checkrefs workflow
The workaround for the authentication problems isn't necessary with the
new version of the checkout action anymore.
2026-08-04 10:09:09 +02:00
Ralph Sennhauser
2fb6347125
Delete .already-built file prior to new build
In case the build fails this will allow other Jenkins jobs to restore
the old working state themselves.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-08-03 20:54:04 +02:00
phosit
7aa756f6a4
Remove line numbers from cppcheck supression list
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
They have to be changed to ofthen when code gets moved.
2026-08-02 14:03:28 +02:00
phosit
e712601602
PreInitGame and InitGame in the Loader
This makes the frame before the simulation starts less stuttery. Doing
it in the Loader also allows to make the progressbar more acurate.
2026-08-02 14:03:28 +02:00
Dunedan
865ca4cd55
Update the pre-commit hooks
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
2026-08-01 16:19:18 +02:00
Dunedan
dcb7cef308
Update Github actions used for pre-commit action 2026-08-01 16:19:03 +02:00
vyordan
71be79791f Use turn_id_t (int32_t) for turn-count across simulation, network and replay
Some checks failed
checkrefs / lfscheck (push) Has been cancelled
checkrefs / checkrefs (push) Has been cancelled
lint / cppcheck (push) Has been cancelled
lint / copyright (push) Has been cancelled
lint / jenkinsfiles (push) Has been cancelled
pre-commit / build (push) Has been cancelled
CSimulation2 used , CSimulation2Impl mixed /, and
CTurnManager used  for the same turn-count concept, forcing a
static_cast<int64_t> at the one place these types already met
(rejoin-test comparison).

Add  (alias for std::int32_t) in SimulationCommand.h and use
it consistently for every turn counter in CSimulation2/Impl,
CTurnManager and its subclasses (CLocalTurnManager,
CReplayTurnManager), CNetServerTurnManager/CNetClientTurnManager, and
the network wire format (m_Turn in CEndCommandBatchMessage,
CSimulationMessage, CSyncCheckMessage, CSyncErrorMessage, and
m_CurrentTurn in CLoadedGameMessage).

Turn *duration* fields (m_TurnLength, m_CommandDelay,
DEFAULT_TURN_LENGTH, COMMAND_DELAY_SP/MP, SetTurnLength,
GetSavedTurnLength return type) are left as u32 — they're milliseconds,
not a counter, and out of scope here.

Remaining turn_id_t vs size_t comparisons use std::cmp_equal or
explicit casts, matching the existing pattern in Simulation2.cpp.

Fixes #8718
2026-07-31 10:34:20 +02:00
Atrik
9ab89b01c2 Add Langbart to art credits
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-07-30 20:54:19 +02:00
Atrik
dcce6666ae Add missing garrison flag to some houses
Art work by @Langbart

Fixes #6911
2026-07-30 20:54:19 +02:00
Atrik
202bdb133a Add Atrik to art credits 2026-07-30 20:54:19 +02:00
Atrik
46981d2b90 Add missing garrison flag to German houses 2026-07-30 20:54:19 +02:00
Ralph Sennhauser
49c887b7ca
Don't nest Paths in Common settings
Doesn't make sense to have the Paths StaticBox inside the Common
settings StaticBox so make it a top-level item as well.

While at it dissolve nested constructors to make the creation of the
widgets more readable.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-30 18:43:24 +02:00
Atrik
91a37e32f6 Sort units spatially for freehand selection
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-07-30 17:19:39 +02:00
Atrik
139b08fe6d Add tests for SelectionBoxTargeting 2026-07-30 17:19:39 +02:00
Atrik
dc6da217fd Enable using SelectionBox for targeting
Introduce a new  input, a command handler and adapt
UnitAI to handle arrays of targets in a single order
2026-07-30 17:19:39 +02:00
Atrik
e41dd2d3a4 Add Math.clamp utility function 2026-07-30 17:19:39 +02:00
phosit
c7953026dd
Use switchPage in autostart
Autostart used `OpenChildPage` to open the loading page. Because it's
started as child page the program doesn't end when closing it.
2026-07-30 12:36:51 +02:00
Ralph Sennhauser
0340cc9abd
Make NUSpline public members protected
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
Add a getter for MaxDistance and make members protected and inline
initialization.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-29 18:44:03 +02:00
Ralph Sennhauser
aeeda05433
Prefix NUSpline member variables
To follow coding convention rename the vector Node to m_Nodes,
MaxDistance to m_MaxDistance and NodeCount to m_NodeCount.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-29 18:44:03 +02:00
Atrik
0da1e0c398 Reveal Gaia wonders to players
Some checks are pending
checkrefs / lfscheck (push) Waiting to run
checkrefs / checkrefs (push) Waiting to run
lint / cppcheck (push) Waiting to run
lint / copyright (push) Waiting to run
lint / jenkinsfiles (push) Waiting to run
pre-commit / build (push) Waiting to run
2026-07-29 18:02:37 +02:00
Atrik
dc1e7179a3 Reveal and notify Wonders being constructed
Fixes #8683
2026-07-29 18:02:37 +02:00
Atrik
b6d9d87ba9 Add a test for ExploreCircle 2026-07-29 18:02:37 +02:00
Atrik
839226d542 Support revealing a small area of the map 2026-07-29 18:02:37 +02:00
Vladislav Belov
4125e5b645
Adds sRGB support to GLES 2026-07-29 01:29:11 +02:00
Vladislav Belov
f781181899
Adds sRGB support for proper lighting
Currently we're incorrectly using sRGB colors as raw inputs for lighting
instead of conversion to linear space. For proper PBR we need linear
values.
2026-07-29 01:29:09 +02:00
Vladislav Belov
e2f5b20922
Fixes GL internal format of textures in compute
In compute shaders load/store operations requires sized formats.
2026-07-28 23:32:23 +02:00
Vladislav Belov
b3165505b6
Fixes possible artifacts with GPU skinning on GL
std140 requires 16 bytes alignment and it makes alignment of uvec2 in
SkinData be equal to 16 bytes instead of 8 bytes.
2026-07-28 18:12:21 +02:00
Itms
15b9a39b00
Rebuild SPIRV shaders on all shader changes
Fixes #9096
2026-07-28 16:26:16 +02:00
Ralph Sennhauser
cf7b8aab39
Preserve Cinema Path selection
Commit 81c57e8a28 added support for adding and removing paths.
Reloading path list stores the current selection but it's unused, assume
the idea was to restore it again.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-25 17:18:16 +02:00
Ralph Sennhauser
df06b6895e
Don't mix static and dynamic events
Commit 309ed5ef28 used dynamic events
which prevents some other menu items from working properly. So don't mix
static and dynamic events for top window.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-25 14:05:38 +02:00
phosit
a80446eb59
Use the profiler to profile map generation
Instead of writing the duration to the console it uses
`Engine.ProfileStart` and `Engine.ProfileStop` to profile map
generation.

Refs: #5263
2026-07-25 10:47:49 +02:00
phosit
4122f0bfb3
Use atomic instead of COMPILER_FENCE for Profiler2 2026-07-24 17:22:44 +02:00
Ralph Sennhauser
aae1bbbab2
Use new WX object macro
Added in 2010.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-24 16:51:58 +02:00
Ralph Sennhauser
8523fa5ab4
Fix Atlas build on macOS Tahoe
Bump wxWidgets, the upstream fix was backported to 3.2.9 with commit
a6fc33b416e2ebc804d80a1ac66021935c434573

Fixes: #8594
Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-24 13:14:34 +02:00
phosit
8b1e78ae1e
Use JS classes for the AI
Fixes: #6285
2026-07-23 15:15:59 +02:00
phosit
09671b49a2
Use JS classes for QueuePlan
Classes derifed from `QueuePlan` used manual inherintance.

Refs: #6285
2026-07-23 15:12:50 +02:00
phosit
327e70ea82
Use JS classes for Template and Entity
`Template` and `Entity` used a costum form of inheritance.

Refs: #6285
2026-07-23 15:12:50 +02:00
phosit
f9de339f85
Use JS classes for Terain-Analysis and InfoMap
`TerrainAnalysis` and `Accessibility` used a costum form of inheritance.

Refs: #6285
2026-07-23 15:12:50 +02:00
phosit
ea1026a1cc
Define the terrain states in a dedicated file 2026-07-23 15:12:49 +02:00
Vantha
2520c45220 Only call Engine.EndGame right before closing
Since #7786, the GUI tick completed normally after calling
`closePageCallback` instead of being cancelled like previously. Since
`Engine.EndGame` deletes the internal `g_Game` pointer, every Engine call
after it that tried to access `g_Game` caused a segfault.
This patch solves it by moving `Engine.EndGame` to the very end, right
before closing the session page.
In order to follow the convention that only functions directly closing
the page are called `closePageCallback`, that function is in turn
renamed to `closeSession`. Additionally, this allows only resolving
the promise with a bool (`showSummary`) and then only calling
`getNextPageOpenRequest` at the end of `init` for reasons of simplicity.
2026-07-22 23:51:49 +02:00
Vantha
801d21671e Correct hotkey names in Economy Walkthrough
The queue hotkey is called "session.queue", not "selection.queue"

Fixes #8939
2026-07-22 23:51:49 +02:00
Vantha
931c132b1b Fix brief text misalignment in GUI text objects
The issue was introduced by 8a2a450686.
It affected GUI text objects (CText) with `scrollbar` set to true; when
their caption was long enough for a scrollbar to be visible and then the
size changed (got bigger) so that no scrollbar was needed anymore, then
for a single frame the text was completely misaligned. This happened
because in `CText::Draw` the scrollbar wasn't updated yet, so the newly
added `GetScrollBar(0).IsVisible()` check still returned true, even
though it wouldn't end up being rendered anyway -- it was updated in
`IGUITextOwner::DrawText` eventually.

This happened to the tutorial info panel in the starting economy
walkthrough, for example. Note: in that case the scrollbar wasn't ever
drawn, but it was temporarily set visible within a frame by the JS
object resizing logic.
2026-07-22 23:51:49 +02:00
Vantha
6ed963b94c Don't check all scrollbars' visibility in CText
The CText class is designed to only work with one scrollbar, so the
any_of was pointless.
2026-07-22 23:51:49 +02:00
Vantha
a784659158 Two adjustments to the Introductory Tutorial
This patch splits the steps that said "Wait for X and then do Y" into
two different steps "Wait for X" and "Do Y".
2026-07-22 23:51:49 +02:00
Vantha
52b40aaa4f Add a tutorial panel for explaining the GUI
This patch adds a new type of tutorial steps called "GUI explanation",
with a corresponding GUI panel. The purpose of it is to explain what a
certain GUI element does. To make use of it the trigger script has to
specify the target GUI object's name as well as the side on which to
place the explanation panel relative to the target itself. The panel
then highlights the target object by fading everything else out with
black and also uses an arrow to point to it. Whilever the target GUI
object is hidden, the panel hides the background fade too and shows a
warning message.
Unlike for the other steps, the TutorialManager does not hide the
previously active panel when showing a GUI explanation, but instead
only disables it, since it could contain relevant information and the
GUI explanation panel is visibly placed "above" all other panels (in the
Z axis).
2026-07-22 23:51:49 +02:00
Vantha
27fbf02ff4 Improve the ending of tutorials
This patch introduces two functions `CompleteTutorial` and
`FailTutorial` for the tutorial steps to call. They can pass a
message to it to be shown on the victory/defeat screen.
`CompleteTutorial` is also called automatically when all steps are
finished, which resolves #8583 and prevents it from happening in the
future.
`FailTutorial` isn't used anywhere at the moment, but it'll be useful
for the future.

Fixes #8583
2026-07-22 23:51:49 +02:00
Vantha
f1a9c503b8 Support custom hint & button captions in the tutorial
This patch allows steps to override the default hint caption and button
caption set by the PanelControlSection class. This allows them to better
communicate what the player is supposed to do, e.g. don't show "Follow
the instructions" if the player is only supposed to wait.
2026-07-22 23:51:49 +02:00
Vantha
f39e71cca0 Move calls in playersFinished to handlers
This patch fixes the TODO comment in playersFinished and moves
all of the calls to handlers. The only reason for adding back the
function `handlePlayersFinished` is to prevent duplication in the "won"
and "defeated" message handlers. In the future, code to execute when
players finish in the future should exclusively be added by registering
new handlers, not by adding it to that function.
2026-07-22 23:51:49 +02:00
Vantha
6456bccebe Replace old GetState and SetState calls
966727b52e made the player state an enum and introduced more descriptive
functions to achieve the same thing, but it seems they were forgotten to
be replaced in a few places, which this patch fixes.
2026-07-22 23:51:49 +02:00
Vantha
0cc025f675 Indicate if a tutorial step is already done
If a new tutorial step has already been completed by the player it isn't
skipped, but instead shown with the continue button, so that the player
can manually switch to the next one. However, it wasn't well
communicated to the player that this was the case and why the continue
button was shown at the same time as the instruction. This patch adds a
hint "You have already done this." to explain the this to the player.
2026-07-22 23:51:49 +02:00
Vantha
05d434830f Add hotloading support to the tutorial UI 2026-07-22 23:51:49 +02:00
Vantha
2522c305dd Allow defining tutorial steps more compactly
This allows the trigger scripts to combine consecutive tutorial steps
that only differ in the `panelData.text` property into a single one that
instead defines a `panelData.texts` array.
2026-07-22 23:51:49 +02:00
Vantha
7e77065c56 Add an info panel to the tutorial
This patch adds a new tutorial step type and corresponding tutorial
panel labelled "info". It is intended to display steps whose purpose it
is to explain something to the player and give tips -- rather than
just giving instructions and telling the player what to do, like the
instruction panel does.
Also, it can show several related steps at once. To do that, the first
step has to set the 'appendable' flag to true and can also define a
title; then the succeeding steps can then set the 'appendToPrevious'
flag to true in order to do exactly that.
Note: 'info'-type tutorial steps are still supposed to be able to define
triggers, switch to the next step on their own and hide the continue
button on the info panel, to teach the topic interactively, e.g.
"Select a unit by left-clicking on it."

In order to prevent code duplication between `InfoPanel` and
`InstructionPanel` a generic superclass `TutorialPanel` is introduced,
which manages the text, hint, and button objects.
2026-07-22 23:51:49 +02:00
Vantha
6dd8139b43 Only show the last instruction in the tutorial
The idea is to add different types of panels to the tutorial in the
future. And the only texts that players might read multiple times are
tips or explanations, which the plan is to display on an entirely
separate panel anyway.
2026-07-22 23:51:49 +02:00
Vantha
c2c8f5bdf2 Remove pointless trainingDone from tutorial
In `Init` it was always overwritten to false, so `IsDone` always returned
false anyway.
2026-07-22 23:51:49 +02:00
Vantha
1f7d384c29 Fix player command handling in the tutorial
Move the code that checks whether a player command is a "continue
tutorial" into a separate method, so that it never overrides the player
command action of the active tutorial step. This could previously
happen if a step had the showContinueButton flag was set to true (and
a OnPlayerCommand method defined).
2026-07-22 23:51:49 +02:00
Vantha
2f27dcc97d Rename index to stepIndex in Tutorial.js
Makes it more clear what it is an index of.
2026-07-22 23:51:49 +02:00
Vantha
6e6864fd7c Improve internal naming of tutorial triggers
Make the naming consistent with e.g. the Triggers Demo map
Remove the "On" at the start, since it's otherwise used for message
handlers, and replace "Trigger" at the end with "Action", since that
more accurately describes what it is.
2026-07-22 23:51:49 +02:00
Vantha
702e3f1113 Revise tutorial step handling
- Remove the delay functionality from the tutorial. It was unused and
  that for a reason. Switching to the next step after a fixed amount of
  time is never wanted. It was only used to force-show the ready button
  in one case, but a designated bool communicates the purpose better.
- Rename the "ready" button to "continue" as it fits better.
- Rename "leave" to "isLast" as it's more descriptive.
- Rename the "warning" object of the instruction panel to "hint" as it's
  not always display warnings, and move its captions to the class
  prototype like the coding conventions state.
- Simplify the logic in NextStep a bit to make it more readable.
2026-07-22 23:51:49 +02:00
Vantha
f3b71a1fbf Remove unused this.fullText from the tutorial 2026-07-22 23:51:49 +02:00
Vantha
e02a2e0b31 Add support for different types of tutorial steps
This patch doesn't add any new functionality and keeps the introductory
tutorial and economy walkthrough as they are. Instead, it rearranges some
code to enable easily adding different types of tutorial steps in the
future. The idea is for each type to be displayed on a different panel
and for the TutorialManager to switch back and forth between them, and
to handle and translate the received messages from the simulation and
pass them along to the active panel. Currently, there are only
"instruction"-type steps, which are handled by the InstructionPanel in
the GUI. But new ones can be added in the future, like information boxes
or bigger objectives.
2026-07-22 23:51:49 +02:00
Vantha
ab4a9d6def Rename tutorial "goals" to "steps"
While it's currently the case everywhere, tutorial "steps" don't
necessarily have to be goals, they could be stuff like tips or info too
in general, if different types of "steps" are added in the future.
2026-07-22 23:51:49 +02:00
Vantha
dd396f19b6 Separate handling of tutorial goals and warnings
This is more explicit and makes the code cleaner.
2026-07-22 23:51:49 +02:00
Vantha
ca8a0a5611 Move "TutorialPanel" style to session styles
It's not used anywhere else besides there, so that where it belongs.
2026-07-22 23:51:49 +02:00
Vantha
663f5c5ff6 Create designated tutorial class in the GUI
This keeps messags.js shorter, prevents unnecessary GetGUIObjectByName
calls and, most importantly, makes it a lot more extensible for the
future.
2026-07-22 23:51:49 +02:00
Vantha
0e6e0280fc Prevent segfault in CMiniMap::Tick
Oversight in 01476b9836
For example, it could cause a segfault when rendering a frame after
EndGame was called therefore g_Game already deleted.
2026-07-22 23:51:49 +02:00
Ralph Sennhauser
adfe6b0c36
Convert wxString using ToStdWstring
Avoid using c-style cast to convert wxString to std::wstring.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-22 16:49:41 +02:00
Ralph Sennhauser
eae57d9aab
Fix clang-tidy modernize-avoid-bind check
And enable the check in the config.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-21 17:15:07 +02:00
Ralph Sennhauser
91a4b834a8
Add clang-tidy linting script
Add groups we might be interested in and disable failing checks.

Signed-off-by: Ralph Sennhauser <ralph.sennhauser@gmail.com>
2026-07-21 17:15:07 +02:00
267 changed files with 23389 additions and 20170 deletions

View file

@ -11,7 +11,7 @@ jobs:
lfscheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Fetch the base branch
run: git fetch origin ${{ env.BASE_SHA }}
@ -24,27 +24,14 @@ jobs:
env:
GIT_LFS_SKIP_SMUDGE: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Add remote fork origin for LFS
run: |
PR_REPO="${{ gitea.event.pull_request.head.repo.full_name || gitea.repository }}"
git remote add ${{ gitea.actor }} https://gitea.wildfiregames.com/${PR_REPO}.git
- name: Workaround for authentication problem with LFS
# https://gitea.com/gitea/act_runner/issues/164
run: |
git config --local \
http.${{ gitea.server_url }}/${{ gitea.repository }}.git/info/lfs/objects/.extraheader ''
PR_REPO="${{ gitea.event.pull_request.head.repo.full_name || gitea.repository }}"
EXTRAHEADER="$(git config --get --local http.${{ gitea.server_url }}/.extraheader)"
git config --local \
http.${{ gitea.server_url }}/${PR_REPO}.git/info/lfs/objects/batch.extraheader \
'${EXTRAHEADER}'
git config --local \
http.${{ gitea.server_url }}/${PR_REPO}.git/info/lfs/objects/.extraheader ''
- name: Download necessary LFS assets
shell: sh {0}
run: |

View file

@ -12,7 +12,7 @@ jobs:
cppcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# cache only works for items in workspace, so configure apt to allow for caching.
- name: Setup apt cache locations
@ -26,7 +26,7 @@ jobs:
- name: Cache apt pkg db and and deb files
id: apt-cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
apt-cache
@ -49,11 +49,11 @@ jobs:
copyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 100
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.11"
@ -66,10 +66,10 @@ jobs:
jenkinsfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20'

View file

@ -3,22 +3,27 @@ name: pre-commit
on:
- push
- pull_request
env:
PRE_COMMIT_VERSION: 4.6.0
jobs:
build:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
- id: restore-pip-cache
uses: actions/cache/restore@v4
- uses: actions/cache@v6
with:
key: pip-cache-v1-${{ github.workflow }}
key: pip-cache-v1-${{github.workflow}}-${{env.pythonLocation}}-${{env.PRE_COMMIT_VERSION}}
path: ~/.cache/pip
- uses: pre-commit/action@v3.0.1
- uses: actions/cache/save@v4
if: steps.restore-pip-cache.outcome == 'success'
- run: python -m pip install pre-commit=="${{ env.PRE_COMMIT_VERSION }}"
shell: bash
- run: python -m pip freeze --local
shell: bash
- uses: actions/cache@v6
with:
key: pip-cache-v1-${{ github.workflow }}
path: ~/.cache/pip
path: ~/.cache/pre-commit
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- run: pre-commit run --show-diff-on-failure --color=always --all-files
shell: bash

3
.gitignore vendored
View file

@ -32,8 +32,9 @@ build/bin/
source/tools/spirv/rules.json
# Linting
cppcheck-error.log
compile_commands.json
copyright-check-error.log
cppcheck-error.log
# Files generated by running premake5
build/premake/.gccmachine.tmp

View file

@ -34,7 +34,7 @@ repos:
\.patch$
)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.9
rev: v0.16.0
hooks:
- id: ruff-check
args:
@ -56,7 +56,7 @@ repos:
files: ^binaries/
exclude: (^binaries/data/mods/(mod|public)/art/.*\.xml|\.dae$)
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.12.0-2
rev: v3.13.1-1
hooks:
- id: shfmt
args:
@ -67,7 +67,7 @@ repos:
hooks:
- id: shellcheck
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.45.0
rev: v0.47.0
hooks:
- id: markdownlint
language_version: 22.14.0
@ -77,13 +77,13 @@ repos:
^source/third_party/
)
- repo: https://github.com/adrienverge/yamllint
rev: v1.37.1
rev: v1.38.0
hooks:
- id: yamllint
args:
- --strict
- repo: https://github.com/eslint/eslint
rev: v9.39.2
rev: v10.8.0
hooks:
- id: eslint
language_version: 22.14.0

View file

@ -78,6 +78,9 @@
<optional>
<attribute name="define"/>
</optional>
<optional>
<attribute name="srgb"/>
</optional>
</element>
</zeroOrMore>
</interleave>

View file

@ -12,10 +12,10 @@
layout(set = 2, binding = LOCATION, FORMAT) uniform image2D NAME
#if USE_DESCRIPTOR_INDEXING
#define STORAGE_BUFFER(LOCATION) \
layout(set = 2, binding = LOCATION)
layout(std430, set = 2, binding = LOCATION)
#else
#define STORAGE_BUFFER(LOCATION) \
layout(set = 1, binding = LOCATION)
layout(std430, set = 1, binding = LOCATION)
#endif
#else
// We use offset to the binding slot for OpenGL to avoid overlapping with other
@ -23,7 +23,7 @@
#define STORAGE_2D(LOCATION, FORMAT, NAME) \
layout(binding = LOCATION, FORMAT) uniform image2D NAME
#define STORAGE_BUFFER(LOCATION) \
layout(binding = LOCATION)
layout(std430, binding = LOCATION)
#endif
#endif // STAGE_COMPUTE

View file

@ -51,6 +51,18 @@
</textures>
</variant>
</group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_achaemenids.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/>

View file

@ -33,6 +33,18 @@
</textures>
</variant>
</group>
<group>
<variant frequency="1" name="ungarrisoned" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_celt.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/>

View file

@ -53,6 +53,18 @@
</textures>
</variant>
</group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_celt.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/>

View file

@ -134,6 +134,20 @@
</props>
</variant>
</group>
<group>
<variant frequency="1" name="ungarrisoned" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_germ.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/>

View file

@ -106,6 +106,18 @@
</props>
</variant>
</group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_iber.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/>

View file

@ -47,6 +47,18 @@
</textures>
</variant>
</group>
<group>
<variant name="ungarrisoned" frequency="1" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_rome.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/destruction_small.xml"/>

View file

@ -43,6 +43,18 @@
</textures>
</variant>
</group>
<group>
<variant frequency="1" name="ungarrisoned" />
<variant name="garrisoned">
<props>
<prop
actor="props/special/common/garrison_flag_seleucids.xml"
attachpoint="garrisoned"
selectable="false"
/>
</props>
</variant>
</group>
<group>
<variant name="alive" frequency="1"/>
<variant file="structures/sele/light_damage.xml"/>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:995da95e21261ae0a2718baee69d76ae5f6823d21eaefaa708a89651f2099bb0
size 586243
oid sha256:ed243a229225ec51ee090588a63307cbd2efedecca06f4f1a0922a4d51ee3967
size 586427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8e022a3ac132a32061583743d1322fc8792cdd6e7be57857547a8ed3cefbed6
size 525300
oid sha256:3ce649aa943771357d7c19f48294af719c50ca14178c32be60992f17845d685d
size 525483

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21971f20f5567f820e0f3fffdd21015e2a5ffaee309be7b6efc38d3fc56aecd3
size 503936
oid sha256:d1d867c2e9f3db233847da547c466325ba9f0a9e7478175281f3538cbb694709
size 504112

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c37b1b7f12a429cd07873d0fdb7cc8692f6578b3e5320a6be0e68420fb4688e
size 780868
oid sha256:cc9c95992889a4c2930a39bd20f7c86b73f7c7ddb3b36492650afae2ae424641
size 781051

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1230a92373c4e4684f791594efb35bb7d6c091377a11bd8f0daafd13816d04ec
size 56755
oid sha256:6779d6ca2067d6dd5594b3d2e5ef82d08de97ad058ce7084eb0bf8ae3290f4c2
size 56953

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30a395c3b5a50214a689abe968ad15edb60212c3ea14041a2edc07295a4462c6
size 45317
oid sha256:9e6aca91d66df31b5820f39025c57937ce6a1593199b8a068722fdec5b0675f6
size 45523

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce554ad452cf75bd4dbb9da5de9830309a1dccf72f72f6a74475078af7f0e1c4
size 42851
oid sha256:07013561c7e46b020acbd03d4f371300c38d3e364c99a1e7bbcf7467aa8a34e6
size 43041

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60f0614c1092395065c4ba8fe6bed64da873d38c579c9cbc400e9a9b8f30f3a8
size 272099
oid sha256:1416efa429cd64471d50e969c43ee723382c031c676414433f8fcd58176cfa77
size 272274

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c6e7d6264d7b3e03466bf1de58ab366a023120c6f78142aebc6570218a4803d9
size 274339
oid sha256:9f9e6debd5f2a9659e19e74b5826d3488167edb9a897ae1ed6e4db10742d94e8
size 274543

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0624fc07116aa70c14527457fced3afcf7b36df1535bbf10250455e6254aeed6
size 372821
oid sha256:091d68b7800d9141e87ba8aaf0fa93c5bc7d260dd459774075de610ca34855e2
size 372997

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ef199b28b9d9ef19e0817b189d9e6f1e2f9861ca211890b8d714f9fe05f5c61
size 420601
oid sha256:c1413de5371443f1249d87fa40a821c880c749513a0347d9128a99472bec7b33
size 420808

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:adf219c08e65bd7a149726d325b6f2b4fd67effc3dd1bc211c3163774ac8308e
size 201143
oid sha256:8aba3f3dc5a909ddadd9af5e7abfe9094f55ed6c97b39e7f97b09dc1cb951f4d
size 201315

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f8c688df60c4880dc92b6898728d4cef443c86831a2a2e87e61dd44661b58240
size 205000
oid sha256:0c3298efebf687a07d4693d9014c51e1e870e95fb9d9024c9126b40adfec75a5
size 205172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d86be00b7e911108e6666422a56eb061a996e6cf0bfe489d6c99f84be752444
size 216525
oid sha256:b97f6dff13f4639e746e1ce79c120169bccb2e09d4507dc900075cf85e788590
size 216697

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e258d0b3a7b5c3fe2275c2816c636b7478ee1051df31a2cbe2f516465c692f9
size 239166
oid sha256:8d764a2d5a712f53f08560504f6841ed00b5268fea2a283d2d5254d57ce1df76
size 239339

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:82ff53473c25d6e17aeb97f795b543df3ec1c423e8ecb8398d0d014beac57f4e
size 101895
oid sha256:dccc1521e6e626e75ac2f05d2c8b2c2963ee751da08a9e819e4c28954b19f01a
size 102099

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2377bddedc6251acbe8f03bf1820d368b21cbf88fc2783695ff31036af8ed4e3
size 82812
oid sha256:ae1042461639bca3d3d73fb3e723ca404c8a897ee0e2ad417af1eebb5849d23f
size 83027

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5f1530adecb42af05b6b81719a5c4d00fa06a5eff33f50eda8312ce2e0fcd5f
size 77866
oid sha256:b71b8848a0a2e503adbff9c1e09c06c1e76e16537f38af5b35395a04f11b365a
size 78066

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c393e007e24473c1332c7d0c3a8093e0b542974da00b51b853ee413e45818ade
size 91109
oid sha256:256b3864edc754955814d5eec113e73fded49525d2e855a1171a4e4b495ffcc4
size 91304

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8fb0f356f48284704d81b601c7947f32e91995757e8c2fc390ecaf772fbd950
size 84604
oid sha256:4ef0415cce15f4d1ff4e342c23a995ee5081d21b7f84eb6c19fb89c1d63f84a7
size 84789

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c2b0d0a461535e0802d2a07395afb4546515ef4d3ccc3eab18d55a0993bc165
size 184407
oid sha256:2ab540ad518de662ba9a4dbe4d0d0590b6ee0b771bc482073296c949ee33d571
size 184610

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:828e2e63b9686a601b3641be10f26e3b099ced5b8dc8d453ab94d41270f22b39
size 213385
oid sha256:39a6a575b8bbb0f82ed8cf4056e0df932a59fd6e3343005625b3b76644c6879c
size 213561

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4825c81a245ddae5637f3b2dee78f7749e27a48d2efa244bc95af075ddabcd1a
size 144598
oid sha256:93341f17ad185e28ce62a533f1db457aec4b69bb2f67c98e4b89e4bcf9503b6a
size 144784

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:427f9512af696afaae0721374a86ceb0e1432602013cb3dde818bfcd2791c163
size 212264
oid sha256:dc27dba302816891bb8f33bc65d9d68b2b330f946256aa966e5d684714af9fe2
size 212447

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19ca2828e5e6b99065d3d901c1a90f1b73df065dc94b3a4b76b4e5804890c137
size 349847
oid sha256:270fe9095c3894fb448cb91668ad48d886088006c689f0efa148e8a78a3de043
size 350053

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:57039fff36fbba71de79b89614fea988a98bdc85b2bdc5ef88ce3a632da95a55
size 340693
oid sha256:977b2ffb5dc9cf0364d125f0b3dd17e1fb0cd482b5c96fbf7c61b6e6a030db86
size 340906

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:221eeca4ee2e46023d346e80c7a3b4b3852699e0c193e31fa58e0619e858f7a2
size 272252
oid sha256:f177172ef923455050064af60377f2e1b171ff4ec72fa9aa5fdc5c4c28000550
size 272435

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:afc01d8e7e7efc988b2f78b58cfb6b48b5453b8786609e9ce5db38bc617a7984
size 279942
oid sha256:acdaaf09848dc5cb7cf207c7bb535865aa4199a5a50609ca3b632ce5d88d95a2
size 280139

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d1a87bfd33e5c55c8bc28eac1985f17fe5d4c51d853b4984f85051ce96a62d8
size 2598

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5ca1fbd89c75157abb88e88d047273c93a61ff085c1eb5311a36ce07b4133590
size 2611

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e961312d0b9cc321af9737fef9042e5c775d22bfd006ffa3697478df0425c0e7
size 1305

View file

@ -330,3 +330,20 @@ Math.euclidDistance3D = function(x1, y1, z1, x2, y2, z2)
{
return Math.sqrt(Math.euclidDistance3DSquared(x1, y1, z1, x2, y2, z2));
};
/**
* Clamps a value between a minimum and maximum range.
*
* @param {number} value - The value to clamp
* @param {number} min - Minimum allowed value
* @param {number} max - Maximum allowed value
* @returns {number} The clamped value (min <= result <= max)
*/
Math.clamp = function(value, min, max)
{
if (value < min)
return min;
if (value > max)
return max;
return value;
};

View file

@ -442,3 +442,17 @@ Vector3D.div = function(v, f)
{
return new Vector3D(v.x / f, v.y / f, v.z / f);
};
/**
* Linear interpolation between this vector and another.
* @param {Vector2D} v - The target vector.
* @param {number} t - Interpolation factor (0 = this, 1 = v).
* @returns {Vector2D} A new vector at the interpolated position.
*/
Vector2D.prototype.lerp = function(v, t)
{
return new Vector2D(
this.x + (v.x - this.x) * t,
this.y + (v.y - this.y) * t
);
};

View file

@ -18,7 +18,7 @@
<object name="gameStateNotifications"
type="text"
ghost="true"
z="199"
z="200"
size="100%-110 40 100%-110 40"
font="mono-stroke-10"
textcolor="255 219 77"
@ -36,7 +36,7 @@
<object name="dataCounter"
type="text"
ghost="true"
z="199"
z="200"
size="100%-100 40 100%-5 54"
font="mono-10"
textcolor="white"
@ -58,7 +58,7 @@
<object name="glbWaterMark"
hidden="true"
hotkey="screenshot.watermark"
z="200"
z="300"
>
<action on="Press">
this.hidden = !this.hidden;

View file

@ -127,17 +127,4 @@
text_align="left"
/>
<style name="TutorialPanel"
buffer_zone="8"
font="sans-bold-14"
scrollbar="true"
scrollbar_style="ModernScrollBar"
scroll_bottom="true"
textcolor="white"
text_align="left"
text_valign="top"
sprite="ModernDarkBox"
sprite_overlay="ModernDarkBoxGoldBorder"
/>
</styles>

View file

@ -72,6 +72,7 @@
"List": [
{ "nick": "Alexandermb", "name": "Daniel Morgado" },
{ "nick": "Athos" },
{ "nick": "Atrik" },
{ "nick": "Basshunter", "name": "Sebastián Gómez" },
{ "nick": "BigTiger", "name": "Nathanael P. Moore" },
{ "nick": "Brightgalrs", "name": "Robert D. Schultz" },
@ -80,6 +81,7 @@
{ "nick": "Enrique", "name": "Enrique Keykens Espolio" },
{ "nick": "Gen.Kenobi", "name": "Daniel Schubert" },
{ "nick": "Historicity", "name": "Shane" },
{ "nick": "Langbart" },
{ "nick": "Leyto", "name": "Valentin Levalet" },
{ "nick": "LordGood", "name": "Johnathan B. Good" },
{ "nick": "m7600" },

View file

@ -3,7 +3,7 @@
*/
class Menu
{
constructor(pauseControl, playerViewControl, chat, closePageCallback)
constructor(pauseControl, playerViewControl, chat, closeSession)
{
this.menuButton = Engine.GetGUIObjectByName("menuButton");
this.menuButton.onPress = this.toggle.bind(this);
@ -26,7 +26,7 @@ class Menu
this.buttons = handlerNames.map((handlerName, i) =>
{
const handler = new MenuButtons.prototype[handlerName](menuButtons[i], pauseControl, playerViewControl, chat);
this.initButton(handler, menuButtons[i], i, closePageCallback);
this.initButton(handler, menuButtons[i], i, closeSession);
return handler;
});
@ -62,12 +62,12 @@ class Menu
this.startAnimation();
}
initButton(handler, button, i, closePageCallback)
initButton(handler, button, i, closeSession)
{
button.onPress = () =>
{
this.close();
handler.onPress(closePageCallback);
handler.onPress(closeSession);
};
button.size.top = this.buttonHeight * (i + 1) + this.margin;
button.size.bottom = this.buttonHeight * (i + 2);

View file

@ -283,13 +283,13 @@ MenuButtons.prototype.Exit = class
this.pauseControl = pauseControl;
}
onPress(closePageCallback)
onPress(closeSession)
{
for (const name in QuitConfirmationMenu.prototype)
{
const quitConfirmation = new QuitConfirmationMenu.prototype[name]();
if (quitConfirmation.enabled())
quitConfirmation.display(closePageCallback);
quitConfirmation.display(closeSession);
}
}
};

View file

@ -4,7 +4,7 @@
type="text"
hidden="true"
ghost="true"
z="199"
z="160"
size="100%-300 60 100%-110 80"
font="mono-10"
textcolor="white"

View file

@ -4,15 +4,12 @@
*/
class NetworkStatusOverlay
{
constructor(closePageCallback)
constructor(closeSession)
{
this.netStatus = Engine.GetGUIObjectByName("netStatus");
this.loadingClientsText = Engine.GetGUIObjectByName("loadingClientsText");
Engine.GetGUIObjectByName("disconnectedExitButton").onPress = () =>
{
closePageCallback({ [Engine.openRequest]: endGame(true) });
};
Engine.GetGUIObjectByName("disconnectedExitButton").onPress = () => { closeSession(true); };
registerNetworkStatusChangeHandler(this.onNetStatusMessage.bind(this));
registerClientsLoadingHandler(this.onClientsLoadingMessage.bind(this));

View file

@ -4,7 +4,7 @@
*/
class SessionMessageBox
{
async display(closePageCallback)
async display(closeSession)
{
closeOpenDialogs();
g_PauseControl.implicitPause();
@ -19,12 +19,9 @@ class SessionMessageBox
"buttonCaptions": this.Buttons ? this.Buttons.map(button => button.caption) : undefined,
});
this.closeSession = closeSession;
if (this.Buttons && this.Buttons[buttonId].onPress)
{
const ret = this.Buttons[buttonId].onPress.call(this);
if (ret !== undefined)
closePageCallback({ [Engine.openRequest]: ret });
}
this.Buttons[buttonId].onPress.call(this);
if (this.ResumeOnClose)
resumeGame();

View file

@ -9,7 +9,7 @@
</object>
<object hotkey="session.gui.tutorial.toggle">
<action on="Press">toggleTutorial();</action>
<action on="Press">g_TutorialManager?.toggleVisibility();</action>
</object>
<object hotkey="silhouettes">

View file

@ -507,6 +507,49 @@ var unitFilters = {
}
};
// Choose, inside a list of entities, which ones are targetable foes for boxtargeting.
// We also filter out entities that aren't visible, or low priority like livestocks
function getTargetableEntities(ents)
{
const player = g_ViewedPlayer;
const simState = GetSimState();
const isEnemy = simState.players[player].isEnemy;
// Filter valid target candidates
const candidates = ents.filter(entity =>
{
const entState = GetEntityState(entity);
return entState &&
unitFilters.isUnit(entity) &&
entState.visibility != "hidden";
});
// Enemy players
const enemyPlayers = candidates.filter(entity =>
{
const entState = GetEntityState(entity);
return isEnemy[entState.player] && entState.player !== 0 && !hasClass(entState, "Domestic");
});
if (enemyPlayers.length > 0)
return enemyPlayers;
// Gaia
const gaiaUnits = candidates.filter(entity =>
{
const entState = GetEntityState(entity);
return entState.player === 0;
});
if (gaiaUnits.length > 0)
return gaiaUnits;
// Domestic animals
return candidates.filter(entity =>
{
const entState = GetEntityState(entity);
return hasClass(entState, "Domestic");
});
}
// Choose, inside a list of entities, which ones will be selected.
// We may use several entity filters, until one returns at least one element.
function getPreferredEntities(ents)
@ -572,16 +615,19 @@ function handleInputBeforeGui(ev, hoveredObject)
case INPUT_BANDBOXING:
{
const bandbox = Engine.GetGUIObjectByName("bandbox");
const isAttackMode = Engine.HotkeyIsPressed("session.attack") && !g_IsObserver;
switch (ev.type)
{
case "mousemotion":
{
const rect = updateBandbox(bandbox, ev, false);
const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer);
const preferredEntities = getPreferredEntities(ents);
g_Selection.setHighlightList(preferredEntities);
const player = isAttackMode ? -1 : g_ViewedPlayer;
const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], player);
const highlightEntities = isAttackMode ? getTargetableEntities(ents) : getPreferredEntities(ents);
g_Selection.setHighlightList(highlightEntities);
return false;
}
@ -589,6 +635,30 @@ function handleInputBeforeGui(ev, hoveredObject)
if (ev.button == SDL_BUTTON_LEFT)
{
const rect = updateBandbox(bandbox, ev, true);
if (isAttackMode)
{
// Box attack behavior
const ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], -1);
// Filter to only enemy units
const enemyUnits = getTargetableEntities(ents);
// Get currently selected friendly units
const selectedUnits = g_Selection.toList();
if (selectedUnits.length && enemyUnits.length)
{
// Sort entities by position
const sortedCombattants = sortEntitiesForEngagement(selectedUnits, enemyUnits);
// Distribute attack orders
distributeAttackOrders(sortedCombattants.attackers, sortedCombattants.targets);
}
g_Selection.setHighlightList([]);
inputState = INPUT_NORMAL;
return true;
}
const ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer));
g_Selection.setHighlightList([]);
@ -1371,6 +1441,7 @@ function positionUnitsFreehandSelectionMouseUp(ev)
for (let i = 1; i < inputLine.length; ++i)
lengthOfLine += inputLine[i].distanceTo(inputLine[i - 1]);
// Filter units that can move
const selection = g_Selection.filter(ent => !!GetEntityState(ent).unitAI).sort((a, b) => a - b);
// Checking the line for a minimum length to save performance.
@ -1380,39 +1451,43 @@ function positionUnitsFreehandSelectionMouseUp(ev)
return !!action && doAction(action, ev);
}
// Even distribution of the units on the line.
let p0 = inputLine[0];
let entityDistribution = [p0];
const distanceBetweenEnts = lengthOfLine / (selection.length - 1);
let freeDist = -distanceBetweenEnts;
// Sort units by their projection onto the line direction (parallel sorting)
const sortedSelection = sortEntitiesAlongLine(selection, inputLine[0], inputLine[inputLine.length - 1], true);
for (let i = 1; i < inputLine.length; ++i)
const selectionCount = sortedSelection.length;
const targetPositions = [];
// Distribute units evenly along the polyline
for (let i = 0; i < selectionCount; ++i)
{
const p1 = inputLine[i];
freeDist += inputLine[i - 1].distanceTo(p1);
const t = i / (selectionCount - 1);
const targetDistance = t * lengthOfLine;
while (freeDist >= 0)
let accumulated = 0;
let targetPoint = inputLine[0];
for (let j = 1; j < inputLine.length; ++j)
{
p0 = Vector2D.sub(p0, p1).normalize().mult(freeDist).add(p1);
entityDistribution.push(p0);
freeDist -= distanceBetweenEnts;
const segStart = inputLine[j - 1];
const segEnd = inputLine[j];
const segLength = segEnd.distanceTo(segStart);
if (accumulated + segLength >= targetDistance)
{
// Interpolate within this segment
const remaining = targetDistance - accumulated;
const frac = remaining / segLength;
targetPoint = segStart.lerp(segEnd, frac);
break;
}
accumulated += segLength;
}
targetPositions.push(targetPoint);
}
// Rounding errors can lead to missing or too many points.
entityDistribution = entityDistribution.slice(0, selection.length);
entityDistribution = entityDistribution.concat(new Array(selection.length - entityDistribution.length).fill(inputLine[inputLine.length - 1]));
if (Vector2D.from3D(GetEntityState(selection[0]).position).distanceTo(entityDistribution[0]) +
Vector2D.from3D(GetEntityState(selection[selection.length - 1]).position).distanceTo(entityDistribution[selection.length - 1]) >
Vector2D.from3D(GetEntityState(selection[0]).position).distanceTo(entityDistribution[selection.length - 1]) +
Vector2D.from3D(GetEntityState(selection[selection.length - 1]).position).distanceTo(entityDistribution[0]))
entityDistribution.reverse();
Engine.PostNetworkCommand({
"type": isAttackMovePressed() ? "attack-walk-custom" : "walk-custom",
"entities": selection,
"targetPositions": entityDistribution.map(pos => pos.toFixed(2)),
"entities": sortedSelection,
"targetPositions": targetPositions.map(pos => pos.toFixed(2)),
"targetClasses": Engine.HotkeyIsPressed("session.attackmoveUnit") ? { "attack": ["Unit"] } : { "attack": ["Unit", "Structure"] },
"queued": Engine.HotkeyIsPressed("session.queue"),
"pushFront": Engine.HotkeyIsPressed("session.pushorderfront"),
@ -1420,9 +1495,10 @@ function positionUnitsFreehandSelectionMouseUp(ev)
});
// Add target markers with a minimum distance of 5 to each other.
const entitiesBetweenMarker = Math.ceil(5 / distanceBetweenEnts);
for (let i = 0; i < entityDistribution.length; i += entitiesBetweenMarker)
DrawTargetMarker({ "x": entityDistribution[i].x, "z": entityDistribution[i].y });
const stepDistance = lengthOfLine / (selectionCount - 1);
const entitiesBetweenMarker = Math.max(1, Math.ceil(5 / stepDistance));
for (let i = 0; i < targetPositions.length; i += entitiesBetweenMarker)
DrawTargetMarker({ "x": targetPositions[i].x, "z": targetPositions[i].y });
Engine.GuiInterfaceCall("PlaySound", {
"name": "order_walk",
@ -1929,3 +2005,108 @@ function clearSelection()
g_Selection.reset();
preSelectedAction = ACTION_NONE;
}
function distributeAttackOrders(attackers, targets)
{
if (targets.length < 1)
return;
// Play attack sounds from a sample of units in selection
const soundCount = Math.min(3, attackers.length);
for (let i = 0; i < soundCount; i++)
{
const t = soundCount === 1 ? 0 : i / (soundCount - 1);
const attackerIndex = Math.floor(t * (attackers.length - 1));
setTimeout(() =>
{
Engine.GuiInterfaceCall("PlaySound", {
"name": "order_attack",
"entity": attackers[attackerIndex]
});
}, i * 180);
}
Engine.PostNetworkCommand({
"type": "attack-group",
"entities": attackers,
"targets": targets,
"queued": Engine.HotkeyIsPressed("session.queue"),
"allowCapture": false
});
}
/**
* Sorts attackers and targets for left-to-right engagement across the battle line.
*
* Entities are sorted by their perpendicular distance to the line connecting the
* two army centers, creating natural pairings across the battle front.
*
* @param {number[]} attackers - Array of attacking entity IDs.
* @param {number[]} targets - Array of target entity IDs.
* @returns {Object} { attackers: number[], targets: number[] } - Both arrays sorted
* for cross-line engagement (targets automatically reversed).
*/
function sortEntitiesForEngagement(attackers, targets)
{
const getPosition = (id) => GetEntityState(id).position;
const computeAveragePosition = (entities) =>
{
if (entities.length === 0) return new Vector2D(0, 0);
const vectors = entities.map(id => Vector2D.from3D(getPosition(id)));
return Vector2D.average(vectors);
};
// Calculate the axis of engagement - the line between the groups' average positions
const avgAttackers = computeAveragePosition(attackers);
const avgTargets = computeAveragePosition(targets);
// Sort each group by perpendicular distance to the engagement line
const sortedAttackers = sortEntitiesAlongLine(attackers, avgAttackers, avgTargets);
const sortedTargets = sortEntitiesAlongLine(targets, avgAttackers, avgTargets);
// Reverse targets so the leftmost attacker pairs with leftmost target
return {
"attackers": sortedAttackers,
"targets": sortedTargets.reverse()
};
}
/**
* Sorts entity IDs by their projection onto a line's direction or perpendicular axis.
*
* @param {number[]} entities - Array of entity IDs.
* @param {Vector2D} lineStart - Start point of the reference line.
* @param {Vector2D} lineEnd - End point of the reference line.
* @param {boolean} sortByDirection - If true, sort by parallel projection (along line);
* If false, sort by perpendicular distance (default).
* @returns {number[]} Sorted entity IDs.
*/
function sortEntitiesAlongLine(entities, lineStart, lineEnd, sortByDirection = false)
{
const dir = lineEnd.sub(lineStart);
const length = dir.length();
if (length === 0)
return entities.slice();
// Choose basis vector: either parallel or perpendicular to the line
let basis;
if (sortByDirection)
basis = dir.mult(1 / length); // Unit direction vector
else
basis = new Vector2D(-dir.y, dir.x).normalize(); // Perpendicular
const withCoord = entities.map(id =>
{
const pos = GetEntityState(id).position;
const point2D = Vector2D.from3D(pos);
const rel = point2D.sub(lineStart);
const coord = rel.dot(basis); // Project onto basis vector
return { id, coord };
});
// Sort by that coordinate
withCoord.sort((a, b) => a.coord - b.coord);
return withCoord.map(item => item.id);
}

View file

@ -18,12 +18,12 @@ QuitConfirmation.prototype.Buttons =
{
// Translation: Shown in the Dialog that shows up when the game finishes
"caption": translate("Quit and View Summary"),
"onPress": () => endGame(true)
"onPress": function() { this.closeSession(true); }
},
{
// Translation: Shown in the Dialog that shows up when the game finishes
"caption": translate("Quit"),
"onPress": () => endGame(false)
"onPress": function() { this.closeSession(false); }
}
];

View file

@ -3,7 +3,7 @@
*/
class QuitConfirmationDefeat extends QuitConfirmation
{
constructor(closePageCallback)
constructor(closeSession)
{
super();
@ -11,10 +11,10 @@ class QuitConfirmationDefeat extends QuitConfirmation
return;
this.confirmHandler = undefined;
registerPlayersFinishedHandler(this.onPlayersFinished.bind(this, closePageCallback));
registerPlayersFinishedHandler(this.onPlayersFinished.bind(this, closeSession));
}
onPlayersFinished(closePageCallback, players, won)
onPlayersFinished(closeSession, players, won)
{
if (players.indexOf(Engine.GetPlayerID()) == -1)
return;
@ -22,11 +22,11 @@ class QuitConfirmationDefeat extends QuitConfirmation
// Defer simulation result until
// 1. the loading screen finished for all networked clients (g_IsNetworkedActive)
// 2. all messages modifying g_Players victory state were processed (next turn)
this.confirmHandler = this.confirmExit.bind(this, won, closePageCallback);
this.confirmHandler = this.confirmExit.bind(this, won, closeSession);
registerSimulationUpdateHandler(this.confirmHandler);
}
confirmExit(won, closePageCallback)
confirmExit(won, closeSession)
{
if (g_IsNetworked && !g_IsNetworkedActive)
return;
@ -47,7 +47,7 @@ class QuitConfirmationDefeat extends QuitConfirmation
this.Buttons = askExit ? super.Buttons : undefined;
this.display(closePageCallback);
this.display(closeSession);
}
}

View file

@ -6,7 +6,7 @@ ReturnQuestion.prototype.Caption = translate("Do you want to resign or will you
ReturnQuestion.prototype.Buttons = [
{
"caption": translate("I will return"),
"onPress": () => endGame(false)
"onPress": function() { this.closeSession(false); }
},
{
"caption": translate("I resign"),
@ -74,9 +74,9 @@ QuitConfirmationMenu.prototype.MultiplayerClient.prototype.Buttons =
},
{
"caption": translate("Yes"),
"onPress": closePageCallback =>
"onPress": closeSession =>
{
(new ReturnQuestion()).display(closePageCallback);
(new ReturnQuestion()).display(closeSession);
}
}
];

View file

@ -3,10 +3,10 @@
*/
class QuitConfirmationReplay extends QuitConfirmation
{
constructor(closePageCallback)
constructor(closeSession)
{
super();
Engine.GetGUIObjectByName("session").onReplayFinished = this.display.bind(this, closePageCallback);
Engine.GetGUIObjectByName("session").onReplayFinished = this.display.bind(this, closeSession);
}
}
@ -26,11 +26,11 @@ QuitConfirmationReplay.prototype.Buttons =
{
// Translation: Shown in the Dialog that shows up when a replay finishes
"caption": translate("Quit and View Summary"),
"onPress": () => endGame(true)
"onPress": function() { this.closeSession(true); }
},
{
// Translation: Shown in the Dialog that shows up when a replay finishes
"caption": translate("Quit"),
"onPress": () => endGame(false)
"onPress": function() { this.closeSession(false); }
}
];

View file

@ -1,13 +1,3 @@
/**
* All tutorial messages received so far.
*/
var g_TutorialMessages = [];
/**
* GUI tags applied to the most recent tutorial message.
*/
var g_TutorialNewMessageTags = { "color": "255 226 149" };
/**
* The number of seconds we monitor to rate limit flares.
*/
@ -148,15 +138,14 @@ var g_NotificationsTypes =
},
"defeat": function(notification, player)
{
playersFinished(notification.allies, notification.message, false);
handlePlayersFinished(notification.allies, notification.message, false);
},
"won": function(notification, player)
{
playersFinished(notification.allies, notification.message, true);
handlePlayersFinished(notification.allies, notification.message, true);
},
"diplomacy": function(notification, player)
{
updatePlayerData();
g_DiplomacyColors.onDiplomacyChange();
addChatMessage({
@ -168,13 +157,12 @@ var g_NotificationsTypes =
},
"ceasefire-ended": function(notification, player)
{
updatePlayerData();
for (const handler of g_CeasefireEndedHandlers)
handler();
},
"tutorial": function(notification, player)
{
updateTutorial(notification);
g_TutorialManager.handleNotification(notification);
},
"tribute": function(notification, player)
{
@ -335,7 +323,7 @@ function findGuidForPlayerID(playerID)
/**
* Processes all pending notifications sent from the GUIInterface simulation component.
*/
function handleNotifications(closePageCallback)
function handleNotifications()
{
for (const notification of Engine.GuiInterfaceCall("GetNotifications"))
{
@ -346,7 +334,7 @@ function handleNotifications(closePageCallback)
}
for (const player of notification.players)
g_NotificationsTypes[notification.type](notification, player, closePageCallback);
g_NotificationsTypes[notification.type](notification, player);
}
}
@ -365,57 +353,6 @@ function focusAttack(attack)
}
}
function toggleTutorial()
{
const tutorialPanel = Engine.GetGUIObjectByName("tutorialPanel");
tutorialPanel.hidden = !tutorialPanel.hidden || !Engine.GetGUIObjectByName("tutorialText").caption;
}
/**
* Updates the tutorial panel when a new goal.
*/
function updateTutorial(notification, closePageCallback)
{
// Show the tutorial panel if not yet done
Engine.GetGUIObjectByName("tutorialPanel").hidden = false;
if (notification.warning)
{
Engine.GetGUIObjectByName("tutorialWarning").caption = coloredText(translate(notification.warning), "orange");
return;
}
const notificationText =
notification.instructions.reduce((instructions, item) =>
instructions + (typeof item === "string" ? translate(item) : colorizeHotkey(translate(item.text), item.hotkey)),
"");
Engine.GetGUIObjectByName("tutorialText").caption = g_TutorialMessages.concat(setStringTags(notificationText, g_TutorialNewMessageTags)).join("\n");
g_TutorialMessages.push(notificationText);
if (notification.readyButton)
{
Engine.GetGUIObjectByName("tutorialReady").hidden = false;
if (notification.leave)
{
Engine.GetGUIObjectByName("tutorialWarning").caption = translate("Click to quit this tutorial.");
Engine.GetGUIObjectByName("tutorialReady").caption = translate("Quit");
Engine.GetGUIObjectByName("tutorialReady").onPress = () =>
{
closePageCallback({ [Engine.openRequest]: endGame(true) });
};
}
else
Engine.GetGUIObjectByName("tutorialWarning").caption = translate("Click when ready.");
}
else
{
Engine.GetGUIObjectByName("tutorialWarning").caption = translate("Follow the instructions.");
Engine.GetGUIObjectByName("tutorialReady").hidden = true;
}
}
/**
* Process every CNetMessage (see NetMessage.h, NetMessages.h) sent by the CNetServer.
* Saves the received object to mainlog.html.
@ -476,6 +413,18 @@ function handlePlayerAssignmentsMessage(message)
updateGUIObjects();
}
function handlePlayersFinished(players, message, won)
{
addChatMessage({
"type": "playerstate",
"message": message,
"players": players
});
for (const handler of g_PlayerFinishedHandlers)
handler(players, won);
}
function onClientJoin(guid)
{
const playerID = g_PlayerAssignments[guid].player;

View file

@ -112,7 +112,7 @@
<!-- Status Effects icons -->
<object name="statusEffectsIcons" size="100%-20 4 100%-4 100%">
<repeat count="5">
<object type="image" size="0 0 16 16" z="200" tooltip_style="sessionToolTip"/>
<object type="image" size="0 0 16 16" z="100" tooltip_style="sessionToolTip"/>
</repeat>
</object>
</object>

View file

@ -35,6 +35,7 @@ var g_ResearchProgress;
var g_TimeNotificationOverlay;
var g_TopPanel;
var g_TradeDialog;
var g_TutorialManager;
/**
* Map, player and match settings set in game setup.
@ -284,8 +285,8 @@ async function init(initData, hotloadData)
g_DiplomacyColors = new DiplomacyColors();
g_PlayerViewControl = new PlayerViewControl();
g_PlayerViewControl.registerViewedPlayerChangeHandler(g_DiplomacyColors.updateDisplayedPlayerColors.bind(g_DiplomacyColors));
g_PlayerViewControl.registerViewedPlayerChangeHandler(resetTemplates);
g_DiplomacyColors.registerDiplomacyColorsChangeHandler(g_PlayerViewControl.rebuild.bind(g_PlayerViewControl));
g_DiplomacyColors.registerDiplomacyColorsChangeHandler(updateGUIObjects);
g_PauseControl = new PauseControl();
g_PlayerViewControl.registerPreViewedPlayerChangeHandler(removeStatusBarDisplay);
g_Ambient = new Ambient();
@ -337,26 +338,29 @@ async function init(initData, hotloadData)
{
if (g_IsNetworked)
handleNetMessages().catch(reject);
}), new Promise(closePageCallback =>
}), new Promise(closeSession =>
{
g_PlayerViewControl.registerViewedPlayerChangeHandler(resetTemplates.bind(undefined,
closePageCallback));
g_Menu = new Menu(g_PauseControl, g_PlayerViewControl, g_Chat, closePageCallback);
g_NetworkStatusOverlay = new NetworkStatusOverlay(closePageCallback);
g_QuitConfirmationDefeat = new QuitConfirmationDefeat(closePageCallback);
g_QuitConfirmationReplay = new QuitConfirmationReplay(closePageCallback);
// TODO: use event instead
onSimulationUpdate(closePageCallback);
Engine.GetGUIObjectByName("session").onSimulationUpdate =
onSimulationUpdate.bind(undefined, closePageCallback);
g_Menu = new Menu(g_PauseControl, g_PlayerViewControl, g_Chat, closeSession);
g_NetworkStatusOverlay = new NetworkStatusOverlay(closeSession);
g_QuitConfirmationDefeat = new QuitConfirmationDefeat(closeSession);
g_QuitConfirmationReplay = new QuitConfirmationReplay(closeSession);
g_TutorialManager = new TutorialManager(closeSession, hotloadData?.tutorial);
})]);
// TODO: use event instead
onSimulationUpdate();
Engine.GetGUIObjectByName("session").onSimulationUpdate = onSimulationUpdate;
for (const handler of g_PlayersInitHandlers)
handler();
for (const handler of g_HotkeyChangeHandlers)
handler();
registerPlayersFinishedHandler(updatePlayerData);
g_DiplomacyColors.registerDiplomacyColorsChangeHandler(updatePlayerData);
registerCeasefireEndedHandler(updatePlayerData);
if (hotloadData)
{
g_Selection.selected = hotloadData.selection;
@ -366,7 +370,19 @@ async function init(initData, hotloadData)
setTimeout(displayGamestateNotifications, 1000);
return promise;
const showSummary = await promise;
// Depends on the simulation, so it has to be called before EndGame.
const openRequest = getNextPageOpenRequest(showSummary);
Engine.EndGame();
// After the replay file was closed in EndGame
if (!g_IsReplay)
Engine.AddReplayToCache(Engine.GetCurrentReplayDirectory());
if (g_IsController && Engine.HasXmppClient())
Engine.SendUnregisterGame();
return { [Engine.openRequest]: openRequest };
}
function registerPlayersInitHandler(handler)
@ -470,16 +486,23 @@ function initializeMusic()
if (g_ViewedPlayer != -1 && g_CivData[g_Players[g_ViewedPlayer].civ].Music)
global.music.storeTracks(g_CivData[g_Players[g_ViewedPlayer].civ].Music);
global.music.setState(global.music.states.PEACE);
registerPlayersFinishedHandler((players, won) =>
{
if (players.includes(Engine.GetPlayerID()) && !Engine.IsAtlasRunning())
global.music.setState(won ? global.music.states.VICTORY : global.music.states.DEFEAT);
});
}
function resetTemplates(closePageCallback)
function resetTemplates()
{
// Update GUI and clear player-dependent cache
g_TemplateData = {};
Engine.GuiInterfaceCall("ResetTemplateModified");
// TODO: do this more selectively
onSimulationUpdate(closePageCallback);
onSimulationUpdate();
}
/**
@ -505,37 +528,6 @@ function controlsPlayer(playerID)
playerStates[playerID].state != "defeated";
}
/**
* Called when one or more players have won or were defeated.
*
* @param {array} - IDs of the players who have won or were defeated.
* @param {Object} - a plural string stating the victory reason.
* @param {boolean} - whether these players have won or lost.
*/
function playersFinished(players, victoryString, won)
{
addChatMessage({
"type": "playerstate",
"message": victoryString,
"players": players
});
updatePlayerData();
// TODO: The other calls in this function should move too
for (const handler of g_PlayerFinishedHandlers)
handler(players, won);
if (players.indexOf(Engine.GetPlayerID()) == -1 || Engine.IsAtlasRunning())
return;
global.music.setState(
won ?
global.music.states.VICTORY :
global.music.states.DEFEAT
);
}
function resumeGame()
{
g_PauseControl.implicitResume();
@ -550,31 +542,20 @@ function closeOpenDialogs()
g_TradeDialog.close();
}
function endGame(showSummary)
/**
* Put together an open request for the next GUI page to show after closing the session.
* This could be, depending on the situation, the the summary screen, the main menu, the lobby, etc.
*/
function getNextPageOpenRequest(showSummary)
{
// Before ending the game
const replayDirectory = Engine.GetCurrentReplayDirectory();
const simData = Engine.GuiInterfaceCall("GetReplayMetadata");
const playerID = Engine.GetPlayerID();
Engine.EndGame();
// After the replay file was closed in EndGame
// Done here to keep EndGame small
if (!g_IsReplay)
Engine.AddReplayToCache(replayDirectory);
if (g_IsController && Engine.HasXmppClient())
Engine.SendUnregisterGame();
const summaryData = {
"sim": simData,
"sim": Engine.GuiInterfaceCall("GetReplayMetadata"),
"gui": {
"dialog": false,
"assignedPlayer": playerID,
"assignedPlayer": Engine.GetPlayerID(),
"disconnected": g_Disconnected,
"isReplay": g_IsReplay,
"replayDirectory": !g_HasRejoined && replayDirectory,
"replayDirectory": !g_HasRejoined && Engine.GetCurrentReplayDirectory(),
"replaySelectionData": g_ReplaySelectionData
}
};
@ -583,9 +564,8 @@ function endGame(showSummary)
{
const menu = g_CampaignSession.getMenu();
if (g_InitAttributes.campaignData.skipSummary)
{
return { "page": menu };
}
summaryData.campaignData = { "filename": g_InitAttributes.campaignData.run };
summaryData.nextPage = menu;
}
@ -609,6 +589,7 @@ function getHotloadData()
"selection": g_Selection.selected,
"playerAssignments": g_PlayerAssignments,
"player": g_Players,
"tutorial": g_TutorialManager.getHotloadData()
};
}
@ -677,7 +658,7 @@ function onTick()
Engine.GuiInterfaceCall("ClearRenamedEntities");
}
function onSimulationUpdate(closePageCallback)
function onSimulationUpdate()
{
// Templates change depending on technologies and auras, so they have to be reloaded after such a change.
// g_TechnologyData data never changes, so it shouldn't be deleted.
@ -705,7 +686,7 @@ function onSimulationUpdate(closePageCallback)
handler();
// TODO: Move to handlers
handleNotifications(closePageCallback);
handleNotifications();
updateGUIObjects();
}

View file

@ -20,6 +20,8 @@
<script directory="gui/session/top_panel/"/>
<script directory="gui/session/top_panel/IconButtons/"/>
<script directory="gui/session/trade/"/>
<script directory="gui/session/tutorial/"/>
<script directory="gui/session/tutorial/panels/"/>
<object name="session">
@ -52,7 +54,7 @@
<include file="gui/session/TimeNotificationOverlay.xml"/>
<include file="gui/session/TopPanel.xml"/>
<include file="gui/session/trade/TradeDialog.xml"/>
<include file="gui/session/tutorial_panel.xml"/>
<include file="gui/session/tutorial/Tutorial.xml"/>
<include file="gui/session/Menu.xml"/>
<!-- Contains miscellanious objects s.a.: the technology research -->

View file

@ -532,6 +532,49 @@
<image backcolor="red" size="100%-1 0 100% 100%"/>
</sprite>
<!-- ================================ ================================ -->
<!-- Tutorial -->
<!-- ================================ ================================ -->
<sprite name="GuiExplanationPanel">
<image texture="session/hud_panels.png"/>
<!-- top edge -->
<image backcolor="41 32 11" size="0 0 100% 4"/>
<image backcolor="164 133 57" size="2 2 100%-2 3"/>
<image backcolor="221 180 87" size="1 1 100%-1 2"/>
<!-- bottom edge -->
<image backcolor="41 32 11" size="0 100%-4 100% 100%"/>
<image backcolor="164 133 57" size="2 100%-3 100%-2 100%-2"/>
<image backcolor="221 180 87" size="1 100%-2 100%-1 100%-1"/>
<!-- left edge -->
<image backcolor="41 32 11" size="0 4 4 100%-4"/>
<image backcolor="164 133 57" size="2 3 3 100%-3"/>
<image backcolor="221 180 87" size="1 2 2 100%-2"/>
<!-- right edge -->
<image backcolor="41 32 11" size="100%-4 4 100% 100%-4"/>
<image backcolor="164 133 57" size="100%-3 3 100%-2 100%-3"/>
<image backcolor="221 180 87" size="100%-2 2 100%-1 100%-2"/>
</sprite>
<sprite name="GoldenArrowDown">
<image texture="session/golden-arrow_down.png"/>
</sprite>
<sprite name="GoldenArrowUp">
<image texture="session/golden-arrow_down.png" texture_size="0 100% 100% 0"/>
</sprite>
<sprite name="GoldenArrowLeft">
<image texture="session/golden-arrow_left.png"/>
</sprite>
<sprite name="GoldernArrowRight">
<image texture="session/golden-arrow_left.png" texture_size="100% 0 0 100%"/>
</sprite>
<!-- ================================ ================================ -->
<!-- Misc -->
<!-- ================================ ================================ -->

View file

@ -222,6 +222,57 @@
text_valign="center"
/>
<style name="TutorialInstructionText"
buffer_zone="18"
font="sans-bold-14"
scrollbar="true"
scrollbar_style="ModernScrollBar"
textcolor="white"
text_align="center"
text_valign="center"
sprite="ModernDarkBox"
sprite_overlay="ModernDarkBoxGoldBorder"
/>
<style name="TutorialInfoText"
buffer_zone="16"
font="sans-16"
sprite="ModernDarkBoxGold"
scrollbar="true"
scrollbar_style="ModernScrollBar"
scroll_bottom="true"
textcolor="white"
text_align="left"
text_valign="top"
/>
<style name="TutorialInfoTitle"
buffer_zone="10"
font="sans-bold-18"
textcolor="white"
text_align="center"
text_valign="center"
/>
<style name="TutorialExplanationTitle"
buffer_zone="10"
font="sans-bold-18"
textcolor="gold"
text_align="center"
text_valign="top"
/>
<style name="TutorialExplanationText"
buffer_zone="10"
font="sans-16"
sprite="ModernFade"
textcolor="white"
scrollbar="true"
scrollbar_style="ModernScrollBar"
text_align="center"
text_valign="top"
/>
<!-- ================================ ================================ -->
<!-- Icon Styles -->
<!-- ================================ ================================ -->

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="tutorialPanels">
<include directory="gui/session/tutorial/panels/"/>
</object>

View file

@ -0,0 +1,158 @@
// Needs to be kept in sync with the one in maps/scripts/Tutorial.js
const TUTORIAL_STEP_TYPE = deepfreeze({
"INSTRUCTION": 1,
"INFO": 2,
"GUI_EXPLANATION": 3
});
/**
* This class manages the tutorial UI, which consists of various panels, which correspond to the different step types.
* It processes all tutorial notifications received from the simulation, translates them and passes them along to
* according panel.
*/
class TutorialManager
{
parentObj = Engine.GetGUIObjectByName("tutorialPanels");
panels = new Map([
[TUTORIAL_STEP_TYPE.INSTRUCTION, new InstructionPanel(this.continue.bind(this))],
[TUTORIAL_STEP_TYPE.INFO, new InfoPanel(this.continue.bind(this))],
[TUTORIAL_STEP_TYPE.GUI_EXPLANATION, new GuiExplanationPanel(this.continue.bind(this))]
]);
displayedSteps = []; // All steps that have already been displayed, in the form of [stepType, panelData]
pendingSteps = []; // All received steps that have yet to be displayed, in the form of [stepType, panelData]
activePanel;
currentWarning = "";
isFinished = false;
closeSession;
constructor(closeSession, hotloadData)
{
this.closeSession = closeSession;
this.parentObj.hidden = true;
if (hotloadData)
{
for (const [stepType, panelData] of hotloadData.displayedSteps)
this.displayStep(stepType, panelData);
this.pendingSteps = hotloadData.pendingSteps;
if (hotloadData.warning)
this.displayWarning(hotloadData.warning);
}
}
toggleVisibility()
{
this.parentObj.hidden = !this.displayedSteps.length || !this.parentObj.hidden;
}
handleNotification(notification)
{
if (notification.step)
{
// The step might be defined in a way that requires us to split it up into several
// before displaying them.
while (this.pendingSteps.length)
this.displayNextPendingStep();
// To minimise duplication in the trigger script, consecutive steps that only
// differ in the 'text' property can be combined into a single one with an array
// 'texts'. Here we need revert this again by splitting it back into different
// steps before passing them to the panels.
if (Array.isArray(notification.step.panelData.texts))
{
const { texts, ...sharedData } = notification.step.panelData;
this.pendingSteps = texts.map((text, i) => ([
notification.step.type, {
...sharedData, text,
// Note: These could be ignored by some panels.
"appendable": sharedData.appendable || i === 0,
"appendToPrevious": sharedData.appendToPrevious || i > 0
}
]));
this.displayNextPendingStep();
}
else
this.displayStep(notification.step.type, notification.step.panelData);
}
if (notification.warning)
this.displayWarning(notification.warning);
}
displayWarning(warning)
{
this.currentWarning = warning;
this.activePanel.displayWarning(translate(warning));
}
displayNextPendingStep()
{
this.displayStep(...this.pendingSteps.shift());
}
displayStep(stepType, panelData)
{
if (this.isFinished)
return;
this.parentObj.hidden = false;
this.isFinished = panelData.isLast;
translateObjectKeys(panelData, ["text", "title"]);
if (panelData.hotkeys)
{
let i = 0;
panelData.text = panelData.hotkeys.length == 1 ?
colorizeHotkey(panelData.text, panelData.hotkeys[0]) :
sprintf(panelData.text, panelData.hotkeys.reduce((obj, hotkey) =>
{
obj["hotkey" + ++i] = colorizeHotkey("%(hotkey)s", hotkey);
return obj;
}, {}));
}
if (!this.panels.has(stepType))
throw new Error("Failed to display tutorial step: Unkown step type: " + stepType);
// Explicitly don't hide the previously active panel if the new step is a GUI explanation. It's displayed
// "on top" of everything else.
if (stepType == TUTORIAL_STEP_TYPE.GUI_EXPLANATION)
{
this.panels.get(TUTORIAL_STEP_TYPE.GUI_EXPLANATION).setVisible(true);
this.activePanel.setEnabled(false);
}
else
this.panels.forEach((panel, type) => panel.setVisible(type == stepType));
this.activePanel = this.panels.get(stepType);
this.activePanel.setEnabled(true);
this.activePanel.displayStep(panelData);
this.displayedSteps.push([stepType, panelData]);
this.currentWarning = "";
}
continue()
{
if (this.isFinished)
this.closeSession(true);
else if (this.pendingSteps.length)
this.displayNextPendingStep();
else
Engine.PostNetworkCommand({ "type": "dialog-answer", "tutorial": "continue" });
}
getHotloadData()
{
return {
"displayedSteps": this.displayedSteps,
"pendingSteps": this.pendingSteps,
"warning": this.currentWarning
};
}
}

View file

@ -0,0 +1,73 @@
/**
* Generic superclass for tutorial panels that manages:
* - a text object showing a step's main content
* - a hint object giving hints or showing warnings
* - a button to continue the tutorial or quit when it's over
*/
class TutorialPanel
{
panel;
text;
hint;
button;
constructor(name, continueCallback)
{
this.panel = Engine.GetGUIObjectByName(name);
this.text = Engine.GetGUIObjectByName(name + "Text");
this.hint = Engine.GetGUIObjectByName(name + "Hint");
this.button = Engine.GetGUIObjectByName(name + "Button");
this.button.caption = this.ButtonCaptions.Continue;
this.button.onPress = continueCallback;
}
setVisible(visible)
{
this.panel.hidden = !visible;
}
setEnabled(enabled)
{
this.button.enabled = enabled;
}
displayWarning(warning)
{
this.hint.caption = coloredText(warning, this.WarningColor);
}
displayStep(step)
{
this.text.caption = step.text;
this.button.hidden = !step.showContinueButton;
if (step.isLast)
{
this.hint.caption = this.HintCaptions.Quit;
this.button.caption = this.ButtonCaptions.Quit;
return;
}
this.button.caption = step.continueButton || this.ButtonCaptions.Continue;
this.hint.caption = step.isDone ?
this.HintCaptions.Done :
step.hint || (step.showContinueButton ?
this.HintCaptions.Continue :
this.HintCaptions.Instruction);
}
}
TutorialPanel.prototype.WarningColor = "orange";
TutorialPanel.prototype.ButtonCaptions = {
"Quit": translateWithContext("button caption", "Quit"),
"Continue": translateWithContext("button caption", "Continue")
};
TutorialPanel.prototype.HintCaptions = {
"Continue": translate("Click to continue."),
"Instruction": translate("Follow the instructions."),
"Done": translate("You have already done this."),
"Quit": translate("Click to quit this tutorial.")
};

View file

@ -0,0 +1,249 @@
/**
* This class manages a tutorial panel meant to explain GUI elements (what they show or do) to the player, like
* "The resource counters at the top show how many resources you have ...".
* The explanations consist of a title and text. It also highlights the target GUI objects by fading out everything else
* with black and uses an arrow to point to them directly.
* If the target object is hidden the panel is still shown (pointing to nothing), but a warning displayed.
*/
class GuiExplanationPanel extends TutorialPanel
{
panel = Engine.GetGUIObjectByName("guiExplanationPanel");
backgroundFade = Engine.GetGUIObjectByName("guiExplanationPanelFade");
arrow = Engine.GetGUIObjectByName("guiExplanationPanelArrow");
currentStep;
targetObject;
wasTargetHidden = false;
constructor(continueCallback)
{
super("guiExplanationPanel", () =>
{
this.resetTargetObjectZ();
this.targetObject = null;
continueCallback();
});
// Place the panel in front of all other session objects.
this.panel.z = this.OverlayZValue + 10;
// Place the black overlay behind the panel.
this.backgroundFade.z = this.OverlayZValue - 10;
// We need to set the arrow's Z value explicitly since it has 'absolute' set to true, which makes it not inherit
// the parent's value by default.
this.arrow.z = this.OverlayZValue;
// Initialize the size to a default, with all relative values set to 0.
// This is necessary for the custom sizing later.
this.panel.size = { "right": this.PanelWidth, "bottom": this.MaxPanelHeight };
this.arrow.size = { "right": this.ArrowScale * 2, "bottom": this.ArrowScale };
this.panel.onWindowResized = this.updatePanelSize.bind(this);
this.panel.onTick = this.updateContent.bind(this);
}
setVisible(visible)
{
super.setVisible(visible);
if (!visible)
this.resetTargetObjectZ();
}
resetTargetObjectZ()
{
if (this.targetObject)
this.targetObject.z = this.targetOriginalZ;
}
displayStep(step)
{
super.displayStep(step);
this.resetTargetObjectZ();
if (!["left", "top", "right", "bottom"].includes(step.side))
throw new Error("GuiExplanationPanel: Invalid or no side specified:" + step.side + ". It must be 'left', 'top', 'right', or 'bottom'.");
this.targetObject = Engine.TryGetGUIObjectByName(step.targetObject);
if (!this.targetObject)
throw new Error("GuiExplanationPanel: Non-existing target GUI object specified: '" + step.targetObject + "'.");
this.currentStep = step;
// This results in the target's grandchildren taking higher Z values than this panel (since they get their parent's value + 10).
// But that's ok, since they should never overlap anyway.
this.targetOriginalZ = this.targetObject.z;
this.targetObject.z = this.OverlayZValue;
this.arrow.sprite = this.ArrowSprites[step.side];
this.wasTargetHidden = undefined;
this.updateContent();
}
/**
* Check the visibility of the target object and update the text and background fade accordingly, if necessary.
*/
updateContent()
{
if (!this.targetObject)
return;
const displayTextAndTitle = (title, text) =>
{
this.text.caption = (title ? setStringTags(title, this.TitleTags) + setStringTags("\n\n", { "font": "sans-3" }) : "") + text;
this.updatePanelSize();
};
if (this.targetObject.hidden && !this.wasTargetHidden)
{
this.backgroundFade.hidden = true;
displayTextAndTitle(this.currentStep.title, this.HiddenWarning);
this.wasTargetHidden = true;
}
else if (!this.targetObject.hidden && (this.wasTargetHidden == undefined || this.wasTargetHidden))
{
this.backgroundFade.hidden = false;
displayTextAndTitle(this.currentStep.title, this.currentStep.text);
this.wasTargetHidden = false;
}
}
/**
* Place the panel next to the target object on the desired side relative to it.
*/
updatePanelSize()
{
if (!this.targetObject)
return;
const panelHeight = Math.min(this.MaxPanelHeight, this.text.size.top + this.text.getTextSize().height - this.text.size.bottom);
const targetComputedSize = this.targetObject.getComputedSize();
const targetHorizontalCenter = (targetComputedSize.left + targetComputedSize.right) / 2;
const targetVerticalCenter = (targetComputedSize.top + targetComputedSize.bottom) / 2;
// Don't perfectly center the panel on the target object, instead shift it to one side a bit.
// That looks better.
const panelPlacementRatio = 0.3;
if (this.currentStep.side == "top" || this.currentStep.side == "bottom")
{
this.panel.size.left = targetHorizontalCenter - panelPlacementRatio * this.PanelWidth;
this.panel.size.right = targetHorizontalCenter + (1 - panelPlacementRatio) * this.PanelWidth;
this.arrow.size.left = targetHorizontalCenter - this.ArrowScale;
this.arrow.size.right = targetHorizontalCenter + this.ArrowScale;
if (this.currentStep.side == "top")
{
this.panel.size.bottom = targetComputedSize.top - this.PanelDistanceFromTarget;
this.panel.size.top = this.panel.size.bottom - panelHeight;
this.arrow.size.bottom = targetComputedSize.top - this.ArrowDistanceFromTarget;
this.arrow.size.top = this.arrow.size.bottom - this.ArrowScale;
}
else
{
this.panel.size.top = targetComputedSize.bottom + this.PanelDistanceFromTarget;
this.panel.size.bottom = this.panel.size.top + panelHeight;
this.arrow.size.top = targetComputedSize.bottom + this.ArrowDistanceFromTarget;
this.arrow.size.bottom = this.arrow.size.top + this.ArrowScale;
}
}
else
{
this.panel.size.top = targetVerticalCenter - panelPlacementRatio * panelHeight;
this.panel.size.bottom = targetVerticalCenter + (1 - panelPlacementRatio) * panelHeight;
this.arrow.size.top = targetVerticalCenter - this.ArrowScale;
this.arrow.size.bottom = targetVerticalCenter + this.ArrowScale;
if (this.currentStep.side == "left")
{
this.panel.size.right = targetComputedSize.left - this.PanelDistanceFromTarget;
this.panel.size.left = this.panel.size.right - this.PanelWidth;
this.arrow.size.right = targetComputedSize.left - this.ArrowDistanceFromTarget;
this.arrow.size.left = this.arrow.size.right - this.ArrowScale;
}
else
{
this.panel.size.left = targetComputedSize.right + this.PanelDistanceFromTarget;
this.panel.size.right = this.panel.size.left + this.PanelWidth;
this.arrow.size.left = targetComputedSize.right + this.ArrowDistanceFromTarget;
this.arrow.size.right = this.arrow.size.left + this.ArrowScale;
}
}
let root = this.panel;
while (root.parent) root = root.parent;
const screenSize = root.getComputedSize();
// Perform some clamping to ensure the panel is always fully visible on the screen.
if (this.panel.size.left < 0)
{
this.panel.size.right -= this.panel.size.left;
this.panel.size.left = 0;
}
else if (this.panel.size.right > screenSize.right)
{
this.panel.size.left -= this.panel.size.right - screenSize.right;
this.panel.size.right = screenSize.right;
}
if (this.panel.size.top < 0)
{
this.panel.size.bottom -= this.panel.size.top;
this.panel.size.top = 0;
}
else if (this.panel.size.bottom > screenSize.bottom)
{
this.panel.size.top -= this.panel.size.bottom - screenSize.bottom;
this.panel.size.bottom = screenSize.bottom;
}
}
}
/**
* Warning shown whilever the target objects is hidden.
*/
GuiExplanationPanel.prototype.HiddenWarning = coloredText(
translate("It is currently hidden. Follow the previous instructions for it to be shown again."),
"orange"
);
/**
* GUI tags applied to the shown title.
*/
GuiExplanationPanel.prototype.TitleTags = { "color": "gold", "font": "sans-bold-18" };
/**
* Which sprite to use depending on the side that the description panel is placed on. The sprites only differ in rotation.
* E.g. if the panel is placed to the left of the target object, the arrow has to point to the right.
*/
GuiExplanationPanel.prototype.ArrowSprites = {
"left": "GoldenArrowRight",
"top": "GoldenArrowDown",
"right": "GoldenArrowLeft",
"bottom": "GoldenArrowUp"
};
GuiExplanationPanel.prototype.PanelWidth = 600;
GuiExplanationPanel.prototype.MaxPanelHeight = 200;
/**
* Margin between the target object and the panel itself. The arrow has to fit inside that gap.
*/
GuiExplanationPanel.prototype.PanelDistanceFromTarget = 40;
/**
* Margin between the target object and the arrow.
*/
GuiExplanationPanel.prototype.ArrowDistanceFromTarget = 5;
/**
* Greater than the z value of all other session GUI objects (except the watermark).
*/
GuiExplanationPanel.prototype.OverlayZValue = 250;
/**
* The arrow is always twice as wide as it is long.
* Therefore, for vertical arrows (pointing up or down) this defines the height and half the width,
* while for horizontal arrows (pointing left or right) this defines the width and half the height.
*/
GuiExplanationPanel.prototype.ArrowScale = 32;

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="guiExplanationPanel" type="image" sprite="GuiExplanationPanel">
<object name="guiExplanationPanelFade" type="image" sprite="color:0 0 0 125" ghost="true" absolute="true"/>
<object name="guiExplanationPanelText" type="text" size="18 18 100%-18 100%-54" style="TutorialExplanationText"/>
<object name="guiExplanationPanelButton" type="button" style="ModernButtonRed" size="100%-170 100%-42 100%-30 100%-14"/>
<object name="guiExplanationPanelHint" type="text" style="ModernLeftLabelText" size="25 100%-48 100%-190 100%-8"/>
<object name="guiExplanationPanelArrow" type="image" absolute="true"/>
</object>

View file

@ -0,0 +1,66 @@
/**
* This class manages a tutorial panel meant to explain game concepts or mechanics to the player, like selecting units
* or bartering, for example.
* These explanations consist of a title and paragraphs of texts. New steps to display don't have to replace the
* preceding ones; with the 'appendable' flag they can add a paragraph to the bottom of an existing explanation instead
* making a brand new one.
*/
class InfoPanel extends TutorialPanel
{
title = Engine.GetGUIObjectByName("infoPanelTitle");
paragraphs = [];
appendable = true;
constructor(continueCallback)
{
super("infoPanel", continueCallback);
}
displayStep(step)
{
super.displayStep(step);
if (step.appendToPrevious && !this.appendable)
{
error("InfoPanel: Can't append step to previous because previous is marked unappendable.");
return;
}
if (!step.appendToPrevious)
{
this.paragraphs = [];
if (step.title)
this.title.caption = step.title;
else
this.title.hidden = true;
}
// Appendable being false means that no paragraph will be appended to this (first one) in the future.
// In that case, we want to portray it as a single piece of info rather than a list of them.
this.appendable = step.appendToPrevious || step.appendable;
if (this.appendable)
{
this.text.text_align = "left";
const newParagraph = sprintf(this.BulletPointFormat, { "text": step.text });
this.text.caption = this.paragraphs.length ?
this.paragraphs.concat(coloredText(newParagraph, this.NewParagraphColor)).join("\n") :
newParagraph;
this.paragraphs.push(newParagraph);
}
else
{
this.text.text_align = "center";
this.text.caption = step.text;
}
this.text.size.top = this.title.hidden || !this.title.caption ? 27 : 54;
const panelHeight = Math.min(this.MaxPanelHeight, this.text.size.top + this.text.getTextSize().height - this.text.size.bottom);
this.panel.size.bottom = this.panel.getComputedSize().top + panelHeight;
}
}
InfoPanel.prototype.BulletPointFormat = translateWithContext("bullet point format", " • %(text)s");
InfoPanel.prototype.NewParagraphColor = "gold";
InfoPanel.prototype.MaxPanelHeight = 235;

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="infoPanel" type="image" sprite="ModernDialog" size="50%-365 90 50%+365 345">
<object type="text" size="50%-96 -16 50%+96 10" style="TitleText">
<translatableAttribute id="caption">Information</translatableAttribute>
</object>
<object name="infoPanelTitle" type="text" size="25 17 100%-25 52" style="TutorialInfoTitle"/>
<object name="infoPanelText" type="text" size="25 52 100%-25 100%-64" style="TutorialInfoText"/>
<object name="infoPanelHint" type="text" style="ModernLeftLabelText" size="30 100%-54 100%-200 100%-14"/>
<object name="infoPanelButton" type="button" size="100%-180 100%-48 100%-30 100%-20" style="ModernButtonRed"/>
</object>

View file

@ -0,0 +1,23 @@
/**
* This class manages a tutorial panel meant to display basic instructions of simple tasks for the player to fulfill,
* consisting of just a text, like "Order one of your units to build a house."
*/
class InstructionPanel extends TutorialPanel
{
instructions = [];
constructor(continueCallback)
{
super("instructionPanel", continueCallback);
}
displayStep(step)
{
super.displayStep(step);
const panelHeight = Math.min(this.MaxPanelHeight, this.text.size.top + this.text.getTextSize().height - this.text.size.bottom);
this.panel.size.bottom = this.panel.getComputedSize().top + panelHeight;
}
}
InstructionPanel.prototype.MaxPanelHeight = 185;

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="instructionPanel" type="image" sprite="color:0 0 0 125" size="50%-310 80 50%+310 210">
<object name="instructionPanelText" type="text" style="TutorialInstructionText" size="10 10 100%-10 100%-52"/>
<object name="instructionPanelHint" type="text" style="ModernLeftLabelText" size="25 100%-46 100%-190 100%-6"/>
<object name="instructionPanelButton" type="button" style="ModernButtonRed" size="100%-170 100%-40 100%-30 100%-12"/>
</object>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<object name="tutorialPanel" type="image" sprite="ModernFade" size="50%-380 38 50%+380 292" hidden="true">
<object size="10 10 100%-20 100%-48">
<object name="tutorialText" type="text" style="TutorialPanel"/>
</object>
<object name="tutorialWarning" type="text" style="ModernLeftLabelText" size="10 100%-42 100%-20 100%-12"/>
<object name="tutorialReady" type="button" style="ModernButtonRed" size="100%-160 100%-42 100%-20 100%-12">
<translatableAttribute id="caption">Ready</translatableAttribute>
<action on="Press">Engine.PostNetworkCommand({"type": "dialog-answer", "tutorial": "ready"});</action>
</object>
</object>

View file

@ -208,3 +208,13 @@
“This is a woman's resolve; as for men, they may live and be slaves.” \n— Boudicca, rallying against Roman occupation (Tacitus's Annals, Book 14, Chapter 35)
“There are two things, knowledge and opinion; one makes its possessor know, the other to be ignorant.” \n— (Hippocratic treatise known as “The Law”, part of the Hippocratic Corpus)
“Let them hate me, so long as they fear me.” \n— Caligula (Suetonius, The Twelve Caesars, Book30)
“There is no instance of a nation benefiting from prolonged warfare.” \n— Sun Tzu (“The Art of War”, Chapter 2)
“To subdue the enemy without fighting is the acme of skill.” \n— Sun Tzu (“The Art of War”, Chapter 3)
“One defends when his strength is inadequate; he attacks when it is abundant.” \n— Sun Tzu (“The Art of War”, Chapter 4)
“When torrential water tosses boulders, it is because of its momentum. When the strike of a hawk breaks the body of its prey, it is because of timing.” \n— Sun Tzu (“The Art of War”, Chapter 5)
“Treat your men as you would your own beloved sons. And they will follow you into the deepest valley.” \n— Sun Tzu (“The Art of War”, Chapter 10)
“Woe to the defeated!” \n— Brennus disputing the weight of gold ransom while sacking Rome c. 387 BC (Livy, “Ab Urbe Condita”, Book 5, Chapter 48)
“\[Brutus] found the women fighting and perishing in company with the men with such bravery that they uttered no cry even in the midst of slaughter.” \n— Brutus in Lusitania during the battles with Viriathus (Appian of Alexandria, “The Spanish Wars”, 15)
“What I desire for my own children, I desire for all men.” \n— Ashoka the Great (“The Kalinga Rock Edicts”)
“If you Romans choose to lord it over the world, does it follow that the world is to accept slavery ?” \n— Caratacus pleading for pardon from Roman Emperor Claudius in Rome (Tacitus, “Annals”, 12.37)
“And where am I to get so many soldiers ?” \n— Septimius Severus, frustrated with the challenge of controlling his own men and the harsh realities of frontier warfare against the northern tribes in Britannia. (Cassius Dio, “Roman History”, Vol IX, Book LXXVI, Section 11)

View file

@ -1,6 +1,6 @@
function RandomMapLogger()
{
this.lastTime = undefined;
this.taskStarted = false;
this.startTime = Engine.GetMicroseconds ? Engine.GetMicroseconds() : 0;
this.prefix = ""; // seems noisy
@ -21,27 +21,18 @@ RandomMapLogger.prototype.printDirectly = function(string)
RandomMapLogger.prototype.print = function(string)
{
this.printDuration();
this.printDirectly(this.prefix + string + "...");
this.lastTime = Engine.GetMicroseconds();
};
if (this.taskStarted)
Engine.ProfileStop();
RandomMapLogger.prototype.printDuration = function()
{
if (!this.lastTime)
return;
this.printDurationDirectly("", this.lastTime);
this.lastTime = Engine.GetMicroseconds();
const text = this.prefix + string;
this.printDirectly(text + "...\n");
Engine.ProfileStart(text);
this.taskStarted = true;
};
RandomMapLogger.prototype.close = function()
{
this.printDuration();
this.printDurationDirectly(this.prefix + "Total map generation time:", this.startTime);
};
RandomMapLogger.prototype.printDurationDirectly = function(text, startTime)
{
this.printDirectly(text + " " + ((Engine.GetMicroseconds() - startTime) / 1000000).toFixed(6) + "s.\n");
if (this.taskStarted)
Engine.ProfileStop();
this.printDirectly(this.prefix + "Map generation finished.\n");
};

View file

@ -370,8 +370,7 @@ Trigger.prototype.RemoveSpread = function()
Trigger.prototype.EndGame = function()
{
Engine.QueryInterface(3, IID_Player).SetState("defeated", "trigger");
Engine.QueryInterface(4, IID_Player).SetState("won", "trigger");
TriggerHelper.SetPlayerWon(3, "trigger", "trigger");
};
var cmpModifiersManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ModifiersManager);

View file

@ -433,8 +433,7 @@ Trigger.prototype.RunExperiment = function(data)
Trigger.prototype.EndGame = function()
{
Engine.QueryInterface(4, IID_Player).SetState("defeated", "trigger");
Engine.QueryInterface(3, IID_Player).SetState("won", "trigger");
TriggerHelper.SetPlayerWon(3, "trigger", "trigger");
};
/*

View file

@ -119,7 +119,7 @@ Trigger.prototype.StartCaptureTheRelicCountdown = function(winningPlayers)
for (let playerID = 1; playerID < TriggerHelper.GetNumberOfPlayers(); ++playerID)
{
const cmpPlayer = QueryPlayerIDInterface(playerID);
if (cmpPlayer.GetState() == "won")
if (cmpPlayer.HasWon())
return;
if (winningPlayers.indexOf(playerID) == -1)

View file

@ -23,7 +23,7 @@ Trigger.prototype.ConquestOwnershipChanged = function(msg)
{
const cmpPlayer = QueryPlayerIDInterface(msg.from);
if (cmpPlayer)
cmpPlayer.SetState("defeated", query.defeatReason);
cmpPlayer.Defeat(query.defeatReason);
}
}
};

View file

@ -315,7 +315,7 @@ TriggerHelper.DefeatPlayer = function(playerID, defeatReason)
{
const cmpPlayer = QueryPlayerIDInterface(playerID);
if (cmpPlayer)
cmpPlayer.SetState("defeated", defeatReason);
cmpPlayer.Defeat(defeatReason);
};
/**
@ -603,4 +603,147 @@ TriggerHelper.SpawnAndTurretAtClasses = function(playerID, classes, templates, c
return results;
};
/**
* Makes an entity permanently visible to all players by:
* - Setting alwaysVisible flag
* - Deactivating fogging (no mirages)
* - Exploring the surrounding area
* - Forcing a visibility update
*
* @param {number} ent - Entity ID to make permanently visible
*/
TriggerHelper.MakeEntityPermanentlyVisible = function(ent)
{
const cmpVisibility = Engine.QueryInterface(ent, IID_Visibility);
if (cmpVisibility)
{
cmpVisibility.alwaysVisible = true;
cmpVisibility.SetActivated(true);
}
const cmpFogging = Engine.QueryInterface(ent, IID_Fogging);
if (cmpFogging)
cmpFogging.PermanentlyReveal("all");
const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager);
const cmpPosition = Engine.QueryInterface(ent, IID_Position);
if (cmpRangeManager && cmpPosition && cmpPosition.IsInWorld())
{
const pos = cmpPosition.GetPosition2D();
const numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
const exploreRadius = TriggerHelper.CalculateExplorationRadius(ent);
for (let i = 1; i < numPlayers; ++i)
cmpRangeManager.ExploreCircle(i, pos.x, pos.y, exploreRadius);
cmpRangeManager.RequestVisibilityUpdate(ent);
}
};
/**
* Calculate a reasonable exploration radius from an entity's footprint.
* Calculates the radius as the entity's full extent to reveal the surrounding area.
* This is equivalent to (extent/2) * 2, giving a 2x margin around the entity.
*/
TriggerHelper.CalculateExplorationRadius = function(ent)
{
const cmpFootprint = Engine.QueryInterface(ent, IID_Footprint);
if (!cmpFootprint)
return 50;
const shapeInfo = cmpFootprint.GetShape();
let halfExtent; // The radius of the entity (half its size)
if (shapeInfo && shapeInfo.type === "circle")
halfExtent = shapeInfo.radius || shapeInfo.size0;
else if (shapeInfo && shapeInfo.type === "square")
halfExtent = Math.max(shapeInfo.width || shapeInfo.size0, shapeInfo.depth || shapeInfo.size1) / 2;
else
return 50;
// Use 2x the entity's radius to reveal surrounding area
// This gives a comfortable margin around the entity
const radius = halfExtent * 2;
return radius;
};
/**
* Sends a notification to specific players.
*
* @param {string} message - The message to display (use markForTranslation)
* @param {number[]} players - Array of player IDs to send the notification to
* @param {number} duration - Duration in milliseconds
* @param {Object} parameters - Translation parameters (optional)
*/
TriggerHelper.SendNotification = function(message, players, duration = 30000, parameters = {}, translateParameters = [])
{
const cmpGuiInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface);
if (!cmpGuiInterface)
return;
// Ensure all parameter values are primitive types
const primitiveParameters = {};
for (const key in parameters)
{
const value = parameters[key];
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
primitiveParameters[key] = value;
else
primitiveParameters[key] = String(value);
}
cmpGuiInterface.AddTimeNotification(
{
"message": message,
"players": players,
"parameters": primitiveParameters,
"translateMessage": true,
"translateParameters": translateParameters
},
duration
);
};
/**
* Sends a notification to a specific player and a different notification to all other active players.
*
* @param {number} player - The player ID who gets the 'owner' message
* @param {string} ownerMessage - Message for the specified player (use markForTranslation)
* @param {string} othersMessage - Message for all other active players (use markForTranslation)
* @param {number} duration - Duration in milliseconds
* @param {Object} parameters - Translation parameters shared by both messages (optional)
* @param {Object} ownerParameters - Additional parameters for the owner message (optional)
* @param {Object} othersParameters - Additional parameters for the others message (optional)
*/
TriggerHelper.SendDualNotification = function(player, ownerMessage, othersMessage, duration = 30000, parameters = {}, ownerParameters = {}, othersParameters = {})
{
const numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers();
const others = [-1]; // Include observers (player id -1)
for (let playerID = 1; playerID < numPlayers; ++playerID)
{
if (playerID == player)
continue;
const cmpPlayer = QueryPlayerIDInterface(playerID);
if (cmpPlayer && cmpPlayer.GetState() == "defeated")
continue;
others.push(playerID);
}
const mergedOwnerParams = Object.assign({}, parameters, ownerParameters);
const mergedOthersParams = Object.assign({}, parameters, othersParameters);
// Auto-detect translatable parameters (those starting with "_" or named "player")
const translateParameters = Object.keys(mergedOthersParams).filter(key => key.startsWith("_") || key == "player");
TriggerHelper.SendNotification(ownerMessage, [player], duration, mergedOwnerParams, translateParameters);
TriggerHelper.SendNotification(othersMessage, others, duration, mergedOthersParams, translateParameters);
};
TriggerHelper.SendWonderNotifications = function(owner, othersMessage, ownerMessage)
{
TriggerHelper.SendDualNotification(owner, ownerMessage, othersMessage, 30000, { "_player_": owner });
};
Engine.RegisterGlobal("TriggerHelper", TriggerHelper);

View file

@ -1,97 +1,101 @@
// Needs to be kept in sync with the one in gui/session/tutorial/Tutorial.js
var TUTORIAL_STEP_TYPE = deepfreeze({
"INSTRUCTION": 1,
"INFO": 2,
"GUI_EXPLANATION": 3
});
Engine.RegisterGlobal("TUTORIAL_STEP_TYPE", TUTORIAL_STEP_TYPE);
Trigger.prototype.InitTutorial = function(data)
{
this.index = 0;
this.fullText = "";
this.stepIndex = -1;
this.tutorialEvents = [];
// Register needed triggers
this.RegisterTrigger("OnDeserialized", "OnDeserializedTrigger", { "enabled": true });
this.RegisterTrigger("OnPlayerCommand", "OnPlayerCommandTrigger", { "enabled": false });
this.tutorialEvents.push("OnPlayerCommand");
this.RegisterTrigger("OnDeserialized", "DeserializedAction", { "enabled": true });
this.RegisterTrigger("OnPlayerCommand", "BasePlayerCommandAction", { "enabled": true });
for (const goal of this.tutorialGoals)
for (const step of this.tutorialSteps)
{
for (const key in goal)
for (const key in step)
{
if (typeof goal[key] !== "function" || this.tutorialEvents.indexOf(key) != -1)
if (typeof step[key] !== "function" || this.tutorialEvents.indexOf(key) != -1)
continue;
if (key == "Init")
continue;
if (key == "IsDone")
continue;
const action = key + "Trigger";
const action = key.substring(2) + "Action";
this.RegisterTrigger(key, action, { "enabled": false });
this.tutorialEvents.push(key);
}
}
this.NextGoal();
this.NextStep();
};
Trigger.prototype.NextGoal = function(deserializing = false)
Trigger.prototype.NextStep = function(deserializing = false)
{
if (this.index > this.tutorialGoals.length)
if (++this.stepIndex >= this.tutorialSteps.length)
{
this.CompleteTutorial();
return;
const goal = this.tutorialGoals[this.index];
let needDelay = true;
let readyButton = false;
}
const step = this.tutorialSteps[this.stepIndex];
Trigger.prototype.Init = goal.Init || null;
Trigger.prototype.Init = step.Init || null;
if (!deserializing && this.Init)
this.Init();
Trigger.prototype.IsDone = goal.IsDone || (() => false);
const goalAlreadyDone = this.IsDone();
for (const event of this.tutorialEvents)
{
const action = event + "Trigger";
if (goal[event])
const action = event.substring(2) + "Action";
if (step[event])
{
Trigger.prototype[action] = goal[event];
Trigger.prototype[action] =
event == "OnPlayerCommand" ?
(msg) =>
{
// Don't forward tutorial continue commands, since the step trigger actions aren't supposed to handle them,
// plus we might have already loaded in the next step.
if (msg.cmd.type != "dialog-answer" || msg.cmd.tutorial != "continue")
step.OnPlayerCommand.call(this, msg);
} :
step[event];
this.EnableTrigger(event, action);
if (!goalAlreadyDone)
needDelay = false;
}
else
this.DisableTrigger(event, action);
}
// Goals without actions to be performed by the player must have
// - either the property delay (a value > 0 to wait for a given time, and -1 to display the Ready button)
// - or no trigger functions (needDelay will be set automatically to true and the Ready button displayed)
if (goal.delay || needDelay)
{
if (goal.delay && goal.delay > 0)
this.DoAfterDelay(+goal.delay, "NextGoal", {});
else
{
this.EnableTrigger("OnPlayerCommand", "OnPlayerCommandTrigger");
Trigger.prototype.OnPlayerCommandTrigger = function(msg)
{
if (msg.cmd.type == "dialog-answer" && msg.cmd.tutorial && msg.cmd.tutorial == "ready")
this.NextGoal();
};
readyButton = true;
}
}
Trigger.prototype.IsDone = step.IsDone || (() => false);
const isDone = this.IsDone();
const showContinueButton = isDone || (step.panelData.showContinueButton === undefined ?
this.tutorialEvents.every(event => !step[event]) :
step.panelData.showContinueButton
);
this.GoalMessage(goal.instructions, readyButton, ++this.index == this.tutorialGoals.length);
this.DisplayStep(step, showContinueButton, isDone);
};
Trigger.prototype.GoalMessage = function(instructions, readyButton=false, leave=false)
Trigger.prototype.DisplayStep = function(step, showContinueButton = false, isDone = false)
{
const cmpGUIInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface);
cmpGUIInterface.PushNotification({
"type": "tutorial",
"players": [1],
"instructions": typeof instructions === "string" ? [instructions] : instructions,
"readyButton": readyButton,
"leave": leave
"step": {
"type": step.type,
"panelData": {
...step.panelData, showContinueButton, isDone
}
}
});
};
Trigger.prototype.WarningMessage = function(warning)
Trigger.prototype.DisplayWarning = function(warning)
{
const cmpGUIInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface);
cmpGUIInterface.PushNotification({
@ -101,13 +105,44 @@ Trigger.prototype.WarningMessage = function(warning)
});
};
Trigger.prototype.OnDeserializedTrigger = function()
Trigger.prototype.CompleteTutorial = function(message = markForTranslation("You have successfully completed the tutorial."))
{
this.index = Math.max(0, this.index - 1);
// Display messages from already processed goals
for (let i = 0; i < this.index; ++i)
this.GoalMessage(this.tutorialGoals[i].instructions, false, false);
this.NextGoal(true);
// End the tutorial first, before marking the player won, since otherwise the triggers would be invoked
// once more when doing so.
this.EndTutorial(message);
TriggerHelper.SetPlayerWon(this.playerID, () => markForTranslation("%(player)s has completed the tutorial."), () => "", message);
};
Trigger.prototype.FailTutorial = function(message = markForTranslation("You have failed to complete the tutorial."))
{
// End the tutorial first, before marking the player defeated, since otherwise the triggers would be invoked
// once more when doing so.
this.EndTutorial(message);
TriggerHelper.DefeatPlayer(this.playerID, markForTranslation("%(player)s has failed the tutorial."), message);
};
Trigger.prototype.EndTutorial = function(message)
{
this.DisableAllTriggers();
this.DisplayStep({
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": { "isLast": true, "text": message }
}, true, false);
};
Trigger.prototype.BasePlayerCommandAction = function(msg)
{
if (msg.cmd.type == "dialog-answer" && msg.cmd.tutorial == "continue")
this.NextStep();
};
Trigger.prototype.DeserializedAction = function()
{
this.stepIndex = Math.max(0, this.stepIndex - 1);
// Display messages from already processed steps
for (let i = 0; i <= this.stepIndex; ++i)
this.DisplayStep(this.tutorialSteps[i], false, false);
this.NextStep(true);
};

View file

@ -1,16 +1,42 @@
Trigger.prototype.WonderVictoryEntityRenamed = function(data)
{
if (this.wonderVictoryMessages[data.entity] && Engine.QueryInterface(data.newentity, IID_Wonder))
{
// When an entity is renamed, we first create a new entity,
// which in case it is a wonder will receive a timer.
// However on a rename we want to use the timer from the old entity,
// so we need to remove the timer of the new entity.
this.WonderVictoryDeleteTimer(data.newentity);
if (!Engine.QueryInterface(data.newentity, IID_Wonder))
return;
// Check if wonder victory is enabled
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
// Handle timer transfer (only if old entity had the timer)
if (this.wonderVictoryMessages[data.entity])
{
this.WonderVictoryDeleteTimer(data.newentity);
this.wonderVictoryMessages[data.newentity] = this.wonderVictoryMessages[data.entity];
delete this.wonderVictoryMessages[data.entity];
}
// Make the completed wonder permanently visible
TriggerHelper.MakeEntityPermanentlyVisible(data.newentity);
// Send "wonder completed" notifications (only for non-Gaia wonders)
const cmpOwnership = Engine.QueryInterface(data.newentity, IID_Ownership);
if (cmpOwnership)
{
const owner = cmpOwnership.GetOwner();
if (owner > 0)
{
TriggerHelper.SendWonderNotifications(
owner,
markForTranslation("%(_player_)s has built a Wonder!"),
markForTranslation("You have built a Wonder!")
);
}
}
};
Trigger.prototype.WonderVictoryOwnershipChanged = function(data)
@ -18,6 +44,15 @@ Trigger.prototype.WonderVictoryOwnershipChanged = function(data)
if (!Engine.QueryInterface(data.entity, IID_Wonder))
return;
// Check if wonder victory is enabled
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
this.WonderVictoryDeleteTimer(data.entity);
if (data.to > 0)
@ -59,7 +94,7 @@ Trigger.prototype.WonderVictoryStartTimer = function(ent, player)
for (let playerID = 1; playerID < TriggerHelper.GetNumberOfPlayers(); ++playerID)
{
const cmpPlayer = QueryPlayerIDInterface(playerID);
if (cmpPlayer.GetState() == "won")
if (cmpPlayer.HasWon())
return;
if (allies.indexOf(playerID) == -1 && playerID != player)
others.push(playerID);
@ -146,11 +181,69 @@ Trigger.prototype.WonderVictorySetWinner = function(playerID)
n));
};
Trigger.prototype.WonderStartNotification = function(data)
{
// Check if wonder victory is enabled in game settings
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
// Don't show the wonder foundation if it's not a wonder victory game
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
const cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager);
if (!cmpTemplateManager)
return;
const template = cmpTemplateManager.GetTemplate(data.template);
if (!template || !("Wonder" in template))
return;
const owner = TriggerHelper.GetOwner(data.foundation);
if (owner <= 0)
return;
TriggerHelper.MakeEntityPermanentlyVisible(data.foundation);
TriggerHelper.SendWonderNotifications(
owner,
markForTranslation("%(_player_)s has started building a Wonder."),
markForTranslation("You have started building a Wonder.")
);
};
Trigger.prototype.RevealGaiaWonders = function()
{
Engine.ProfileStart("RevealGaiaWonders");
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
if (!cmpEndGameManager)
return;
const victoryConditions = cmpEndGameManager.GetVictoryConditions();
if (!victoryConditions || !victoryConditions.includes("wonder"))
return;
// Find and reveal all Gaia wonders
const gaiaWonders = Engine.GetEntitiesWithInterface(IID_Wonder).filter(
ent => TriggerHelper.GetOwner(ent) === 0
);
for (const ent of gaiaWonders)
TriggerHelper.MakeEntityPermanentlyVisible(ent);
Engine.ProfileStop();
};
{
const cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger);
cmpTrigger.RegisterTrigger("OnEntityRenamed", "WonderVictoryEntityRenamed", { "enabled": true });
cmpTrigger.RegisterTrigger("OnOwnershipChanged", "WonderVictoryOwnershipChanged", { "enabled": true });
cmpTrigger.RegisterTrigger("OnDiplomacyChanged", "WonderVictoryDiplomacyChanged", { "enabled": true });
cmpTrigger.RegisterTrigger("OnPlayerWon", "WonderVictoryPlayerWon", { "enabled": true });
cmpTrigger.RegisterTrigger("OnConstructionStarted", "WonderStartNotification", { "enabled": true });
cmpTrigger.DoAfterDelay(0, "RevealGaiaWonders", {});
cmpTrigger.wonderVictoryMessages = {};
}

View file

@ -1,32 +1,46 @@
Trigger.prototype.tutorialGoals = [
Trigger.prototype.tutorialSteps = [
{
"instructions": markForTranslation("Welcome to the 0\xa0A.D. tutorial."),
},
{
"instructions": markForTranslation("Left-click on a Civilian and then right-click on a berry bush to make that Civilian gather food. Civilians gather vegetables faster than other units."),
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "fruit")
this.NextGoal();
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Welcome to the 0\xa0A.D. tutorial."),
"hint": markForTranslation("Click to start the tutorial."),
"continueButton": markForTranslation("Start")
}
},
{
"instructions": markForTranslation("Select the Citizen Soldier, right-click on a tree near the Civic Center to begin gathering wood. Citizen Soldiers gather wood faster than Civilians."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Left-click on a Civilian and then right-click on a berry bush to make that Civilian gather food. Civilians gather vegetables faster than other units.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "tree")
this.NextGoal();
TriggerHelper.GetResourceType(msg.cmd.target).specific == "fruit")
this.NextStep();
}
},
{
"instructions": [
{
"text": markForTranslation("Select the Civic Center building and hold %(hotkey)s while clicking on the Hoplite icon once to begin training a batch of Hoplites."),
"hotkey": "session.batchtrain"
}
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Select the Citizen Soldier, right-click on a tree near the Civic Center to begin gathering wood. Citizen Soldiers gather wood faster than Civilians.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "tree")
this.NextStep();
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Select the Civic Center building and hold %(hotkey)s while clicking on the Hoplite icon once to begin training a batch of Hoplites."),
"hotkeys": ["session.batchtrain"]
},
"Init": function()
{
this.trainingDone = false;
},
"OnTrainingQueued": function(msg)
{
if (msg.unitTemplate != "units/spart/infantry_spearman_b" || +msg.count == 1)
@ -36,35 +50,58 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count == 1 ?
markForTranslation("Do not forget to press the batch training hotkey while clicking to produce multiple units.") :
markForTranslation("Click on the Hoplite icon.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Select the two idle Civilians and build a House nearby by selecting the House icon. Place the House by left-clicking on a piece of land."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Select the two idle Civilians and build a House nearby by selecting the House icon. Place the House by left-clicking on a piece of land.")
},
"OnTrainingFinished": function(msg)
{
this.trainingDone = true;
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "House"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("When they are ready, select the newly trained Hoplites and assign them to build a Storehouse beside some nearby trees. They will begin to gather wood when it's constructed."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Wait for the training of the Hoplites to finish.")
},
"IsDone": function()
{
return this.trainingDone;
},
"OnTrainingFinished": function(msg)
{
this.NextStep();
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Select the newly trained Hoplites and assign them to build a Storehouse beside some nearby trees. They will begin to gather wood when it's constructed.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Storehouse"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
{
"text": markForTranslation("Train a batch of Skirmishers by holding %(hotkey)s and clicking on the Skirmisher icon in the Civic Center."),
"hotkey": "session.batchtrain"
}
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Train a batch of Skirmishers by holding %(hotkey)s and clicking on the Skirmisher icon in the Civic Center."),
"hotkeys": ["session.batchtrain"]
},
"Init": function()
{
this.trainingDone = false;
@ -78,18 +115,21 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count == 1 ?
markForTranslation("Do not forget to press the batch training hotkey while clicking to produce multiple units.") :
markForTranslation("Click on the Skirmisher icon.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Build a Farmstead in an open space beside the Civic Center using any idle builders."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Build a Farmstead in an open space beside the Civic Center using any idle builders.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Farmstead"))
this.NextGoal();
this.NextStep();
},
"OnTrainingFinished": function(msg)
{
@ -97,7 +137,11 @@ Trigger.prototype.tutorialGoals = [
}
},
{
"instructions": markForTranslation("Let's wait for the Farmstead to be built."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Let's wait for them to finish building the Farmstead, so they can use it as a dropsite."),
"hint": markForTranslation("Wait for the Farmstead to be built.")
},
"OnTrainingFinished": function(msg)
{
this.trainingDone = true;
@ -105,11 +149,14 @@ Trigger.prototype.tutorialGoals = [
"OnStructureBuilt": function(msg)
{
if (TriggerHelper.EntityMatchesClassList(msg.building, "Farmstead"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Once the Farmstead is constructed, its builders will automatically begin gathering food if there is any nearby. Select the builders and instead make them construct a Field beside the Farmstead."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Now its builders will automatically begin gathering food if there is any nearby. Select the builders and instead make them construct a Field beside the Farmstead.")
},
"Init": function()
{
this.farmStarted = false;
@ -123,25 +170,31 @@ Trigger.prototype.tutorialGoals = [
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Field"))
this.farmStarted = true;
if (this.IsDone())
this.NextGoal();
this.NextStep();
},
"OnTrainingFinished": function(msg)
{
this.trainingDone = true;
if (this.IsDone())
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("The Field's builders will now automatically begin gathering food from the Field. Using the newly created group of skirmishers, get them to build another House nearby."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("The Field's builders will now automatically begin gathering food from the Field. Using the newly created group of skirmishers, get them to build another House nearby.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "House"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Train a batch of Hoplites at the Civic Center. Select the Civic Center and with it selected right-click on a tree nearby. Units from the Civic Center will now automatically gather wood."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Train a batch of Hoplites at the Civic Center. Select the Civic Center and with it selected right-click on a tree nearby. Units from the Civic Center will now automatically gather wood.")
},
"Init": function()
{
this.rallyPointSet = false;
@ -160,37 +213,55 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count == 1 ?
markForTranslation("Do not forget to press the batch training hotkey while clicking to produce multiple units.") :
markForTranslation("Click on the Hoplite icon.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
this.trainingStarted = true;
if (this.IsDone())
this.NextGoal();
this.NextStep();
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type != "set-rallypoint" || !msg.cmd.data ||
!msg.cmd.data.command || msg.cmd.data.command != "gather" ||
!msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "tree")
!msg.cmd.data.command || msg.cmd.data.command != "gather" ||
!msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "tree")
{
this.WarningMessage(markForTranslation("Select the Civic Center, then hover the cursor over the tree and right-click when you see your cursor change into a wood icon."));
this.DisplayWarning(markForTranslation("Select the Civic Center, then hover the cursor over the tree and right-click when you see your cursor change into a wood icon."));
return;
}
this.rallyPointSet = true;
if (this.IsDone())
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Build a Barracks nearby. Whenever your population limit is reached, build an extra House using any available builder units. This will be the fifth Village Phase structure that you have built, allowing you to advance to the Town Phase."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Build a Barracks nearby. Whenever your population limit is reached, build an extra House using any available builder units.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Barracks"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Select the Civic Center again and advance to Town Phase by clicking on the II icon (you have to wait for the barracks to be built first). This will allow Town Phase buildings to be constructed."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("The Barracks will be your fifth Village Phase structure, allowing you to advance to the Town Phase."),
"hint": markForTranslation("Wait for the Barracks to be built.")
},
"OnStructureBuilt": function(msg)
{
if (TriggerHelper.EntityMatchesClassList(msg.building, "Barracks"))
this.NextStep();
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Select the Civic Center again and advance to Town Phase by clicking on the II icon (you have to wait for the barracks to be built first). This will allow Town Phase buildings to be constructed.")
},
"IsDone": function()
{
return TriggerHelper.HasDealtWithTech(this.playerID, "phase_town_generic");
@ -198,11 +269,15 @@ Trigger.prototype.tutorialGoals = [
"OnResearchQueued": function(msg)
{
if (msg.technologyTemplate && TriggerHelper.EntityMatchesClassList(msg.researcherEntity, "CivilCentre"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("While waiting for the phasing up, you may reassign your idle workers to gathering the resources you are short of."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("While waiting for the phasing up, you may reassign your idle workers to gathering the resources you are short of."),
"hint": markForTranslation("Wait until you reach the Town Phase.")
},
"IsDone": function()
{
const cmpPlayerManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager);
@ -213,19 +288,25 @@ Trigger.prototype.tutorialGoals = [
"OnResearchFinished": function(msg)
{
if (msg.tech == "phase_town_generic")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Order the idle Skirmishers to build an outpost to the north east at the edge of your territory."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Order the idle Skirmishers to build an outpost to the north east at the edge of your territory.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Outpost"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Start training a batch of Civilians in the Civic Center and set its rally point to the Field (right click on it)."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Start training a batch of Civilians in the Civic Center and set its rally point to the Field (right click on it).")
},
"Init": function()
{
this.rallyPointSet = false;
@ -244,60 +325,75 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count == 1 ?
markForTranslation("Do not forget to press the batch training hotkey while clicking to produce multiple units.") :
markForTranslation("Click on the Civilian icon.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
this.trainingStarted = true;
if (this.IsDone())
this.NextGoal();
this.NextStep();
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type != "set-rallypoint" || !msg.cmd.data ||
!msg.cmd.data.command || msg.cmd.data.command != "gather" ||
!msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "grain")
!msg.cmd.data.command || msg.cmd.data.command != "gather" ||
!msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "grain")
return;
this.rallyPointSet = true;
if (this.IsDone())
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Prepare for an attack by an enemy player. Train more soldiers using the Barracks, and get idle soldiers to build a Tower near your Outpost."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Prepare for an attack by an enemy player. Train more soldiers using the Barracks, and get idle soldiers to build a Tower near your Outpost.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "repair" && TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Tower"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Build a Forge and research the Infantry Training technology (sword icon) to improve infantry hack attack."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Build a Forge and research the Infantry Training technology (sword icon) to improve infantry hack attack.")
},
"OnResearchQueued": function(msg)
{
if (msg.technologyTemplate && TriggerHelper.EntityMatchesClassList(msg.researcherEntity, "Forge"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("The enemy is coming. Train more soldiers to fight off the enemies."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("The enemy is coming. Train more soldiers to fight off the enemies.")
},
"OnResearchFinished": function(msg)
{
this.LaunchAttack();
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Try to repel the attack."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Try to repel the attack.")
},
"OnOwnershipChanged": function(msg)
{
if (msg.to != INVALID_PLAYER)
return;
if (this.IsAttackRepelled())
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("The enemy attack has been thwarted. Now build a market and a temple while you assign new units to gather required resources."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("The enemy attack has been thwarted. Now build a market and a temple while you assign new units to gather required resources.")
},
"Init": function()
{
this.marketStarted = false;
@ -314,11 +410,14 @@ Trigger.prototype.tutorialGoals = [
this.marketStarted = this.marketStarted || TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Market");
this.templeStarted = this.templeStarted || TriggerHelper.EntityMatchesClassList(msg.cmd.target, "Temple");
if (this.IsDone())
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Once you meet the City Phase requirements, select your Civic Center and advance to City Phase."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Once you meet the City Phase requirements, select your Civic Center and advance to City Phase.")
},
"IsDone": function()
{
return TriggerHelper.HasDealtWithTech(this.playerID, "phase_city_generic");
@ -326,11 +425,15 @@ Trigger.prototype.tutorialGoals = [
"OnResearchQueued": function(msg)
{
if (msg.technologyTemplate && TriggerHelper.EntityMatchesClassList(msg.researcherEntity, "CivilCentre"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("While waiting for the phase change, you may train more soldiers at the Barracks."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("While waiting for the phase change, you may train more soldiers at the Barracks."),
"hint": markForTranslation("Wait until you reach the City Phase")
},
"IsDone": function()
{
return TriggerHelper.HasDealtWithTech(this.playerID, "phase_city_generic");
@ -338,11 +441,14 @@ Trigger.prototype.tutorialGoals = [
"OnResearchFinished": function(msg)
{
if (msg.tech == "phase_city_generic")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": markForTranslation("Now that you are in City Phase, build the Arsenal nearby and then use it to construct 2 Battering Rams."),
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Now that you are in City Phase, build the Arsenal nearby and then use it to construct 2 Battering Rams.")
},
"Init": function()
{
this.ramCount = 0;
@ -358,29 +464,23 @@ Trigger.prototype.tutorialGoals = [
if (this.IsDone())
{
this.RemoveChampions();
this.NextGoal();
this.NextStep();
}
}
},
{
"instructions": [
markForTranslation("Stop all your soldiers gathering resources and instead task small groups to find the enemy Civic Center on the map. Once the enemy's base has been spotted, send your Siege Engines and all remaining soldiers to destroy it.\n"),
markForTranslation("Civilians should continue to gather resources.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("Stop all your soldiers gathering resources and instead task small groups to find the enemy Civic Center on the map. Once the enemy's base has been spotted, send your Siege Engines and all remaining soldiers to destroy it.\n") +
markForTranslation("Civilians should continue to gather resources.")
},
"OnOwnershipChanged": function(msg)
{
if (msg.from != this.enemyID)
return;
if (TriggerHelper.EntityMatchesClassList(msg.entity, "CivilCentre"))
this.NextGoal();
}
},
{
"instructions": markForTranslation("The enemy has been defeated. These tutorial tasks are now completed."),
"Init": function()
{
const cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager);
cmpEndGameManager.MarkPlayerAndAlliesAsWon(1, () => "Tutorial completed", () => "");
this.CompleteTutorial(markForTranslation("You have defeated the enemy by destroying or capturing the Civic Center. These tutorial tasks are now completed."));
}
}
];

View file

@ -1,111 +1,156 @@
Trigger.prototype.tutorialGoals = [
Trigger.prototype.tutorialSteps = [
{
"instructions": [
markForTranslation("This tutorial will teach the basics of developing your economy. Typically, you will start with a Civic Center and a couple units in Village Phase and ultimately, your goal will be to develop and expand your empire, often by evolving to Town Phase and City Phase afterward.\n"),
{
"text": markForTranslation("\nBefore starting, you can toggle between fullscreen and windowed mode using %(hotkey)s."),
"hotkey": "togglefullscreen"
},
markForTranslation("You can change the level of zoom using the mouse wheel and the camera view using any of your keyboard's arrow keys.\n"),
markForTranslation("Adjust the game window to your preferences.\n"),
{
"text": markForTranslation("\nYou may also toggle between showing and hiding this tutorial panel at any moment using %(hotkey)s.\n"),
"hotkey": "session.gui.tutorial.toggle"
}
]
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("This tutorial will teach the basics of developing your economy. Typically, you will start with a Civic Center and a couple units in Village Phase and ultimately, your goal will be to develop and expand your empire, often by evolving to Town Phase and City Phase afterward."),
"hint": markForTranslation("Click to start."),
"continueButton": markForTranslation("Start")
}
},
{
"instructions": [
markForTranslation("To start off, select your building, the Civic Center, by clicking on it. A selection ring in the color of your player will be displayed after clicking.")
]
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"appendable": "true",
"title": markForTranslation("Basic Controls"),
"texts": [
markForTranslation("You can toggle between fullscreen and windowed mode using %(hotkey1)s."),
markForTranslation("You can change the level of zoom using the mouse wheel and the camera view using any of your keyboard's arrow keys."),
markForTranslation("Adjust the game window to your preferences."),
markForTranslation("You may also toggle between showing and hiding this tutorial panel at any moment using %(hotkey2)s.")
],
"hotkeys": ["togglefullscreen", "session.gui.tutorial.toggle"]
}
},
{
"instructions": [
markForTranslation("Now that the Civic Center is selected, you will notice that a production panel will appear on the lower right of your screen detailing the actions that the buildings supports. For the production panel, available actions are not masked in any color, while an icon masked in gray indicates that the action has not been unlocked and a red mask indicates that you do not have sufficient resources to perform that action. Additionally, you can hover the cursor over any icon to show a tooltip with more details.\n"),
markForTranslation("The top row of buttons contains portraits of units that may be trained at the building while the bottom one or two rows will have researchable technologies. Hover the cursor over the II icon. The tooltip will tell us that advancing to Town Phase requires both more constructed structures as well as more food and wood resources.")
]
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("To start off, select your building, the Civic Center, by clicking on it. A selection ring in the color of your player will be displayed after clicking."),
"hint": markForTranslation("Click to continue when you have selected the Civic Center.")
}
},
{
"instructions": [
markForTranslation("You have two main types of starting units: Civilians and Citizen Soldiers. Civilians are purely economic units; they have low health and little to no attack. Citizen Soldiers are workers by default, but in times of need, can utilize a weapon to fight. You have two categories of Citizen Soldiers: Infantry and Cavalry. Civilians and Infantry Citizen Soldiers can gather any land resources while Cavalry Citizen Soldiers can only gather meat from animals.\n")
]
"type": TUTORIAL_STEP_TYPE.GUI_EXPLANATION,
"panelData": {
"targetObject": "unitCommands",
"side": "top",
"title": markForTranslation("Production Panel"),
"texts": [
markForTranslation("Now that the Civic Center is selected, you will notice that a production panel will appear on the lower right of your screen detailing the actions that the buildings supports. For the production panel, available actions are not masked in any color, while an icon masked in gray indicates that the action has not been unlocked and a red mask indicates that you do not have sufficient resources to perform that action. Additionally, you can hover the cursor over any icon to show a tooltip with more details."),
markForTranslation("The top row of buttons contains portraits of units that may be trained at the building while the bottom one or two rows will have researchable technologies. Hover the cursor over the II icon. The tooltip will tell us that advancing to Town Phase requires both more constructed structures as well as more food and wood resources.")
]
}
},
{
"instructions": [
markForTranslation("As a general rule of thumb, left-clicking represents selection while right-clicking with an entity selected represents an order (gather, build, fight, etc.).\n")
]
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"title": markForTranslation("Types of Units"),
"text": markForTranslation("You have two main types of starting units: Civilians and Citizen Soldiers. Civilians are purely economic units; they have low health and little to no attack. Citizen Soldiers are workers by default, but in times of need, can utilize a weapon to fight. You have two categories of Citizen Soldiers: Infantry and Cavalry. Civilians and Infantry Citizen Soldiers can gather any land resources while Cavalry Citizen Soldiers can only gather meat from animals.")
}
},
{
"instructions": [
markForTranslation("At this point, food and wood are the most important resources for developing your economy, so let's start with gathering food. Civilians gather vegetables faster than other units.\n"),
markForTranslation("There are primarily three ways to select units:\n"),
markForTranslation("1) Hold the left mouse button and drag a selection rectangle that encloses the units you want to select.\n"),
{
"text": markForTranslation("2) Click on one of them and then add additional units to your selection by holding %(hotkey)s and clicking each additional unit (or also via the above selection rectangle).\n"),
"hotkey": "selection.add"
},
{
"text": markForTranslation("3) Double-click on a unit. This will select every unit of the same type as the specified unit in your visible window. %(hotkey)s+double-click will select all units of the same type on the entire map.\n"),
"hotkey": "selection.offscreen"
},
markForTranslation("You can click on an empty space on the map to reset the selection. Try each of these methods before tasking all of your Civilians to gather the berries to the southeast of your Civic Center by right-clicking on the berries when you have all the Civilians selected.")
],
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text": markForTranslation("As a general rule of thumb, left-clicking represents selection while right-clicking with an entity selected represents an order (gather, build, fight, etc.).")
}
},
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"title": markForTranslation("Making Selections"),
"texts": [
markForTranslation("There are primarily three ways to select units:"),
markForTranslation("1) Hold the left mouse button and drag a selection rectangle that encloses the units you want to select."),
markForTranslation("2) Click on one of them and then add additional units to your selection by holding %(hotkey1)s and clicking each additional unit (or also via the above selection rectangle)."),
markForTranslation("3) Double-click on a unit. This will select every unit of the same type as the specified unit in your visible window. %(hotkey2)s+double-click will select all units of the same type on the entire map."),
markForTranslation("You can click on an empty space on the map to reset the selection.")
],
"hotkeys": ["selection.add", "selection.offscreen"]
}
},
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text": markForTranslation("At this point, food and wood are the most important resources for developing your economy.")
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Let's start with gathering food. Civilians gather vegetables faster than other units. Select them and order them to gather the berries to the southeast of your Civic Center by right-clicking on the berries.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "fruit")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Now, let's gather some wood with your Infantry Citizen Soldiers. Select your Infantry Citizen Soldiers and order them to gather wood by right-clicking on the nearest tree.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Now, let's gather some wood with your Infantry Citizen Soldiers. Select your Infantry Citizen Soldiers and order them to gather wood by right-clicking on the nearest tree.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "tree")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Cavalry Citizen Soldiers are good for hunting. Select your Cavalry and order him to slaughter the chickens around your Civic Center in similar fashion.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Cavalry Citizen Soldiers are good for hunting. Select your Cavalry and order him to slaughter the chickens around your Civic Center in similar fashion.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "meat")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("All your units are now gathering resources. We should train more units!\n"),
markForTranslation("First, let's set a rally point. Setting a rally point on a building that can train units will automatically designate a task to the new unit upon completion of training. We want to send the newly trained units to gather wood on the group of trees to the south of the Civic Center. To do so, select the Civic Center by clicking on it and then right-click on one of the trees.\n"),
markForTranslation("Rally points are indicated by a small flag at the end of the blue line.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("All your units are now gathering resources. We should train more units!")
}
},
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"appendable": true,
"title": markForTranslation("Rally Points"),
"text": markForTranslation("First, let's set a rally point. Setting a rally point on a building that can train units will automatically designate a task to the new unit upon completion of training. We want to send the newly trained units to gather wood on the group of trees to the south of the Civic Center. To do so, select the Civic Center by clicking on it and then right-click on one of the trees.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type != "set-rallypoint" || !msg.cmd.data ||
!msg.cmd.data.command || msg.cmd.data.command != "gather" ||
!msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "tree")
!msg.cmd.data.command || msg.cmd.data.command != "gather" ||
!msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "tree")
{
this.WarningMessage(markForTranslation("Select the Civic Center, then hover the cursor over a tree and right-click when you see the cursor change into a wood icon."));
this.DisplayWarning(markForTranslation("Select the Civic Center, then hover the cursor over a tree and right-click when you see the cursor change into a wood icon."));
return;
}
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Now that the rally point is set, we can produce additional units and they will do their assigned task automatically.\n"),
{
"text": markForTranslation("Citizen Soldiers gather wood faster than Civilians. Select the Civic Center and, while holding %(hotkey)s, click on the second unit icon, the Hoplites (holding %(hotkey)s trains a batch of five units). You can also train units individually by simply clicking, but training 5 units together takes less time than training 5 units individually."),
"hotkey": "session.batchtrain"
}
],
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"appendToPrevious": true,
"texts": [
markForTranslation("Rally points are indicated by a small flag at the end of the blue line."),
markForTranslation("Now that the rally point is set, we can produce additional units and they will do their assigned task automatically.")
]
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("Citizen Soldiers gather wood faster than Civilians. Select the Civic Center and, while holding %(hotkey)s, click on the second unit icon, the Hoplites (holding %(hotkey)s trains a batch of five units). You can also train units individually by simply clicking, but training 5 units together takes less time than training 5 units individually."),
"hotkeys": ["session.batchtrain"]
},
"OnTrainingQueued": function(msg)
{
if (msg.unitTemplate != "units/athen/infantry_spearman_b" || +msg.count == 1)
@ -116,71 +161,110 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count == 1 ?
markForTranslation("Do not forget to hold the hotkey while clicking to train several units.") :
markForTranslation("The second icon represents the Hoplites.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Let's wait for the units to be trained.\n"),
markForTranslation("While waiting, direct your attention to the panel at the top of your screen. On the upper left, you will see your current resource supply (food, wood, stone, and metal). As each worker brings resources back to the Civic Center (or another dropsite), you will see the amount of the corresponding resource increase.\n"),
markForTranslation("This is a very important concept to keep in mind: gathered resources have to be brought back to a dropsite to be accounted, and you should always try to minimize the distance between resource and nearest dropsite to improve your gathering efficiency.")
],
"type": TUTORIAL_STEP_TYPE.GUI_EXPLANATION,
"panelData": {
"targetObject": "resourceCounts",
"side": "bottom",
"title": markForTranslation("Resource Supply"),
"text": markForTranslation("Direct your attention to the panel at the top of your screen. On the upper left, you will see your current resource supply (food, wood, stone, and metal). As each worker brings resources back to the Civic Center (or another dropsite), you will see the amount of the corresponding resource increase."),
"showContinueButton": true
},
"OnTrainingFinished": function(msg)
{
this.NextGoal();
this.trainingFinished = true;
}
},
{
"instructions": [
markForTranslation("The newly trained units automatically go to the trees and start gathering wood.\n"),
markForTranslation("But as they have to bring it back to the Civic Center to deposit it, their gathering efficiency suffers from the distance. To fix that, we can build a Storehouse, a dropsite for wood, stone, and metal, close to the trees. To do so, select your five newly trained Citizen Soldiers and look for the construction panel on the bottom right, click on the Storehouse icon, move the mouse as close as possible to the trees you want to gather and click on a valid place to build the dropsite.\n"),
markForTranslation("Invalid (obstructed) positions will show the building preview overlay in red.")
],
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text": markForTranslation("This is a very important concept to keep in mind: gathered resources have to be brought back to a dropsite to be accounted, and you should always try to minimize the distance between resource and nearest dropsite to improve your gathering efficiency."),
"showContinueButton": true
},
"OnTrainingFinished": function(msg)
{
this.trainingFinished = true;
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Let's wait until the batch of Hoplites has finished training."),
"hint": markForTranslation("Wait for the training to finish.")
},
"IsDone": function()
{
return this.trainingFinished;
},
"OnTrainingFinished": function(msg)
{
this.NextStep();
}
},
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"title": markForTranslation("Storehouse"),
"texts": [
markForTranslation("The newly trained units automatically go to the trees and start gathering wood."),
markForTranslation("But as they have to bring it back to the Civic Center to deposit it, their gathering efficiency suffers from the distance. To fix that, we can build a Storehouse, a dropsite for wood, stone, and metal, close to the trees.")
]
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("To do so, select your five newly trained Citizen Soldiers and look for the construction panel on the bottom right, click on the Storehouse icon, move the mouse as close as possible to the trees you want to gather and click on a valid place to build the dropsite. Invalid (obstructed) positions will show the building preview overlay in red.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "construct" && msg.cmd.template == "structures/athen/storehouse")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("The selected Citizens will automatically start constructing the building once you place the foundation.")
],
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text": markForTranslation("The selected Citizens will automatically start constructing the building once you place the foundation."),
"hint": markForTranslation("Wait for the storehouse to be built.")
},
"OnStructureBuilt": function(msg)
{
const cmpResourceDropsite = Engine.QueryInterface(msg.building, IID_ResourceDropsite);
if (cmpResourceDropsite && cmpResourceDropsite.AcceptsType("wood"))
this.NextGoal();
},
},
{
"instructions": [
markForTranslation("When construction finishes, the builders default to gathering wood automatically.\n"),
markForTranslation("In the meantime, we seem to have enough workers gathering wood. We should remove the current rally point of the Civic Center away from gathering wood. For that purpose, right-click on the Civic Center when it is selected (and the flag icon indicating the rally point is crossed out).")
],
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "unset-rallypoint")
this.NextGoal();
},
"OnTrainingFinished": function(msg)
{
this.trainingDone = true;
this.NextStep();
}
},
{
"instructions": [
{
"text": markForTranslation("Let's train some Civilians to gather more food. Select the Civic Center, hold %(hotkey)s and click on the Civilian icon to train five Civilians."),
"hotkey": "session.batchtrain"
}
],
"Init": function()
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text": markForTranslation("When construction finishes, the builders default to gathering wood automatically.")
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("In the meantime, we seem to have enough workers gathering wood. We should remove the current rally point of the Civic Center away from gathering wood. For that purpose, right-click on the Civic Center when it is selected (and the flag icon indicating the rally point is crossed out).")
},
"OnPlayerCommand": function(msg)
{
this.trainingDone = false;
if (msg.cmd.type == "unset-rallypoint")
this.NextStep();
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Let's train some Civilians to gather more food. Select the Civic Center, hold %(hotkey)s and click on the Civilian icon to train five Civilians."),
"hotkeys": ["session.batchtrain"]
},
"OnTrainingQueued": function(msg)
{
@ -192,43 +276,46 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count == 1 ?
markForTranslation("Do not forget to hold the hotkey and click to train several units.") :
markForTranslation("The first icon represents the Civilians.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("The units should be ready soon.\n"),
markForTranslation("In the meantime, direct your attention to your population count on the top panel. It is the fifth item from the left, after the resources. It would be prudent to keep an eye on it. It indicates your current population (including those being trained) and the current population limit, which is determined by your built structures.")
],
"IsDone": function(msg)
{
return this.trainingDone;
"type": TUTORIAL_STEP_TYPE.GUI_EXPLANATION,
"panelData": {
"targetObject": "resourceCounts",
"side": "bottom",
"text": markForTranslation("The units should be ready soon.\nIn the meantime, direct your attention to your population count on the top panel. It is the fifth item from the left, after the resources. It would be prudent to keep an eye on it. It indicates your current population (including those being trained) and the current population limit, which is determined by your built structures."),
"hint": markForTranslation("Wait for the training of the Civilians to finish.")
},
"OnTrainingFinished": function(msg)
{
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("As you have nearly reached the population limit, you must increase it by building some new structures if you want to train more units. The most cost effective structure to increase your population limit is the House.\n"),
markForTranslation("Now that the units are ready, let's see how to build several Houses in a row.")
]
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"title": markForTranslation("Population Limit"),
"text": markForTranslation("As you have nearly reached the population limit, you must increase it by building some new structures if you want to train more units. The most cost effective structure to increase your population limit is the House.")
}
},
{
"instructions": [
{
"text": markForTranslation("Select two of your newly-trained Civilians and order them to build these Houses in the empty space to the east of the Civic Center. Click on the House icon in the bottom right panel. While holding %(hotkey)s click on positions on the map where you want to build Houses. The Civilians will then start to build the houses in order. (When you give a command while holding %(hotkey)s, you put the command in a queue; units automatically switch to the next command in their queue when they finish their current command). Press Escape to get rid of the House cursor so you don't spam Houses all over the map.\n"),
"hotkey": "selection.queue"
},
{
"text": markForTranslation("Reminder: to select only two Civilians, click on the first one and then hold %(hotkey)s and click on the second one."),
"hotkey": "selection.add"
}
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Now that the units are ready, let's see how to build several Houses in a row.")
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("Select two of your newly-trained Civilians and order them to build these Houses in the empty space to the east of the Civic Center. Click on the House icon in the bottom right panel. While holding %(hotkey1)s click on positions on the map where you want to build Houses. The Civilians will then start to build the houses in order. (When you give a command while holding %(hotkey1)s, you put the command in a queue; units automatically switch to the next command in their queue when they finish their current command). Press Escape to get rid of the House cursor so you don't spam Houses all over the map.\n") +
markForTranslation("Reminder: to select only two Civilians, click on the first one and then hold %(hotkey2)s and click on the second one."),
"hotkeys": ["session.queue", "selection.add"]
},
"Init": function()
{
this.houseGoal = new Set();
@ -244,45 +331,28 @@ Trigger.prototype.tutorialGoals = [
{
this.houseGoal.delete(+msg.entity);
const cmpFoundation = Engine.QueryInterface(+msg.entity, IID_Foundation);
if (cmpFoundation && cmpFoundation.GetBuildProgress() < 1) // Destroyed before built
if (cmpFoundation && cmpFoundation.GetBuildProgress() < 1)
--this.houseCount;
}
else if (msg.from == INVALID_PLAYER && msg.to == this.playerID &&
Engine.QueryInterface(+msg.entity, IID_Foundation) &&
TriggerHelper.EntityMatchesClassList(+msg.entity, "House"))
Engine.QueryInterface(+msg.entity, IID_Foundation) &&
TriggerHelper.EntityMatchesClassList(+msg.entity, "House"))
{
this.houseGoal.add(+msg.entity);
++this.houseCount;
if (this.IsDone())
this.NextGoal();
this.NextStep();
}
}
},
{
"instructions": [
markForTranslation("You may notice that berries are a finite supply of food. We will need a more lasting food source. Fields produce an unlimited food resource, but are slower to gather than forageable fruits.\n"),
markForTranslation("But to minimize the distance between a farm and its corresponding food dropsite, we will first build a Farmstead.")
],
"delay": -1,
"OnOwnershipChanged": function(msg)
{
if (this.houseGoal.has(+msg.entity))
this.houseGoal.delete(+msg.entity);
}
},
{
"instructions": [
markForTranslation("Select the three remaining (idle) Civilians and order them to build a Farmstead in the center of the large open area to the west of the Civic Center.\n"),
markForTranslation("We will need a decent chunk of space around the Farmstead to build Fields. In addition, we can see goats on the west side to further improve our food gathering efficiency should we ever decide to hunt them.\n"),
{
"text": markForTranslation("If you try to select the three idle Civilians by clicking and dragging a selection rectangle over them, you might accidentally select additional units. To avoid that, hold %(hotkey)s while selecting so that only idle units are selected. If you accidentally select a cavalry unit, hold Ctrl and click on the cavalry unit icon of the selection panel at the bottom of the screen to remove the cavalry unit from the current selection."),
"hotkey": "selection.idleonly"
}
],
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "construct" && msg.cmd.template == "structures/athen/farmstead")
this.NextGoal();
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"texts": [
markForTranslation("You may notice that berries are a finite supply of food. We will need a more lasting food source. Fields produce an unlimited food resource, but are slower to gather than forageable fruits."),
markForTranslation("But to minimize the distance between a farm and its corresponding food dropsite, we will first build a Farmstead.")
],
"showContinueButton": true
},
"OnOwnershipChanged": function(msg)
{
@ -291,10 +361,33 @@ Trigger.prototype.tutorialGoals = [
}
},
{
"instructions": [
markForTranslation("When the Farmstead construction is finished, its builders will automatically look for food, and in this case, they will go after the nearby goats.\n"),
markForTranslation("But your House builders will only look for something else to build and, if nothing found, become idle. Let's wait for them to build the Houses.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("Select the three remaining (idle) Civilians and order them to build a Farmstead in the center of the large open area to the west of the Civic Center.\n") +
markForTranslation("We will need a decent chunk of space around the Farmstead to build Fields. In addition, we can see goats on the west side to further improve our food gathering efficiency should we ever decide to hunt them.\n") +
markForTranslation("If you try to select the three idle Civilians by clicking and dragging a selection rectangle over them, you might accidentally select additional units. To avoid that, hold %(hotkey)s while selecting so that only idle units are selected. If you accidentally select a cavalry unit, hold Ctrl and click on the cavalry unit icon of the selection panel at the bottom of the screen to remove the cavalry unit from the current selection."),
"hotkeys": ["selection.idleonly"]
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "construct" && msg.cmd.template == "structures/athen/farmstead")
this.NextStep();
},
"OnOwnershipChanged": function(msg)
{
if (this.houseGoal.has(+msg.entity))
this.houseGoal.delete(+msg.entity);
}
},
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text":
markForTranslation("When the Farmstead construction is finished, its builders will automatically look for food, and in this case, they will go after the nearby goats.\n") +
markForTranslation("But your House builders will only look for something else to build and, if nothing found, become idle."),
"hint": markForTranslation("Wait until the houses are built.")
},
"IsDone": function()
{
return !this.houseGoal.size;
@ -304,51 +397,60 @@ Trigger.prototype.tutorialGoals = [
if (this.houseGoal.has(+msg.entity))
this.houseGoal.delete(+msg.entity);
if (this.IsDone())
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("When both Houses are built, select your two Civilians and order them to build a Field as close as possible to the Farmstead, which is a dropsite for all types of food.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Select your two Civilians and order them to build a Field as close as possible to the Farmstead, which is a dropsite for all types of food.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "construct" && msg.cmd.template == "structures/athen/field")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("When the Field is ready, the builders will automatically start gathering it.\n"),
markForTranslation("The cavalry unit should have slaughtered all chickens by now. Select it and explore the area to the south of the Civic Center: there is a lake with some camels around. Move your cavalry by right-clicking on the point you want to go, and when you see a herd of camels, right-click on one of them to start hunting for food.")
],
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"text": markForTranslation("When the Field is ready, the builders will automatically start gathering it.")
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("The cavalry unit should have slaughtered all chickens by now. Select it and explore the area to the south of the Civic Center: there is a lake with some camels around. Move your cavalry by right-clicking on the point you want to go, and when you see a herd of camels, right-click on one of them to start hunting for food.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "gather" && msg.cmd.target &&
TriggerHelper.GetResourceType(msg.cmd.target).specific == "meat")
this.NextGoal();
TriggerHelper.GetResourceType(msg.cmd.target).specific == "meat")
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Up to five Workers can gather from a Field. To add additional Workers, select the Civic Center and set a rally point on a Field by right-clicking on it. If the Field is not yet finished, new Workers sent by a rally point will help building it, and when built, they will gather food.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Up to five Workers can gather from a Field. To add additional Workers, select the Civic Center and set a rally point on a Field by right-clicking on it. If the Field is not yet finished, new Workers sent by a rally point will help building it, and when built, they will gather food.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type != "set-rallypoint" || !msg.cmd.data || !msg.cmd.data.command ||
(msg.cmd.data.command != "build" || !msg.cmd.data.target || !TriggerHelper.EntityMatchesClassList(msg.cmd.data.target, "Field")) &&
(msg.cmd.data.command != "gather" || !msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "grain"))
(msg.cmd.data.command != "build" || !msg.cmd.data.target || !TriggerHelper.EntityMatchesClassList(msg.cmd.data.target, "Field")) &&
(msg.cmd.data.command != "gather" || !msg.cmd.data.resourceType || msg.cmd.data.resourceType.specific != "grain"))
{
this.WarningMessage(markForTranslation("Select the Civic Center and right-click on the Field."));
this.DisplayWarning(markForTranslation("Select the Civic Center and right-click on the Field."));
return;
}
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Now click three times on the Civilian icon in the bottom right panel to train three additional farmers.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("Now click three times on the Civilian icon in the bottom right panel to train three additional farmers.")
},
"Init": function(msg)
{
this.femaleCount = 0;
@ -363,57 +465,76 @@ Trigger.prototype.tutorialGoals = [
const txt = +msg.count != 1 ?
markForTranslation("Click without holding a hotkey to train a single unit.") :
markForTranslation("Click on the Civilian icon.");
this.WarningMessage(txt);
this.DisplayWarning(txt);
return;
}
if (++this.femaleCount == 3)
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("You can increase the gather rates of your workers by researching new technologies available in some buildings.\n"),
markForTranslation("The farming rate, for example, can be improved with a researchable technology in the Farmstead. Select the Farmstead and look at its production panel on the bottom right. You will see several researchable technologies. Hover the cursor over them to see their costs and effects and click on the one you want to research.")
],
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"appendable": true,
"title": markForTranslation("Technologies"),
"text": markForTranslation("You can increase the gather rates of your workers by researching new technologies available in some buildings.")
}
},
{
"type": TUTORIAL_STEP_TYPE.INFO,
"panelData": {
"appendToPrevious": true,
"text": markForTranslation("The farming rate, for example, can be improved with a researchable technology in the Farmstead. Select the Farmstead and look at its production panel on the bottom right. You will see several researchable technologies. Hover the cursor over them to see their costs and effects and click on the one you want to research.")
},
"IsDone": function()
{
return TriggerHelper.HasDealtWithTech(this.playerID, "gather_wicker_baskets") ||
TriggerHelper.HasDealtWithTech(this.playerID, "gather_farming_plows");
TriggerHelper.HasDealtWithTech(this.playerID, "gather_farming_plows");
},
"OnResearchQueued": function(msg)
{
if (msg.technologyTemplate && TriggerHelper.EntityMatchesClassList(msg.researcherEntity, "Farmstead"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("We should start preparing to phase up into Town Phase, which will unlock many more units and buildings. Select the Civic Center and hover the cursor over the Town Phase icon to see what is still needed.\n"),
markForTranslation("We now have enough resources, but one structure is missing. Although this is an economic tutorial, it is nonetheless useful to be prepared for defense in case of attack, so let's build Barracks.\n"),
markForTranslation("Select four of your soldiers and ask them to build a Barracks: as before, start selecting the soldiers, click on the Barracks icon in the production panel and then lay down a foundation not far from your Civic Center where you want to build.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("We should start preparing to phase up into Town Phase, which will unlock many more units and buildings. Select the Civic Center and hover the cursor over the Town Phase icon to see what is still needed.")
}
},
{
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("We now have enough resources, but one structure is missing. Although this is an economic tutorial, it is nonetheless useful to be prepared for defense in case of attack, so let's build a Barracks.\n") +
markForTranslation("Select four of your soldiers and ask them to build a Barracks: as before, start selecting the soldiers, click on the Barracks icon in the production panel and then lay down a foundation not far from your Civic Center where you want to build.")
},
"OnPlayerCommand": function(msg)
{
if (msg.cmd.type == "construct" && msg.cmd.template == "structures/athen/barracks")
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("Let's wait for the Barracks to be built. As this construction is lengthy, you can add two soldiers to build it faster. To do so, select your Civic Center and set up a rally point on the Barracks foundation by right-clicking on it (you should see a hammer icon). Then produce two more builders by clicking on the Hoplite icon twice.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text": markForTranslation("As the construction of a Barracks is lengthy, you can add two soldiers to build it faster. To do so, select your Civic Center and set up a rally point on the Barracks foundation by right-clicking on it (you should see a hammer icon). Then produce two more builders by clicking on the Hoplite icon twice."),
"hint": markForTranslation("Wait for the Barracks to be built.")
},
"OnStructureBuilt": function(msg)
{
if (TriggerHelper.EntityMatchesClassList(msg.building, "Barracks"))
this.NextGoal();
},
this.NextStep();
}
},
{
"instructions": [
markForTranslation("You should now be able to research Town Phase. Select the Civic Center and click on the technology icon.\n"),
markForTranslation("If you still miss some resources (icon with red overlay), wait for them to be gathered by your workers.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("You should now be able to research Town Phase. Select the Civic Center and click on the technology icon.\n") +
markForTranslation("If you still miss some resources (icon with red overlay), wait for them to be gathered by your workers.")
},
"IsDone": function()
{
return TriggerHelper.HasDealtWithTech(this.playerID, "phase_town_athen");
@ -421,23 +542,25 @@ Trigger.prototype.tutorialGoals = [
"OnResearchQueued": function(msg)
{
if (msg.technologyTemplate && TriggerHelper.EntityMatchesClassList(msg.researcherEntity, "CivilCentre"))
this.NextGoal();
this.NextStep();
}
},
{
"instructions": [
markForTranslation("In later phases, you need usually stone and metal to build bigger structures and train better soldiers. Hence, while waiting for the research to be done, you will send half of your idle Citizen Soldiers (who have finished building the Barracks) to gather stone and the other half to gather metal.\n"),
markForTranslation("To do so, we could select three Citizen Soldiers and right-click on the stone quarry on the west of the Civic Center (the cursor changes when hovering the stone quarry while your soldiers are selected). However, these soldiers were gathering wood, so they may still carry some wood which would be lost when starting to gather another resource.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("In later phases, you need usually stone and metal to build bigger structures and train better soldiers. Hence, while waiting for the research to be done, you will send half of your idle Citizen Soldiers (who have finished building the Barracks) to gather stone and the other half to gather metal.") +
markForTranslation("To do so, we could select three Citizen Soldiers and right-click on the stone quarry on the west of the Civic Center (the cursor changes when hovering the stone quarry while your soldiers are selected). However, these soldiers were gathering wood, so they may still carry some wood which would be lost when starting to gather another resource.")
}
},
{
"instructions": [
{
"text": markForTranslation("Thus, we should order them to deposit their wood in the Civic Center along the way. To do so, we will hold %(hotkey)s while clicking to queue orders: select your soldiers, hold %(hotkey)s and right-click on the Civic Center to deposit their wood and then hold %(hotkey)s and right-click on the stone quarry to gather it.\n"),
"hotkey": "selection.queue"
},
markForTranslation("Perform a similar order queue with the remaining soldiers and the metal mine in the west.")
],
"type": TUTORIAL_STEP_TYPE.INSTRUCTION,
"panelData": {
"text":
markForTranslation("Thus, we should order them to deposit their wood in the Civic Center along the way. To do so, we will hold %(hotkey)s while clicking to queue orders: select your soldiers, hold %(hotkey)s and right-click on the Civic Center to deposit their wood and then hold %(hotkey)s and right-click on the stone quarry to gather it.\n") +
markForTranslation("Perform a similar order queue with the remaining soldiers and the metal mine in the west."),
"hotkeys": ["session.queue"]
},
"Init": function()
{
this.stone = false;
@ -448,7 +571,6 @@ Trigger.prototype.tutorialGoals = [
if (!this.stone || !this.metal)
return false;
return TriggerHelper.HasDealtWithTech(this.playerID, "phase_town_athen");
},
"OnPlayerCommand": function(msg)
{
@ -460,18 +582,13 @@ Trigger.prototype.tutorialGoals = [
this.metal = true;
}
if (this.IsDone())
this.NextGoal();
this.NextStep();
},
"OnResearchFinished": function(msg)
{
if (this.IsDone())
this.NextGoal();
this.CompleteTutorial(markForTranslation("This is the end of the walkthrough. This should give you a good idea of the basics of setting up your economy."));
}
},
{
"instructions": [
markForTranslation("This is the end of the walkthrough. This should give you a good idea of the basics of setting up your economy.")
]
}
];

View file

@ -1,60 +1,63 @@
globalThis.PlayerID = -1;
export function BaseAI(settings)
export class BaseAI
{
if (!settings)
return;
constructor(settings)
{
if (!settings)
return;
this.player = settings.player;
this.player = settings.player;
}
/** Return a simple object (using no classes etc) that will be serialized into saved games */
Serialize()
{
return {};
}
/**
* Called after the constructor when loading a saved game, with 'data' being
* whatever Serialize() returned
*/
Deserialize(data, sharedScript)
{
}
Init(playerID, sharedAI)
{
PlayerID = playerID;
this.territoryMap = sharedAI.territoryMap;
this.accessibility = sharedAI.accessibility;
this.gameState = sharedAI.gameState[this.player];
this.gameState.ai = this;
this.timeElapsed = sharedAI.timeElapsed;
this.CustomInit(this.gameState);
}
/** AIs override this function */
CustomInit()
{
}
HandleMessage(state, playerID, sharedAI)
{
PlayerID = playerID;
this.territoryMap = sharedAI.territoryMap;
this.OnUpdate(sharedAI);
}
/** AIs override this function */
OnUpdate()
{
}
chat(message)
{
Engine.PostCommand(PlayerID, { "type": "aichat", "message": message });
}
}
/** Return a simple object (using no classes etc) that will be serialized into saved games */
BaseAI.prototype.Serialize = function()
{
return {};
};
/**
* Called after the constructor when loading a saved game, with 'data' being
* whatever Serialize() returned
*/
BaseAI.prototype.Deserialize = function(data, sharedScript)
{
};
BaseAI.prototype.Init = function(playerID, sharedAI)
{
PlayerID = playerID;
this.territoryMap = sharedAI.territoryMap;
this.accessibility = sharedAI.accessibility;
this.gameState = sharedAI.gameState[this.player];
this.gameState.ai = this;
this.timeElapsed = sharedAI.timeElapsed;
this.CustomInit(this.gameState);
};
/** AIs override this function */
BaseAI.prototype.CustomInit = function()
{
};
BaseAI.prototype.HandleMessage = function(state, playerID, sharedAI)
{
PlayerID = playerID;
this.territoryMap = sharedAI.territoryMap;
this.OnUpdate(sharedAI);
};
/** AIs override this function */
BaseAI.prototype.OnUpdate = function()
{
};
BaseAI.prototype.chat = function(message)
{
Engine.PostCommand(PlayerID, { "type": "aichat", "message": message });
};

View file

@ -1,20 +0,0 @@
/**
* Provides a nicer syntax for defining classes,
* with support for OO-style inheritance.
*/
export function Class(data)
{
let ctor;
if (data._init)
ctor = data._init;
else
ctor = function() { };
if (data._super)
ctor.prototype = { "__proto__": data._super.prototype };
for (const key in data)
ctor.prototype[key] = data[key];
return ctor;
}

File diff suppressed because it is too large Load diff

View file

@ -1,368 +1,371 @@
import { SquareVectorDistance } from "simulation/ai/common-api/utils.js";
export function EntityCollection(sharedAI, entities = new Map(), filters = [])
export class EntityCollection
{
this._ai = sharedAI;
this._entities = entities;
this._filters = filters;
this.dynamicProp = [];
for (const filter of this._filters)
if (filter.dynamicProperties.length)
this.dynamicProp = this.dynamicProp.concat(filter.dynamicProperties);
Object.defineProperty(this, "length", { "get": () => this._entities.size });
this.frozen = false;
}
EntityCollection.prototype.Serialize = function()
{
const filters = [];
for (const f of this._filters)
filters.push(uneval(f));
return {
"ents": this.toIdArray(),
"frozen": this.frozen,
"filters": filters
};
};
EntityCollection.prototype.Deserialize = function(data, sharedAI)
{
this._ai = sharedAI;
for (const id of data.ents)
this._entities.set(id, sharedAI._entities.get(id));
for (const f of data.filters)
this._filters.push(eval(f));
if (data.frozen)
this.freeze();
else
this.defreeze();
};
/**
* If an entitycollection is frozen, it will never automatically add a unit.
* But can remove one.
* this makes it easy to create entity collection that will auto-remove dead units
* but never add new ones.
*/
EntityCollection.prototype.freeze = function()
{
this.frozen = true;
};
EntityCollection.prototype.defreeze = function()
{
this.frozen = false;
};
EntityCollection.prototype.toIdArray = function()
{
return Array.from(this._entities.keys());
};
EntityCollection.prototype.toEntityArray = function()
{
return Array.from(this._entities.values());
};
EntityCollection.prototype.values = function()
{
return this._entities.values();
};
EntityCollection.prototype.toString = function()
{
return "[EntityCollection " + this.toEntityArray().join(" ") + "]";
};
EntityCollection.prototype.filter = function(filter, thisp)
{
if (typeof filter === "function")
filter = { "func": filter, "dynamicProperties": [] };
const ret = new Map();
for (const [id, ent] of this._entities)
if (filter.func.call(thisp, ent, id, this))
ret.set(id, ent);
return new EntityCollection(this._ai, ret, this._filters.concat([filter]));
};
/**
* Returns the (at most) n entities nearest to targetPos.
*/
EntityCollection.prototype.filterNearest = function(targetPos, n)
{
// Compute the distance of each entity
const data = []; // [ [id, ent, distance], ... ]
for (const [id, ent] of this._entities)
if (ent.position())
data.push([id, ent, SquareVectorDistance(targetPos, ent.position())]);
// Sort by increasing distance
data.sort((a, b) => a[2] - b[2]);
if (n === undefined)
n = data.length;
else
n = Math.min(n, data.length);
// Extract the first n
const ret = new Map();
for (let i = 0; i < n; ++i)
ret.set(data[i][0], data[i][1]);
return new EntityCollection(this._ai, ret);
};
EntityCollection.prototype.filter_raw = function(callback, thisp)
{
const ret = new Map();
for (const [id, ent] of this._entities)
constructor(sharedAI, entities = new Map(), filters = [])
{
const val = ent._entity;
if (callback.call(thisp, val, id, this))
ret.set(id, ent);
this._ai = sharedAI;
this._entities = entities;
this._filters = filters;
this.dynamicProp = [];
for (const filter of this._filters)
if (filter.dynamicProperties.length)
this.dynamicProp = this.dynamicProp.concat(filter.dynamicProperties);
Object.defineProperty(this, "length", { "get": () => this._entities.size });
this.frozen = false;
}
return new EntityCollection(this._ai, ret);
};
EntityCollection.prototype.forEach = function(callback)
{
for (const ent of this._entities.values())
callback(ent);
return this;
};
Serialize()
{
const filters = [];
for (const f of this._filters)
filters.push(uneval(f));
return {
"ents": this.toIdArray(),
"frozen": this.frozen,
"filters": filters
};
}
EntityCollection.prototype.hasEntities = function()
{
return this._entities.size !== 0;
};
Deserialize(data, sharedAI)
{
this._ai = sharedAI;
for (const id of data.ents)
this._entities.set(id, sharedAI._entities.get(id));
EntityCollection.prototype.move = function(x, z, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "walk",
"entities": this.toIdArray(),
"x": x,
"z": z,
"queued": queued,
"pushFront": pushFront
});
return this;
};
for (const f of data.filters)
this._filters.push(eval(f));
EntityCollection.prototype.moveToRange = function(x, z, min, max, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "walk-to-range",
"entities": this.toIdArray(),
"x": x,
"z": z,
"min": min,
"max": max,
"queued": queued,
"pushFront": pushFront
});
return this;
};
if (data.frozen)
this.freeze();
else
this.defreeze();
}
EntityCollection.prototype.attackMove = function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "attack-walk",
"entities": this.toIdArray(),
"x": x,
"z": z,
"targetClasses": targetClasses,
"allowCapture": allowCapture,
"queued": queued,
"pushFront": pushFront
});
return this;
};
/**
* If an entitycollection is frozen, it will never automatically add a unit.
* But can remove one.
* this makes it easy to create entity collection that will auto-remove dead units
* but never add new ones.
*/
freeze()
{
this.frozen = true;
}
EntityCollection.prototype.moveIndiv = function(x, z, queued = false, pushFront = false)
{
for (const id of this._entities.keys())
defreeze()
{
this.frozen = false;
}
toIdArray()
{
return Array.from(this._entities.keys());
}
toEntityArray()
{
return Array.from(this._entities.values());
}
values()
{
return this._entities.values();
}
toString()
{
return "[EntityCollection " + this.toEntityArray().join(" ") + "]";
}
filter(filter, thisp)
{
if (typeof filter === "function")
filter = { "func": filter, "dynamicProperties": [] };
const ret = new Map();
for (const [id, ent] of this._entities)
if (filter.func.call(thisp, ent, id, this))
ret.set(id, ent);
return new EntityCollection(this._ai, ret, this._filters.concat([filter]));
}
/**
* Returns the (at most) n entities nearest to targetPos.
*/
filterNearest(targetPos, n)
{
// Compute the distance of each entity
const data = []; // [ [id, ent, distance], ... ]
for (const [id, ent] of this._entities)
if (ent.position())
data.push([id, ent, SquareVectorDistance(targetPos, ent.position())]);
// Sort by increasing distance
data.sort((a, b) => a[2] - b[2]);
if (n === undefined)
n = data.length;
else
n = Math.min(n, data.length);
// Extract the first n
const ret = new Map();
for (let i = 0; i < n; ++i)
ret.set(data[i][0], data[i][1]);
return new EntityCollection(this._ai, ret);
}
filter_raw(callback, thisp)
{
const ret = new Map();
for (const [id, ent] of this._entities)
{
const val = ent._entity;
if (callback.call(thisp, val, id, this))
ret.set(id, ent);
}
return new EntityCollection(this._ai, ret);
}
forEach(callback)
{
for (const ent of this._entities.values())
callback(ent);
return this;
}
hasEntities()
{
return this._entities.size !== 0;
}
move(x, z, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "walk",
"entities": [id],
"entities": this.toIdArray(),
"x": x,
"z": z,
"queued": queued,
"pushFront": pushFront
});
return this;
};
EntityCollection.prototype.garrison = function(target, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "garrison",
"entities": this.toIdArray(),
"target": target.id(),
"queued": queued,
"pushFront": pushFront
});
return this;
};
EntityCollection.prototype.occupyTurret = function(target, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "occupy-turret",
"entities": this.toIdArray(),
"target": target.id(),
"queued": queued,
"pushFront": pushFront
});
return this;
};
EntityCollection.prototype.destroy = function()
{
Engine.PostCommand(PlayerID, { "type": "delete-entities", "entities": this.toIdArray() });
return this;
};
EntityCollection.prototype.attack = function(unitId, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "attack",
"entities": this.toIdArray(),
"target": unitId,
"queued": queued,
"pushFront": pushFront
});
return this;
};
/** violent, aggressive, defensive, passive, standground */
EntityCollection.prototype.setStance = function(stance)
{
Engine.PostCommand(PlayerID, {
"type": "stance",
"entities": this.toIdArray(),
"name": stance
});
return this;
};
/** Returns the average position of all units */
EntityCollection.prototype.getCentrePosition = function()
{
const sumPos = [0, 0];
let count = 0;
for (const ent of this._entities.values())
{
if (!ent.position())
continue;
sumPos[0] += ent.position()[0];
sumPos[1] += ent.position()[1];
count++;
return this;
}
return count ? [sumPos[0]/count, sumPos[1]/count] : undefined;
};
/**
* returns the average position from the sample first units.
* This might be faster for huge collections, but there's
* always a risk that it'll be unprecise.
*/
EntityCollection.prototype.getApproximatePosition = function(sample)
{
const sumPos = [0, 0];
let i = 0;
for (const ent of this._entities.values())
moveToRange(x, z, min, max, queued = false, pushFront = false)
{
if (!ent.position())
continue;
sumPos[0] += ent.position()[0];
sumPos[1] += ent.position()[1];
i++;
if (i === sample)
break;
Engine.PostCommand(PlayerID, {
"type": "walk-to-range",
"entities": this.toIdArray(),
"x": x,
"z": z,
"min": min,
"max": max,
"queued": queued,
"pushFront": pushFront
});
return this;
}
return i ? [sumPos[0]/i, sumPos[1]/i] : undefined;
};
EntityCollection.prototype.hasEntId = function(id)
{
return this._entities.has(id);
};
/** Removes an entity from the collection, returns true if the entity was a member, false otherwise */
EntityCollection.prototype.removeEnt = function(ent)
{
if (!this._entities.has(ent.id()))
return false;
this._entities.delete(ent.id());
return true;
};
/** Adds an entity to the collection, returns true if the entity was not member, false otherwise */
EntityCollection.prototype.addEnt = function(ent)
{
if (this._entities.has(ent.id()))
return false;
this._entities.set(ent.id(), ent);
const temp = this.toEntityArray();
temp.sort((a, b) => a.id() - b.id());
this._entities.clear();
for (const e of temp)
this._entities.set(e.id(), e);
return true;
};
/**
* Checks the entity against the filters, and adds or removes it appropriately, returns true if the
* entity collection was modified.
* Force can add a unit despite a freezing.
* If an entitycollection is frozen, it will never automatically add a unit.
* But can remove one.
*/
EntityCollection.prototype.updateEnt = function(ent, force)
{
let passesFilters = true;
for (const filter of this._filters)
passesFilters = passesFilters && filter.func(ent);
if (passesFilters)
attackMove(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false)
{
if (!force && this.frozen)
Engine.PostCommand(PlayerID, {
"type": "attack-walk",
"entities": this.toIdArray(),
"x": x,
"z": z,
"targetClasses": targetClasses,
"allowCapture": allowCapture,
"queued": queued,
"pushFront": pushFront
});
return this;
}
moveIndiv(x, z, queued = false, pushFront = false)
{
for (const id of this._entities.keys())
Engine.PostCommand(PlayerID, {
"type": "walk",
"entities": [id],
"x": x,
"z": z,
"queued": queued,
"pushFront": pushFront
});
return this;
}
garrison(target, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "garrison",
"entities": this.toIdArray(),
"target": target.id(),
"queued": queued,
"pushFront": pushFront
});
return this;
}
occupyTurret(target, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "occupy-turret",
"entities": this.toIdArray(),
"target": target.id(),
"queued": queued,
"pushFront": pushFront
});
return this;
}
destroy()
{
Engine.PostCommand(PlayerID, { "type": "delete-entities", "entities": this.toIdArray() });
return this;
}
attack(unitId, queued = false, pushFront = false)
{
Engine.PostCommand(PlayerID, {
"type": "attack",
"entities": this.toIdArray(),
"target": unitId,
"queued": queued,
"pushFront": pushFront
});
return this;
}
/** violent, aggressive, defensive, passive, standground */
setStance(stance)
{
Engine.PostCommand(PlayerID, {
"type": "stance",
"entities": this.toIdArray(),
"name": stance
});
return this;
}
/** Returns the average position of all units */
getCentrePosition()
{
const sumPos = [0, 0];
let count = 0;
for (const ent of this._entities.values())
{
if (!ent.position())
continue;
sumPos[0] += ent.position()[0];
sumPos[1] += ent.position()[1];
count++;
}
return count ? [sumPos[0]/count, sumPos[1]/count] : undefined;
}
/**
* returns the average position from the sample first units.
* This might be faster for huge collections, but there's
* always a risk that it'll be unprecise.
*/
getApproximatePosition(sample)
{
const sumPos = [0, 0];
let i = 0;
for (const ent of this._entities.values())
{
if (!ent.position())
continue;
sumPos[0] += ent.position()[0];
sumPos[1] += ent.position()[1];
i++;
if (i === sample)
break;
}
return i ? [sumPos[0]/i, sumPos[1]/i] : undefined;
}
hasEntId(id)
{
return this._entities.has(id);
}
/** Removes an entity from the collection, returns true if the entity was a member, false otherwise */
removeEnt(ent)
{
if (!this._entities.has(ent.id()))
return false;
return this.addEnt(ent);
this._entities.delete(ent.id());
return true;
}
return this.removeEnt(ent);
};
/** Adds an entity to the collection, returns true if the entity was not member, false otherwise */
addEnt(ent)
{
if (this._entities.has(ent.id()))
return false;
this._entities.set(ent.id(), ent);
const temp = this.toEntityArray();
temp.sort((a, b) => a.id() - b.id());
this._entities.clear();
for (const e of temp)
this._entities.set(e.id(), e);
return true;
}
EntityCollection.prototype.registerUpdates = function()
{
this._ai.registerUpdatingEntityCollection(this);
};
/**
* Checks the entity against the filters, and adds or removes it appropriately, returns true if the
* entity collection was modified.
* Force can add a unit despite a freezing.
* If an entitycollection is frozen, it will never automatically add a unit.
* But can remove one.
*/
updateEnt(ent, force)
{
let passesFilters = true;
for (const filter of this._filters)
passesFilters = passesFilters && filter.func(ent);
EntityCollection.prototype.unregister = function()
{
this._ai.removeUpdatingEntityCollection(this);
};
if (passesFilters)
{
if (!force && this.frozen)
return false;
return this.addEnt(ent);
}
EntityCollection.prototype.dynamicProperties = function()
{
return this.dynamicProp;
};
return this.removeEnt(ent);
}
EntityCollection.prototype.setUID = function(id)
{
this._UID = id;
};
registerUpdates()
{
this._ai.registerUpdatingEntityCollection(this);
}
EntityCollection.prototype.getUID = function()
{
return this._UID;
};
unregister()
{
this._ai.removeUpdatingEntityCollection(this);
}
dynamicProperties()
{
return this.dynamicProp;
}
setUID(id)
{
this._UID = id;
}
getUID()
{
return this._UID;
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,211 +2,213 @@
* The map module.
* Copied with changes from QuantumState's original for qBot, it's a component for storing 8 bit values.
*/
export function InfoMap(sharedScript, type, originalMap, actualCopy)
export class InfoMap
{
// get the correct dimensions according to the map type
const map = type == "territory" || type == "resource" ? sharedScript.territoryMap : sharedScript.passabilityMap;
this.width = map.width;
this.height = map.height;
this.cellSize = map.cellSize;
this.length = this.width * this.height;
this.maxVal = 255;
// sanity check
if (originalMap && originalMap.length != this.length)
warn("AI map size incompatibility with type " + type + ": original " + originalMap.length + " new " + this.length);
if (originalMap && actualCopy)
constructor(sharedScript, type, originalMap, actualCopy)
{
// get the correct dimensions according to the map type
const map = type == "territory" || type == "resource" ? sharedScript.territoryMap : sharedScript.passabilityMap;
this.width = map.width;
this.height = map.height;
this.cellSize = map.cellSize;
this.length = this.width * this.height;
this.maxVal = 255;
// sanity check
if (originalMap && originalMap.length != this.length)
warn("AI map size incompatibility with type " + type + ": original " + originalMap.length + " new " + this.length);
if (originalMap && actualCopy)
{
this.map = new Uint8Array(this.length);
for (let i = 0; i < this.length; ++i)
this.map[i] = originalMap[i];
}
else if (originalMap)
this.map = originalMap;
else
this.map = new Uint8Array(this.length);
}
setMaxVal(val)
{
this.maxVal = val;
}
gamePosToMapPos(p)
{
return [Math.floor(p[0]/this.cellSize), Math.floor(p[1]/this.cellSize)];
}
point(p)
{
const q = this.gamePosToMapPos(p);
q[0] = q[0] >= this.width ? this.width-1 : q[0] < 0 ? 0 : q[0];
q[1] = q[1] >= this.width ? this.width-1 : q[1] < 0 ? 0 : q[1];
return this.map[q[0] + this.width * q[1]];
}
runLoop(x0, x1, y0, y1, cx, cy, maxDist2, func)
{
for (let y = y0; y < y1; ++y)
{
const dy2 = (y - cy) * (y - cy);
const yw = y * this.width;
for (let x = x0; x < x1; ++x)
{
const dx = x - cx;
const r2 = dx * dx + dy2;
if (r2 >= maxDist2)
continue;
const w = x + yw;
this.set(w, func(w, r2));
}
}
}
addInfluence(cx, cy, maxDist, strength, type = "linear")
{
strength = strength ? strength : maxDist;
const x0 = Math.floor(Math.max(0, cx - maxDist));
const y0 = Math.floor(Math.max(0, cy - maxDist));
const x1 = Math.floor(Math.min(this.width-1, cx + maxDist));
const y1 = Math.floor(Math.min(this.height-1, cy + maxDist));
const maxDist2 = maxDist * maxDist;
if (type == "linear")
{
const str = strength / maxDist;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] + str * (maxDist - Math.sqrt(r2)));
}
else if (type == "quadratic")
{
const str = strength / maxDist2;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] + str * (maxDist2 - r2));
}
else
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] + strength);
}
multiplyInfluence(cx, cy, maxDist, strength, type = "constant")
{
strength = strength ? +strength : +maxDist;
const x0 = Math.max(0, cx - maxDist);
const y0 = Math.max(0, cy - maxDist);
const x1 = Math.min(this.width, cx + maxDist);
const y1 = Math.min(this.height, cy + maxDist);
const maxDist2 = maxDist * maxDist;
if (type == "linear")
{
const str = strength / maxDist;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => str * (maxDist - Math.sqrt(r2)) * this.map[w]);
}
else if (type == "quadratic")
{
const str = strength / maxDist2;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => str * (maxDist2 - r2) * this.map[w]);
}
else
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] * strength);
}
/** add to current map by the parameter map pixelwise */
add(map)
{
this.map = new Uint8Array(this.length);
for (let i = 0; i < this.length; ++i)
this.map[i] = originalMap[i];
this.set(i, this.map[i] + map.map[i]);
}
else if (originalMap)
this.map = originalMap;
else
this.map = new Uint8Array(this.length);
}
InfoMap.prototype.setMaxVal = function(val)
{
this.maxVal = val;
};
InfoMap.prototype.gamePosToMapPos = function(p)
{
return [Math.floor(p[0]/this.cellSize), Math.floor(p[1]/this.cellSize)];
};
InfoMap.prototype.point = function(p)
{
const q = this.gamePosToMapPos(p);
q[0] = q[0] >= this.width ? this.width-1 : q[0] < 0 ? 0 : q[0];
q[1] = q[1] >= this.width ? this.width-1 : q[1] < 0 ? 0 : q[1];
return this.map[q[0] + this.width * q[1]];
};
InfoMap.prototype.runLoop = function(x0, x1, y0, y1, cx, cy, maxDist2, func)
{
for (let y = y0; y < y1; ++y)
/** Set the value taking overflow into account */
set(i, value)
{
const dy2 = (y - cy) * (y - cy);
const yw = y * this.width;
for (let x = x0; x < x1; ++x)
this.map[i] = value < 0 ? 0 : value > this.maxVal ? this.maxVal : value;
}
/** Find the best non-obstructed tile */
findBestTile(radius, obstruction)
{
let bestIdx;
let bestVal = 0;
for (let j = 0; j < this.length; ++j)
{
const dx = x - cx;
const r2 = dx * dx + dy2;
if (r2 >= maxDist2)
if (this.map[j] <= bestVal)
continue;
const w = x + yw;
this.set(w, func(w, r2));
const i = this.getNonObstructedTile(j, radius, obstruction);
if (i < 0)
continue;
bestVal = this.map[j];
bestIdx = i;
}
}
};
InfoMap.prototype.addInfluence = function(cx, cy, maxDist, strength, type = "linear")
{
strength = strength ? strength : maxDist;
const x0 = Math.floor(Math.max(0, cx - maxDist));
const y0 = Math.floor(Math.max(0, cy - maxDist));
const x1 = Math.floor(Math.min(this.width-1, cx + maxDist));
const y1 = Math.floor(Math.min(this.height-1, cy + maxDist));
const maxDist2 = maxDist * maxDist;
if (type == "linear")
{
const str = strength / maxDist;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] + str * (maxDist - Math.sqrt(r2)));
}
else if (type == "quadratic")
{
const str = strength / maxDist2;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] + str * (maxDist2 - r2));
}
else
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] + strength);
};
InfoMap.prototype.multiplyInfluence = function(cx, cy, maxDist, strength, type = "constant")
{
strength = strength ? +strength : +maxDist;
const x0 = Math.max(0, cx - maxDist);
const y0 = Math.max(0, cy - maxDist);
const x1 = Math.min(this.width, cx + maxDist);
const y1 = Math.min(this.height, cy + maxDist);
const maxDist2 = maxDist * maxDist;
if (type == "linear")
{
const str = strength / maxDist;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => str * (maxDist - Math.sqrt(r2)) * this.map[w]);
}
else if (type == "quadratic")
{
const str = strength / maxDist2;
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => str * (maxDist2 - r2) * this.map[w]);
}
else
this.runLoop(x0, x1, y0, y1, cx, cy, maxDist2, (w, r2) => this.map[w] * strength);
};
/** add to current map by the parameter map pixelwise */
InfoMap.prototype.add = function(map)
{
for (let i = 0; i < this.length; ++i)
this.set(i, this.map[i] + map.map[i]);
};
/** Set the value taking overflow into account */
InfoMap.prototype.set = function(i, value)
{
this.map[i] = value < 0 ? 0 : value > this.maxVal ? this.maxVal : value;
};
/** Find the best non-obstructed tile */
InfoMap.prototype.findBestTile = function(radius, obstruction)
{
let bestIdx;
let bestVal = 0;
for (let j = 0; j < this.length; ++j)
{
if (this.map[j] <= bestVal)
continue;
const i = this.getNonObstructedTile(j, radius, obstruction);
if (i < 0)
continue;
bestVal = this.map[j];
bestIdx = i;
return { "idx": bestIdx, "val": bestVal };
}
return { "idx": bestIdx, "val": bestVal };
};
/** return any non obstructed (small) tile inside the (big) tile i from obstruction map */
InfoMap.prototype.getNonObstructedTile = function(i, radius, obstruction)
{
const ratio = this.cellSize / obstruction.cellSize;
const ix = (i % this.width) * ratio;
const iy = Math.floor(i / this.width) * ratio;
const w = obstruction.width;
const r2 = radius * radius;
let lastPoint;
for (let kx = ix; kx < ix + ratio; ++kx)
/** return any non obstructed (small) tile inside the (big) tile i from obstruction map */
getNonObstructedTile(i, radius, obstruction)
{
if (kx < radius || kx >= w - radius)
continue;
for (let ky = iy; ky < iy + ratio; ++ky)
{
if (ky < radius || ky >= w - radius)
continue;
if (lastPoint && (kx - lastPoint.x)*(kx - lastPoint.x) + (ky - lastPoint.y)*(ky - lastPoint.y) < r2)
continue;
lastPoint = obstruction.isObstructedTile(kx, ky, radius);
if (!lastPoint)
return kx + ky*w;
}
}
return -1;
};
/** return true if the area centered on tile kx-ky and with radius is obstructed */
InfoMap.prototype.isObstructedTile = function(kx, ky, radius)
{
const w = this.width;
if (kx < radius || kx >= w - radius || ky < radius || ky >= w - radius || this.map[kx+ky*w] == 0)
return { "x": kx, "y": ky };
if (!this.pattern || this.pattern[0] != radius)
{
this.pattern = [radius];
const ratio = this.cellSize / obstruction.cellSize;
const ix = (i % this.width) * ratio;
const iy = Math.floor(i / this.width) * ratio;
const w = obstruction.width;
const r2 = radius * radius;
for (let i = 1; i <= radius; ++i)
this.pattern.push(Math.floor(Math.sqrt(r2 - (i-0.5)*(i-0.5)) + 0.5));
}
for (let dy = 0; dy <= radius; ++dy)
{
const dxmax = this.pattern[dy];
const xp = kx + (ky + dy)*w;
const xm = kx + (ky - dy)*w;
for (let dx = 0; dx <= dxmax; ++dx)
let lastPoint;
for (let kx = ix; kx < ix + ratio; ++kx)
{
if (this.map[xp + dx] == 0)
return { "x": kx + dx, "y": ky + dy };
if (this.map[xm + dx] == 0)
return { "x": kx + dx, "y": ky - dy };
if (this.map[xp - dx] == 0)
return { "x": kx - dx, "y": ky + dy };
if (this.map[xm - dx] == 0)
return { "x": kx - dx, "y": ky - dy };
if (kx < radius || kx >= w - radius)
continue;
for (let ky = iy; ky < iy + ratio; ++ky)
{
if (ky < radius || ky >= w - radius)
continue;
if (lastPoint && (kx - lastPoint.x)*(kx - lastPoint.x) + (ky - lastPoint.y)*(ky - lastPoint.y) < r2)
continue;
lastPoint = obstruction.isObstructedTile(kx, ky, radius);
if (!lastPoint)
return kx + ky*w;
}
}
return -1;
}
return null;
};
InfoMap.prototype.dumpIm = function(name = "default.png", threshold = this.maxVal)
{
Engine.DumpImage(name, this.map, this.width, this.height, threshold);
};
/** return true if the area centered on tile kx-ky and with radius is obstructed */
isObstructedTile(kx, ky, radius)
{
const w = this.width;
if (kx < radius || kx >= w - radius || ky < radius || ky >= w - radius || this.map[kx+ky*w] == 0)
return { "x": kx, "y": ky };
if (!this.pattern || this.pattern[0] != radius)
{
this.pattern = [radius];
const r2 = radius * radius;
for (let i = 1; i <= radius; ++i)
this.pattern.push(Math.floor(Math.sqrt(r2 - (i-0.5)*(i-0.5)) + 0.5));
}
for (let dy = 0; dy <= radius; ++dy)
{
const dxmax = this.pattern[dy];
const xp = kx + (ky + dy)*w;
const xm = kx + (ky - dy)*w;
for (let dx = 0; dx <= dxmax; ++dx)
{
if (this.map[xp + dx] == 0)
return { "x": kx + dx, "y": ky + dy };
if (this.map[xm + dx] == 0)
return { "x": kx + dx, "y": ky - dy };
if (this.map[xp - dx] == 0)
return { "x": kx - dx, "y": ky + dy };
if (this.map[xm - dx] == 0)
return { "x": kx - dx, "y": ky - dy };
}
}
return null;
}
dumpIm(name = "default.png", threshold = this.maxVal)
{
Engine.DumpImage(name, this.map, this.width, this.height, threshold);
}
}

View file

@ -1,60 +1,63 @@
Resources = new Resources();
export function ResourcesManager(amounts = {}, population = 0)
export class ResourcesManager
{
for (const key of Resources.GetCodes())
this[key] = amounts[key] || 0;
constructor(amounts = {}, population = 0)
{
for (const key of Resources.GetCodes())
this[key] = amounts[key] || 0;
this.population = population > 0 ? population : 0;
this.population = population > 0 ? population : 0;
}
reset()
{
for (const key of Resources.GetCodes())
this[key] = 0;
this.population = 0;
}
canAfford(that)
{
for (const key of Resources.GetCodes())
if (this[key] < that[key])
return false;
return true;
}
add(that)
{
for (const key of Resources.GetCodes())
this[key] += that[key];
this.population += that.population;
}
subtract(that)
{
for (const key of Resources.GetCodes())
this[key] -= that[key];
this.population += that.population;
}
multiply(n)
{
for (const key of Resources.GetCodes())
this[key] *= n;
this.population *= n;
}
Serialize()
{
const amounts = {};
for (const key of Resources.GetCodes())
amounts[key] = this[key];
return { "amounts": amounts, "population": this.population };
}
Deserialize(data)
{
for (const key in data.amounts)
this[key] = data.amounts[key];
this.population = data.population;
}
}
ResourcesManager.prototype.reset = function()
{
for (const key of Resources.GetCodes())
this[key] = 0;
this.population = 0;
};
ResourcesManager.prototype.canAfford = function(that)
{
for (const key of Resources.GetCodes())
if (this[key] < that[key])
return false;
return true;
};
ResourcesManager.prototype.add = function(that)
{
for (const key of Resources.GetCodes())
this[key] += that[key];
this.population += that.population;
};
ResourcesManager.prototype.subtract = function(that)
{
for (const key of Resources.GetCodes())
this[key] -= that[key];
this.population += that.population;
};
ResourcesManager.prototype.multiply = function(n)
{
for (const key of Resources.GetCodes())
this[key] *= n;
this.population *= n;
};
ResourcesManager.prototype.Serialize = function()
{
const amounts = {};
for (const key of Resources.GetCodes())
amounts[key] = this[key];
return { "amounts": amounts, "population": this.population };
};
ResourcesManager.prototype.Deserialize = function(data)
{
for (const key in data.amounts)
this[key] = data.amounts[key];
this.population = data.population;
};

View file

@ -5,462 +5,451 @@ import { InfoMap } from "simulation/ai/common-api/map-module.js";
import { Accessibility, TerrainAnalysis } from "simulation/ai/common-api/terrain-analysis.js";
/** Shared script handling templates and basic terrain analysis */
export function SharedScript(settings)
export class SharedScript
{
if (!settings)
return;
this._players = Object.keys(settings.players).map(key => settings.players[key]); // TODO SM55 Object.values(settings.players)
this._templates = settings.templates;
this._entityMetadata = {};
for (const player of this._players)
this._entityMetadata[player] = {};
// array of entity collections
this._entityCollections = new Map();
this._entitiesModifications = new Map(); // entities modifications
this._templatesModifications = {}; // template modifications
// each name is a reference to the actual one.
this._entityCollectionsName = new Map();
this._entityCollectionsByDynProp = {};
this._entityCollectionsUID = 0;
}
/** Return a simple object (using no classes etc) that will be serialized into saved games */
SharedScript.prototype.Serialize = function()
{
return {
"players": this._players,
"templatesModifications": this._templatesModifications,
"entitiesModifications": this._entitiesModifications,
"metadata": this._entityMetadata
};
};
/**
* Called after the constructor when loading a saved game, with 'data' being
* whatever Serialize() returned
*/
SharedScript.prototype.Deserialize = function(data)
{
this._players = data.players;
this._templatesModifications = data.templatesModifications;
this._entitiesModifications = data.entitiesModifications;
this._entityMetadata = data.metadata;
this.isDeserialized = true;
};
SharedScript.prototype.GetTemplate = function(name)
{
if (this._templates[name] === undefined)
this._templates[name] = Engine.GetTemplate(name) || null;
return this._templates[name];
};
/**
* Initialize the shared component.
* We need to know the initial state of the game for this, as we will use it.
* This is called right at the end of the map generation.
*/
SharedScript.prototype.init = function(state, deserialization)
{
if (!deserialization)
this._entitiesModifications = new Map();
this.ApplyTemplatesDelta(state);
this.passabilityClasses = state.passabilityClasses;
this.playersData = state.players;
this.timeElapsed = state.timeElapsed;
this.circularMap = state.circularMap;
this.mapSize = state.mapSize;
this.victoryConditions = new Set(state.victoryConditions);
this.alliedVictory = state.alliedVictory;
this.ceasefireActive = state.ceasefireActive;
this.ceasefireTimeRemaining = state.ceasefireTimeRemaining / 1000;
this.passabilityMap = state.passabilityMap;
if (this.mapSize % this.passabilityMap.width !== 0)
error("AI shared component inconsistent sizes: map=" + this.mapSize + " while passability=" + this.passabilityMap.width);
this.passabilityMap.cellSize = this.mapSize / this.passabilityMap.width;
this.territoryMap = state.territoryMap;
if (this.mapSize % this.territoryMap.width !== 0)
error("AI shared component inconsistent sizes: map=" + this.mapSize + " while territory=" + this.territoryMap.width);
this.territoryMap.cellSize = this.mapSize / this.territoryMap.width;
/*
let landPassMap = new Uint8Array(this.passabilityMap.data.length);
let waterPassMap = new Uint8Array(this.passabilityMap.data.length);
let obstructionMaskLand = this.passabilityClasses["default-terrain-only"];
let obstructionMaskWater = this.passabilityClasses["ship-terrain-only"];
for (let i = 0; i < this.passabilityMap.data.length; ++i)
constructor(settings)
{
landPassMap[i] = (this.passabilityMap.data[i] & obstructionMaskLand) ? 0 : 255;
waterPassMap[i] = (this.passabilityMap.data[i] & obstructionMaskWater) ? 0 : 255;
}
Engine.DumpImage("LandPassMap.png", landPassMap, this.passabilityMap.width, this.passabilityMap.height, 255);
Engine.DumpImage("WaterPassMap.png", waterPassMap, this.passabilityMap.width, this.passabilityMap.height, 255);
*/
if (!settings)
return;
this._entities = new Map();
if (state.entities)
for (const id in state.entities)
this._entities.set(+id, new Entity(this, state.entities[id]));
// entity collection updated on create/destroy event.
this.entities = new EntityCollection(this, this._entities);
this._players = Object.keys(settings.players).map(key => settings.players[key]); // TODO SM55 Object.values(settings.players)
this._templates = settings.templates;
// create the terrain analyzer
this.terrainAnalyzer = new TerrainAnalysis();
this.terrainAnalyzer.init(this, state);
this.accessibility = new Accessibility();
this.accessibility.init(state, this.terrainAnalyzer);
this._entityMetadata = {};
for (const player of this._players)
this._entityMetadata[player] = {};
// Resource types: ignore = not used for resource maps
// abundant = abundant resource with small amount each
// sparse = sparse resource, but huge amount each
// The following maps are defined in TerrainAnalysis.js and are used for some building placement (cc, dropsites)
// They are updated by checking for create and destroy events for all resources
this.normalizationFactor = { "abundant": 50, "sparse": 90 };
this.influenceRadius = { "abundant": 36, "sparse": 48 };
this.ccInfluenceRadius = { "abundant": 60, "sparse": 120 };
this.resources = []; // Contains entityIds of all resources in the maps.
this.resourceMaps = {}; // Contains maps showing the density of resources
this.ccResourceMaps = {}; // Contains maps showing the density of resources, optimized for CC placement.
this.createResourceMaps();
this.gameState = {};
for (const player of this._players)
{
this.gameState[player] = new GameState();
this.gameState[player].init(this, state, player);
}
};
/**
* General update of the shared script, before each AI's update
* applies entity deltas, and each gamestate.
*/
SharedScript.prototype.onUpdate = function(state)
{
if (this.isDeserialized)
{
this.init(state, true);
this.isDeserialized = false;
// array of entity collections
this._entityCollections = new Map();
this._entitiesModifications = new Map(); // entities modifications
this._templatesModifications = {}; // template modifications
// each name is a reference to the actual one.
this._entityCollectionsName = new Map();
this._entityCollectionsByDynProp = {};
this._entityCollectionsUID = 0;
}
// deals with updating based on create and destroy messages.
this.ApplyEntitiesDelta(state);
this.ApplyTemplatesDelta(state);
Engine.ProfileStart("onUpdate");
// those are dynamic and need to be reset as the "state" object moves in memory.
this.events = state.events;
this.passabilityClasses = state.passabilityClasses;
this.playersData = state.players;
this.timeElapsed = state.timeElapsed;
this.barterPrices = state.barterPrices;
this.ceasefireActive = state.ceasefireActive;
this.ceasefireTimeRemaining = state.ceasefireTimeRemaining / 1000;
this.passabilityMap = state.passabilityMap;
this.passabilityMap.cellSize = this.mapSize / this.passabilityMap.width;
this.territoryMap = state.territoryMap;
this.territoryMap.cellSize = this.mapSize / this.territoryMap.width;
for (const i in this.gameState)
this.gameState[i].update(this);
// TODO: merge this with "ApplyEntitiesDelta" since after all they do the same.
this.updateResourceMaps(this.events);
Engine.ProfileStop();
};
SharedScript.prototype.ApplyEntitiesDelta = function(state)
{
Engine.ProfileStart("Shared ApplyEntitiesDelta");
const foundationFinished = {};
// by order of updating:
// we "Destroy" last because we want to be able to switch Metadata first.
for (const evt of state.events.Create)
/** Return a simple object (using no classes etc) that will be serialized into saved games */
Serialize()
{
if (!state.entities[evt.entity])
continue; // Sometimes there are things like foundations which get destroyed too fast
const entity = new Entity(this, state.entities[evt.entity]);
this._entities.set(evt.entity, entity);
this.entities.addEnt(entity);
// Update all the entity collections since the create operation affects static properties as well as dynamic
for (const entCol of this._entityCollections.values())
entCol.updateEnt(entity);
return {
"players": this._players,
"templatesModifications": this._templatesModifications,
"entitiesModifications": this._entitiesModifications,
"metadata": this._entityMetadata
};
}
for (const evt of state.events.EntityRenamed)
{ // Switch the metadata: TODO entityCollections are updated only because of the owner change. Should be done properly
/**
* Called after the constructor when loading a saved game, with 'data' being
* whatever Serialize() returned
*/
Deserialize(data)
{
this._players = data.players;
this._templatesModifications = data.templatesModifications;
this._entitiesModifications = data.entitiesModifications;
this._entityMetadata = data.metadata;
this.isDeserialized = true;
}
GetTemplate(name)
{
if (this._templates[name] === undefined)
this._templates[name] = Engine.GetTemplate(name) || null;
return this._templates[name];
}
/**
* Initialize the shared component.
* We need to know the initial state of the game for this, as we will use it.
* This is called right at the end of the map generation.
*/
init(state, deserialization)
{
if (!deserialization)
this._entitiesModifications = new Map();
this.ApplyTemplatesDelta(state);
this.passabilityClasses = state.passabilityClasses;
this.playersData = state.players;
this.timeElapsed = state.timeElapsed;
this.circularMap = state.circularMap;
this.mapSize = state.mapSize;
this.victoryConditions = new Set(state.victoryConditions);
this.alliedVictory = state.alliedVictory;
this.ceasefireActive = state.ceasefireActive;
this.ceasefireTimeRemaining = state.ceasefireTimeRemaining / 1000;
this.passabilityMap = state.passabilityMap;
if (this.mapSize % this.passabilityMap.width !== 0)
error("AI shared component inconsistent sizes: map=" + this.mapSize + " while passability=" + this.passabilityMap.width);
this.passabilityMap.cellSize = this.mapSize / this.passabilityMap.width;
this.territoryMap = state.territoryMap;
if (this.mapSize % this.territoryMap.width !== 0)
error("AI shared component inconsistent sizes: map=" + this.mapSize + " while territory=" + this.territoryMap.width);
this.territoryMap.cellSize = this.mapSize / this.territoryMap.width;
/*
let landPassMap = new Uint8Array(this.passabilityMap.data.length);
let waterPassMap = new Uint8Array(this.passabilityMap.data.length);
let obstructionMaskLand = this.passabilityClasses["default-terrain-only"];
let obstructionMaskWater = this.passabilityClasses["ship-terrain-only"];
for (let i = 0; i < this.passabilityMap.data.length; ++i)
{
landPassMap[i] = (this.passabilityMap.data[i] & obstructionMaskLand) ? 0 : 255;
waterPassMap[i] = (this.passabilityMap.data[i] & obstructionMaskWater) ? 0 : 255;
}
Engine.DumpImage("LandPassMap.png", landPassMap, this.passabilityMap.width, this.passabilityMap.height, 255);
Engine.DumpImage("WaterPassMap.png", waterPassMap, this.passabilityMap.width, this.passabilityMap.height, 255);
*/
this._entities = new Map();
if (state.entities)
for (const id in state.entities)
this._entities.set(+id, new Entity(this, state.entities[id]));
// entity collection updated on create/destroy event.
this.entities = new EntityCollection(this, this._entities);
// create the terrain analyzer
this.terrainAnalyzer = new TerrainAnalysis(this, state);
this.accessibility = new Accessibility(state, this.terrainAnalyzer);
// Resource types: ignore = not used for resource maps
// abundant = abundant resource with small amount each
// sparse = sparse resource, but huge amount each
// The following maps are defined in TerrainAnalysis.js and are used for some building placement (cc, dropsites)
// They are updated by checking for create and destroy events for all resources
this.normalizationFactor = { "abundant": 50, "sparse": 90 };
this.influenceRadius = { "abundant": 36, "sparse": 48 };
this.ccInfluenceRadius = { "abundant": 60, "sparse": 120 };
this.resources = []; // Contains entityIds of all resources in the maps.
this.resourceMaps = {}; // Contains maps showing the density of resources
this.ccResourceMaps = {}; // Contains maps showing the density of resources, optimized for CC placement.
this.createResourceMaps();
this.gameState = {};
for (const player of this._players)
{
this._entityMetadata[player][evt.newentity] = this._entityMetadata[player][evt.entity];
this._entityMetadata[player][evt.entity] = {};
this.gameState[player] = new GameState();
this.gameState[player].init(this, state, player);
}
}
for (const evt of state.events.TrainingFinished)
{ // Apply metadata stored in training queues
for (const entId of evt.entities)
if (this._entities.has(entId))
for (const key in evt.metadata)
this.setMetadata(evt.owner, this._entities.get(entId), key, evt.metadata[key]);
}
for (const evt of state.events.ConstructionFinished)
/**
* General update of the shared script, before each AI's update
* applies entity deltas, and each gamestate.
*/
onUpdate(state)
{
// metada are already moved by EntityRenamed when needed (i.e. construction, not repair)
if (evt.entity != evt.newentity)
foundationFinished[evt.entity] = true;
}
for (const evt of state.events.AIMetadata)
{
if (!this._entities.has(evt.id))
continue; // might happen in some rare cases of foundations getting destroyed, perhaps.
// Apply metadata (here for buildings for example)
for (const key in evt.metadata)
this.setMetadata(evt.owner, this._entities.get(evt.id), key, evt.metadata[key]);
}
for (const evt of state.events.Destroy)
{
if (foundationFinished[evt.entity])
evt.SuccessfulFoundation = true;
// The entity was destroyed but its data may still be useful, so
// remember the AI's metadata concerning it
evt.metadata = {};
for (const player of this._players)
evt.metadata[player] = this._entityMetadata[player][evt.entity];
const entity = this._entities.get(evt.entity);
if (entity)
if (this.isDeserialized)
{
this.init(state, true);
this.isDeserialized = false;
}
// deals with updating based on create and destroy messages.
this.ApplyEntitiesDelta(state);
this.ApplyTemplatesDelta(state);
Engine.ProfileStart("onUpdate");
// those are dynamic and need to be reset as the "state" object moves in memory.
this.events = state.events;
this.passabilityClasses = state.passabilityClasses;
this.playersData = state.players;
this.timeElapsed = state.timeElapsed;
this.barterPrices = state.barterPrices;
this.ceasefireActive = state.ceasefireActive;
this.ceasefireTimeRemaining = state.ceasefireTimeRemaining / 1000;
this.passabilityMap = state.passabilityMap;
this.passabilityMap.cellSize = this.mapSize / this.passabilityMap.width;
this.territoryMap = state.territoryMap;
this.territoryMap.cellSize = this.mapSize / this.territoryMap.width;
for (const i in this.gameState)
this.gameState[i].update(this);
// TODO: merge this with "ApplyEntitiesDelta" since after all they do the same.
this.updateResourceMaps(this.events);
Engine.ProfileStop();
}
ApplyEntitiesDelta(state)
{
Engine.ProfileStart("Shared ApplyEntitiesDelta");
const foundationFinished = {};
// by order of updating:
// we "Destroy" last because we want to be able to switch Metadata first.
for (const evt of state.events.Create)
{
if (!state.entities[evt.entity])
continue; // Sometimes there are things like foundations which get destroyed too fast
const entity = new Entity(this, state.entities[evt.entity]);
this._entities.set(evt.entity, entity);
this.entities.addEnt(entity);
// Update all the entity collections since the create operation affects static properties as well as dynamic
for (const entCol of this._entityCollections.values())
entCol.removeEnt(entity);
this.entities.removeEnt(entity);
entCol.updateEnt(entity);
}
this._entities.delete(evt.entity);
this._entitiesModifications.delete(evt.entity);
for (const player of this._players)
delete this._entityMetadata[player][evt.entity];
}
for (const id in state.entities)
{
const changes = state.entities[id];
const entity = this._entities.get(+id);
for (const prop in changes)
{
entity._entity[prop] = changes[prop];
this.updateEntityCollections(prop, entity);
for (const evt of state.events.EntityRenamed)
{ // Switch the metadata: TODO entityCollections are updated only because of the owner change. Should be done properly
for (const player of this._players)
{
this._entityMetadata[player][evt.newentity] = this._entityMetadata[player][evt.entity];
this._entityMetadata[player][evt.entity] = {};
}
}
}
// apply per-entity aura-related changes.
// this supersedes tech-related changes.
for (const id in state.changedEntityTemplateInfo)
{
if (!this._entities.has(+id))
continue; // dead, presumably.
const changes = state.changedEntityTemplateInfo[id];
if (!this._entitiesModifications.has(+id))
this._entitiesModifications.set(+id, new Map());
const modif = this._entitiesModifications.get(+id);
for (const change of changes)
modif.set(change.variable, change.value);
}
Engine.ProfileStop();
};
for (const evt of state.events.TrainingFinished)
{ // Apply metadata stored in training queues
for (const entId of evt.entities)
if (this._entities.has(entId))
for (const key in evt.metadata)
this.setMetadata(evt.owner, this._entities.get(entId), key, evt.metadata[key]);
}
SharedScript.prototype.ApplyTemplatesDelta = function(state)
{
Engine.ProfileStart("Shared ApplyTemplatesDelta");
for (const player in state.changedTemplateInfo)
{
const playerDiff = state.changedTemplateInfo[player];
for (const template in playerDiff)
for (const evt of state.events.ConstructionFinished)
{
const changes = playerDiff[template];
if (!this._templatesModifications[template])
this._templatesModifications[template] = {};
if (!this._templatesModifications[template][player])
this._templatesModifications[template][player] = new Map();
const modif = this._templatesModifications[template][player];
// metada are already moved by EntityRenamed when needed (i.e. construction, not repair)
if (evt.entity != evt.newentity)
foundationFinished[evt.entity] = true;
}
for (const evt of state.events.AIMetadata)
{
if (!this._entities.has(evt.id))
continue; // might happen in some rare cases of foundations getting destroyed, perhaps.
// Apply metadata (here for buildings for example)
for (const key in evt.metadata)
this.setMetadata(evt.owner, this._entities.get(evt.id), key, evt.metadata[key]);
}
for (const evt of state.events.Destroy)
{
if (foundationFinished[evt.entity])
evt.SuccessfulFoundation = true;
// The entity was destroyed but its data may still be useful, so
// remember the AI's metadata concerning it
evt.metadata = {};
for (const player of this._players)
evt.metadata[player] = this._entityMetadata[player][evt.entity];
const entity = this._entities.get(evt.entity);
if (entity)
{
for (const entCol of this._entityCollections.values())
entCol.removeEnt(entity);
this.entities.removeEnt(entity);
}
this._entities.delete(evt.entity);
this._entitiesModifications.delete(evt.entity);
for (const player of this._players)
delete this._entityMetadata[player][evt.entity];
}
for (const id in state.entities)
{
const changes = state.entities[id];
const entity = this._entities.get(+id);
for (const prop in changes)
{
entity._entity[prop] = changes[prop];
this.updateEntityCollections(prop, entity);
}
}
// apply per-entity aura-related changes.
// this supersedes tech-related changes.
for (const id in state.changedEntityTemplateInfo)
{
if (!this._entities.has(+id))
continue; // dead, presumably.
const changes = state.changedEntityTemplateInfo[id];
if (!this._entitiesModifications.has(+id))
this._entitiesModifications.set(+id, new Map());
const modif = this._entitiesModifications.get(+id);
for (const change of changes)
modif.set(change.variable, change.value);
}
Engine.ProfileStop();
}
this._templatesModifications =
Object.fromEntries(Object.entries(this._templatesModifications).sort());
Engine.ProfileStop();
};
SharedScript.prototype.registerUpdatingEntityCollection = function(entCollection)
{
entCollection.setUID(this._entityCollectionsUID);
this._entityCollections.set(this._entityCollectionsUID, entCollection);
for (const prop of entCollection.dynamicProperties())
ApplyTemplatesDelta(state)
{
if (!this._entityCollectionsByDynProp[prop])
this._entityCollectionsByDynProp[prop] = new Map();
this._entityCollectionsByDynProp[prop].set(this._entityCollectionsUID, entCollection);
Engine.ProfileStart("Shared ApplyTemplatesDelta");
for (const player in state.changedTemplateInfo)
{
const playerDiff = state.changedTemplateInfo[player];
for (const template in playerDiff)
{
const changes = playerDiff[template];
if (!this._templatesModifications[template])
this._templatesModifications[template] = {};
if (!this._templatesModifications[template][player])
this._templatesModifications[template][player] = new Map();
const modif = this._templatesModifications[template][player];
for (const change of changes)
modif.set(change.variable, change.value);
}
}
this._templatesModifications =
Object.fromEntries(Object.entries(this._templatesModifications).sort());
Engine.ProfileStop();
}
this._entityCollectionsUID++;
};
SharedScript.prototype.removeUpdatingEntityCollection = function(entCollection)
{
const uid = entCollection.getUID();
if (this._entityCollections.has(uid))
this._entityCollections.delete(uid);
for (const prop of entCollection.dynamicProperties())
if (this._entityCollectionsByDynProp[prop].has(uid))
this._entityCollectionsByDynProp[prop].delete(uid);
};
SharedScript.prototype.updateEntityCollections = function(property, ent)
{
if (this._entityCollectionsByDynProp[property] === undefined)
return;
for (const entCol of this._entityCollectionsByDynProp[property].values())
entCol.updateEnt(ent);
};
SharedScript.prototype.setMetadata = function(player, ent, key, value)
{
let metadata = this._entityMetadata[player][ent.id()];
if (!metadata)
registerUpdatingEntityCollection(entCollection)
{
this._entityMetadata[player][ent.id()] = {};
metadata = this._entityMetadata[player][ent.id()];
entCollection.setUID(this._entityCollectionsUID);
this._entityCollections.set(this._entityCollectionsUID, entCollection);
for (const prop of entCollection.dynamicProperties())
{
if (!this._entityCollectionsByDynProp[prop])
this._entityCollectionsByDynProp[prop] = new Map();
this._entityCollectionsByDynProp[prop].set(this._entityCollectionsUID, entCollection);
}
this._entityCollectionsUID++;
}
metadata[key] = value;
this.updateEntityCollections('metadata', ent);
this.updateEntityCollections('metadata.' + key, ent);
};
removeUpdatingEntityCollection(entCollection)
{
const uid = entCollection.getUID();
SharedScript.prototype.getMetadata = function(player, ent, key)
{
return this._entityMetadata[player][ent.id()]?.[key];
};
if (this._entityCollections.has(uid))
this._entityCollections.delete(uid);
SharedScript.prototype.deleteMetadata = function(player, ent, key)
{
const metadata = this._entityMetadata[player][ent.id()];
for (const prop of entCollection.dynamicProperties())
if (this._entityCollectionsByDynProp[prop].has(uid))
this._entityCollectionsByDynProp[prop].delete(uid);
}
if (!metadata || !(key in metadata))
updateEntityCollections(property, ent)
{
if (this._entityCollectionsByDynProp[property] === undefined)
return;
for (const entCol of this._entityCollectionsByDynProp[property].values())
entCol.updateEnt(ent);
}
setMetadata(player, ent, key, value)
{
let metadata = this._entityMetadata[player][ent.id()];
if (!metadata)
{
this._entityMetadata[player][ent.id()] = {};
metadata = this._entityMetadata[player][ent.id()];
}
metadata[key] = value;
this.updateEntityCollections('metadata', ent);
this.updateEntityCollections('metadata.' + key, ent);
}
getMetadata(player, ent, key)
{
return this._entityMetadata[player][ent.id()]?.[key];
}
deleteMetadata(player, ent, key)
{
const metadata = this._entityMetadata[player][ent.id()];
if (!metadata || !(key in metadata))
return true;
metadata[key] = undefined;
delete metadata[key];
this.updateEntityCollections('metadata', ent);
this.updateEntityCollections('metadata.' + key, ent);
return true;
metadata[key] = undefined;
delete metadata[key];
this.updateEntityCollections('metadata', ent);
this.updateEntityCollections('metadata.' + key, ent);
return true;
};
}
export function copyPrototype(descendant, parent)
{
const sConstructor = parent.toString();
const aMatch = sConstructor.match(/\s*function (.*)\(/);
/** creates a map of resource density */
createResourceMaps()
{
for (const resource of Resources.GetCodes())
{
if (this.resourceMaps[resource] ||
!(Resources.GetResource(resource).aiAnalysisInfluenceGroup in this.normalizationFactor))
continue;
// We're creating them 8-bit. Things could go above 255 if there are really tons of resources
// But at that point the precision is not really important anyway. And it saves memory.
this.resourceMaps[resource] = new InfoMap(this, "resource");
this.ccResourceMaps[resource] = new InfoMap(this, "resource");
}
for (const ent of this._entities.values())
this.addEntityToResourceMap(ent);
}
if (aMatch != null)
descendant.prototype[aMatch[1]] = parent;
/**
* @param {Object} events - The events from a turn.
*/
updateResourceMaps(events)
{
if (events.Destroy.some(e => this.resources.includes(e.entity)))
{
this.resources = [];
this.resourceMaps = {};
this.createResourceMaps();
}
for (const p in parent.prototype)
descendant.prototype[p] = parent.prototype[p];
for (const e of events.Create)
if (e.entity && this._entities.has(e.entity))
this.addEntityToResourceMap(this._entities.get(e.entity));
}
/**
* @param {entity} entity - The entity to add to the resource map.
*/
addEntityToResourceMap(entity)
{
this.changeEntityInResourceMapHelper(entity, 1);
this.resources.push(entity.id());
}
/**
* @param {entity} entity - The entity to remove from the resource map.
*/
removeEntityFromResourceMap(entity)
{
this.changeEntityInResourceMapHelper(entity, -1);
}
/**
* @param {entity} ent - The entity to add to the resource map.
*/
changeEntityInResourceMapHelper(ent, multiplication = 1)
{
if (!ent)
return;
const entPos = ent.position();
if (!entPos)
return;
const resource = ent.resourceSupplyType()?.generic;
if (!resource || !this.resourceMaps[resource])
return;
const cellSize = this.resourceMaps[resource].cellSize;
const x = Math.floor(entPos[0] / cellSize);
const y = Math.floor(entPos[1] / cellSize);
const grp = Resources.GetResource(resource).aiAnalysisInfluenceGroup;
const strength = multiplication * ent.resourceSupplyMax() / this.normalizationFactor[grp];
this.resourceMaps[resource].addInfluence(x, y, this.influenceRadius[grp] / cellSize, strength / 2, "constant");
this.resourceMaps[resource].addInfluence(x, y, this.influenceRadius[grp] / cellSize, strength / 2);
this.ccResourceMaps[resource].addInfluence(x, y, this.ccInfluenceRadius[grp] / cellSize, strength, "constant");
}
}
/** creates a map of resource density */
SharedScript.prototype.createResourceMaps = function()
{
for (const resource of Resources.GetCodes())
{
if (this.resourceMaps[resource] ||
!(Resources.GetResource(resource).aiAnalysisInfluenceGroup in this.normalizationFactor))
continue;
// We're creating them 8-bit. Things could go above 255 if there are really tons of resources
// But at that point the precision is not really important anyway. And it saves memory.
this.resourceMaps[resource] = new InfoMap(this, "resource");
this.ccResourceMaps[resource] = new InfoMap(this, "resource");
}
for (const ent of this._entities.values())
this.addEntityToResourceMap(ent);
};
/**
* @param {Object} events - The events from a turn.
*/
SharedScript.prototype.updateResourceMaps = function(events)
{
if (events.Destroy.some(e => this.resources.includes(e.entity)))
{
this.resources = [];
this.resourceMaps = {};
this.createResourceMaps();
}
for (const e of events.Create)
if (e.entity && this._entities.has(e.entity))
this.addEntityToResourceMap(this._entities.get(e.entity));
};
/**
* @param {entity} entity - The entity to add to the resource map.
*/
SharedScript.prototype.addEntityToResourceMap = function(entity)
{
this.changeEntityInResourceMapHelper(entity, 1);
this.resources.push(entity.id());
};
/**
* @param {entity} entity - The entity to remove from the resource map.
*/
SharedScript.prototype.removeEntityFromResourceMap = function(entity)
{
this.changeEntityInResourceMapHelper(entity, -1);
};
/**
* @param {entity} ent - The entity to add to the resource map.
*/
SharedScript.prototype.changeEntityInResourceMapHelper = function(ent, multiplication = 1)
{
if (!ent)
return;
const entPos = ent.position();
if (!entPos)
return;
const resource = ent.resourceSupplyType()?.generic;
if (!resource || !this.resourceMaps[resource])
return;
const cellSize = this.resourceMaps[resource].cellSize;
const x = Math.floor(entPos[0] / cellSize);
const y = Math.floor(entPos[1] / cellSize);
const grp = Resources.GetResource(resource).aiAnalysisInfluenceGroup;
const strength = multiplication * ent.resourceSupplyMax() / this.normalizationFactor[grp];
this.resourceMaps[resource].addInfluence(x, y, this.influenceRadius[grp] / cellSize, strength / 2, "constant");
this.resourceMaps[resource].addInfluence(x, y, this.influenceRadius[grp] / cellSize, strength / 2);
this.ccResourceMaps[resource].addInfluence(x, y, this.ccInfluenceRadius[grp] / cellSize, strength, "constant");
};

View file

@ -1,124 +1,127 @@
LoadModificationTemplates();
/** Wrapper around a technology template */
export function Technology(templateName)
export class Technology
{
this._templateName = templateName;
const template = TechnologyTemplates.Get(templateName);
// check if this is one of two paired technologies.
if (template.partOfPair)
constructor(templateName)
{
const parentTech = TechnologyTemplates.Get(template.partOfPair);
this._pairedWith = parentTech.pair[0] == templateName ? parentTech.pair[1] : parentTech.pair[0];
this._templateName = templateName;
const template = TechnologyTemplates.Get(templateName);
// check if this is one of two paired technologies.
if (template.partOfPair)
{
const parentTech = TechnologyTemplates.Get(template.partOfPair);
this._pairedWith = parentTech.pair[0] == templateName ? parentTech.pair[1] : parentTech.pair[0];
}
// check if it only defines a pair:
this._definesPair = !!template.pair;
this._template = template;
}
// check if it only defines a pair:
this._definesPair = !!template.pair;
this._template = template;
/** returns generic, or specific if civ provided. */
name(civ)
{
if (civ === undefined)
return this._template.genericName;
if (this._template.specificName === undefined || this._template.specificName[civ] === undefined)
return undefined;
return this._template.specificName[civ];
}
pairDef()
{
return this._definesPair;
}
/** in case this defines a pair only, returns the two paired technologies. */
getPairedTechs()
{
if (!this._definesPair)
return undefined;
return this._template.pair.map(name => new Technology(name));
}
pair()
{
return this._template.partOfPair;
}
pairedWith()
{
if (!this._template.partOfPair)
return undefined;
return this._pairedWith;
}
cost(researcher)
{
if (!this._template.cost)
return undefined;
const cost = {};
for (const type in this._template.cost)
{
cost[type] = +this._template.cost[type];
if (researcher)
cost[type] *= researcher.techCostMultiplier(type);
}
return cost;
}
costSum(researcher)
{
const cost = this.cost(researcher);
if (!cost)
return 0;
let ret = 0;
for (const type in cost)
ret += cost[type];
return ret;
}
researchTime()
{
return this._template.researchTime || 0;
}
requirements(civ)
{
return DeriveTechnologyRequirements(this._template, civ);
}
autoResearch()
{
if (!this._template.autoResearch)
return undefined;
return this._template.autoResearch;
}
supersedes()
{
if (!this._template.supersedes)
return undefined;
return this._template.supersedes;
}
modifications()
{
if (!this._template.modifications)
return undefined;
return this._template.modifications;
}
affects()
{
if (!this._template.affects)
return undefined;
return this._template.affects;
}
isAffected(classes)
{
return this._template.affects && this._template.affects.some(affect => MatchesClassList(classes, affect));
}
}
/** returns generic, or specific if civ provided. */
Technology.prototype.name = function(civ)
{
if (civ === undefined)
return this._template.genericName;
if (this._template.specificName === undefined || this._template.specificName[civ] === undefined)
return undefined;
return this._template.specificName[civ];
};
Technology.prototype.pairDef = function()
{
return this._definesPair;
};
/** in case this defines a pair only, returns the two paired technologies. */
Technology.prototype.getPairedTechs = function()
{
if (!this._definesPair)
return undefined;
return this._template.pair.map(name => new Technology(name));
};
Technology.prototype.pair = function()
{
return this._template.partOfPair;
};
Technology.prototype.pairedWith = function()
{
if (!this._template.partOfPair)
return undefined;
return this._pairedWith;
};
Technology.prototype.cost = function(researcher)
{
if (!this._template.cost)
return undefined;
const cost = {};
for (const type in this._template.cost)
{
cost[type] = +this._template.cost[type];
if (researcher)
cost[type] *= researcher.techCostMultiplier(type);
}
return cost;
};
Technology.prototype.costSum = function(researcher)
{
const cost = this.cost(researcher);
if (!cost)
return 0;
let ret = 0;
for (const type in cost)
ret += cost[type];
return ret;
};
Technology.prototype.researchTime = function()
{
return this._template.researchTime || 0;
};
Technology.prototype.requirements = function(civ)
{
return DeriveTechnologyRequirements(this._template, civ);
};
Technology.prototype.autoResearch = function()
{
if (!this._template.autoResearch)
return undefined;
return this._template.autoResearch;
};
Technology.prototype.supersedes = function()
{
if (!this._template.supersedes)
return undefined;
return this._template.supersedes;
};
Technology.prototype.modifications = function()
{
if (!this._template.modifications)
return undefined;
return this._template.modifications;
};
Technology.prototype.affects = function()
{
if (!this._template.affects)
return undefined;
return this._template.affects;
};
Technology.prototype.isAffected = function(classes)
{
return this._template.affects && this._template.affects.some(affect => MatchesClassList(classes, affect));
};

View file

@ -1,5 +1,5 @@
import { InfoMap } from "simulation/ai/common-api/map-module.js";
import { copyPrototype } from "simulation/ai/common-api/shared.js";
import * as terrainStates from "simulation/ai/common-api/terrain-states.js";
/**
* TerrainAnalysis, inheriting from the Map Component.
@ -11,51 +11,41 @@ import { copyPrototype } from "simulation/ai/common-api/shared.js";
* This is intended for use with 8 bit maps for reduced memory usage.
* Upgraded from QuantumState's original TerrainAnalysis for qBot.
*/
export function TerrainAnalysis()
export class TerrainAnalysis extends InfoMap
{
}
copyPrototype(TerrainAnalysis, InfoMap);
TerrainAnalysis.prototype.IMPASSABLE = 0;
/**
* non-passable by land units
*/
TerrainAnalysis.prototype.DEEP_WATER = 200;
/**
* passable by land units and water units
*/
TerrainAnalysis.prototype.SHALLOW_WATER = 201;
/**
* passable by land units
*/
TerrainAnalysis.prototype.LAND = 255;
TerrainAnalysis.prototype.init = function(sharedScript, rawState)
{
const passabilityMap = rawState.passabilityMap;
this.width = passabilityMap.width;
this.height = passabilityMap.height;
this.cellSize = passabilityMap.cellSize;
const obstructionMaskLand = rawState.passabilityClasses["default-terrain-only"];
const obstructionMaskWater = rawState.passabilityClasses["ship-terrain-only"];
const obstructionTiles = new Uint8Array(passabilityMap.data.length);
for (let i = 0; i < passabilityMap.data.length; ++i)
constructor(sharedScript, rawState)
{
obstructionTiles[i] = (passabilityMap.data[i] & obstructionMaskLand) ? this.IMPASSABLE : this.LAND;
const passabilityMap = rawState.passabilityMap;
if (!(passabilityMap.data[i] & obstructionMaskWater) && obstructionTiles[i] === this.IMPASSABLE)
obstructionTiles[i] = this.DEEP_WATER;
else if (!(passabilityMap.data[i] & obstructionMaskWater) && obstructionTiles[i] === this.LAND)
obstructionTiles[i] = this.SHALLOW_WATER;
const obstructionMaskLand = rawState.passabilityClasses["default-terrain-only"];
const obstructionMaskWater = rawState.passabilityClasses["ship-terrain-only"];
const obstructionTiles = new Uint8Array(passabilityMap.data.length);
for (let i = 0; i < passabilityMap.data.length; ++i)
{
obstructionTiles[i] = (passabilityMap.data[i] & obstructionMaskLand) ?
terrainStates.IMPASSABLE : terrainStates.LAND;
if (!(passabilityMap.data[i] & obstructionMaskWater) &&
obstructionTiles[i] === terrainStates.IMPASSABLE)
{
obstructionTiles[i] = terrainStates.DEEP_WATER;
}
else if (!(passabilityMap.data[i] & obstructionMaskWater) &&
obstructionTiles[i] === terrainStates.LAND)
{
obstructionTiles[i] = terrainStates.SHALLOW_WATER;
}
}
super(rawState, "passability", obstructionTiles);
this.width = passabilityMap.width;
this.height = passabilityMap.height;
this.cellSize = passabilityMap.cellSize;
}
this.InfoMap(rawState, "passability", obstructionTiles);
};
}
/**
* Accessibility inherits from TerrainAnalysis
@ -65,321 +55,338 @@ TerrainAnalysis.prototype.init = function(sharedScript, rawState)
* for optimizations it's called after the TerrainAnalyser has finished initializing his map
* so this can use the land regions already.
*/
export function Accessibility()
export class Accessibility extends InfoMap
{
}
copyPrototype(Accessibility, TerrainAnalysis);
Accessibility.prototype.init = function(rawState, terrainAnalyser)
{
this.InfoMap(rawState, "passability", terrainAnalyser.map);
this.landPassMap = new Uint16Array(terrainAnalyser.length);
this.navalPassMap = new Uint16Array(terrainAnalyser.length);
this.maxRegions = 65535;
this.regionSize = [];
this.regionType = []; // "inaccessible", "land" or "water";
// ID of the region associated with an array of region IDs.
this.regionLinks = [];
// initialized to 0, it's more optimized to start at 1 (I'm checking that if it's not 0, then it's already aprt of a region, don't touch);
// However I actually store all unpassable as region 1 (because if I don't, on some maps the toal nb of region is over 256, and it crashes as the mapis 8bit.)
// So start at 2.
this.regionID = 2;
for (let i = 0; i < this.landPassMap.length; ++i)
constructor(rawState, terrainAnalyser)
{
if (this.map[i] !== this.IMPASSABLE)
{ // any non-painted, non-inacessible area.
if (this.landPassMap[i] == 0 && this.floodFill(i, this.regionID, false))
this.regionType[this.regionID++] = "land";
if (this.navalPassMap[i] == 0 && this.floodFill(i, this.regionID, true))
this.regionType[this.regionID++] = "water";
}
else if (this.landPassMap[i] == 0)
{ // any non-painted, inacessible area.
this.floodFill(i, 1, false);
this.floodFill(i, 1, true);
}
}
super(rawState, "passability", terrainAnalyser.map);
this.landPassMap = new Uint16Array(terrainAnalyser.length);
this.navalPassMap = new Uint16Array(terrainAnalyser.length);
this.maxRegions = 65535;
this.regionSize = [];
this.regionType = []; // "inaccessible", "land" or "water";
// ID of the region associated with an array of region IDs.
this.regionLinks = [];
// calculating region links. Regions only touching diagonaly are not linked.
// since we're checking all of them, we'll check from the top left to the bottom right
const w = this.width;
for (let x = 0; x < this.width-1; ++x)
{
for (let y = 0; y < this.height-1; ++y)
// initialized to 0, it's more optimized to start at 1 (I'm checking that if it's not 0, then it's already aprt of a region, don't touch);
// However I actually store all unpassable as region 1 (because if I don't, on some maps the toal nb of region is over 256, and it crashes as the mapis 8bit.)
// So start at 2.
this.regionID = 2;
for (let i = 0; i < this.landPassMap.length; ++i)
{
// checking right.
const thisLID = this.landPassMap[x+y*w];
const thisNID = this.navalPassMap[x+y*w];
const rightLID = this.landPassMap[x+1+y*w];
const rightNID = this.navalPassMap[x+1+y*w];
const bottomLID = this.landPassMap[x+y*w+w];
const bottomNID = this.navalPassMap[x+y*w+w];
if (thisLID > 1)
{
if (rightNID > 1)
if (this.regionLinks[thisLID].indexOf(rightNID) === -1)
this.regionLinks[thisLID].push(rightNID);
if (bottomNID > 1)
if (this.regionLinks[thisLID].indexOf(bottomNID) === -1)
this.regionLinks[thisLID].push(bottomNID);
if (this.map[i] !== terrainStates.IMPASSABLE)
{ // any non-painted, non-inacessible area.
if (this.landPassMap[i] == 0 && this.floodFill(i, this.regionID, false))
this.regionType[this.regionID++] = "land";
if (this.navalPassMap[i] == 0 && this.floodFill(i, this.regionID, true))
this.regionType[this.regionID++] = "water";
}
if (thisNID > 1)
else if (this.landPassMap[i] == 0)
{ // any non-painted, inacessible area.
this.floodFill(i, 1, false);
this.floodFill(i, 1, true);
}
}
// calculating region links. Regions only touching diagonaly are not linked.
// since we're checking all of them, we'll check from the top left to the bottom right
const w = this.width;
for (let x = 0; x < this.width-1; ++x)
{
for (let y = 0; y < this.height-1; ++y)
{
if (rightLID > 1)
if (this.regionLinks[thisNID].indexOf(rightLID) === -1)
this.regionLinks[thisNID].push(rightLID);
if (bottomLID > 1)
if (this.regionLinks[thisNID].indexOf(bottomLID) === -1)
this.regionLinks[thisNID].push(bottomLID);
// checking right.
const thisLID = this.landPassMap[x+y*w];
const thisNID = this.navalPassMap[x+y*w];
const rightLID = this.landPassMap[x+1+y*w];
const rightNID = this.navalPassMap[x+1+y*w];
const bottomLID = this.landPassMap[x+y*w+w];
const bottomNID = this.navalPassMap[x+y*w+w];
if (thisLID > 1)
if (this.regionLinks[thisNID].indexOf(thisLID) === -1)
this.regionLinks[thisNID].push(thisLID);
{
if (rightNID > 1)
if (this.regionLinks[thisLID].indexOf(rightNID) === -1)
this.regionLinks[thisLID].push(rightNID);
if (bottomNID > 1)
if (this.regionLinks[thisLID].indexOf(bottomNID) === -1)
this.regionLinks[thisLID].push(bottomNID);
}
if (thisNID > 1)
{
if (rightLID > 1)
if (this.regionLinks[thisNID].indexOf(rightLID) === -1)
this.regionLinks[thisNID].push(rightLID);
if (bottomLID > 1)
if (this.regionLinks[thisNID].indexOf(bottomLID) === -1)
this.regionLinks[thisNID].push(bottomLID);
if (thisLID > 1)
if (this.regionLinks[thisNID].indexOf(thisLID) === -1)
this.regionLinks[thisNID].push(thisLID);
}
}
}
// Engine.DumpImage("LandPassMap.png", this.landPassMap, this.width, this.height, 255);
// Engine.DumpImage("NavalPassMap.png", this.navalPassMap, this.width, this.height, 255);
}
// Engine.DumpImage("LandPassMap.png", this.landPassMap, this.width, this.height, 255);
// Engine.DumpImage("NavalPassMap.png", this.navalPassMap, this.width, this.height, 255);
};
Accessibility.prototype.getAccessValue = function(position, onWater)
{
const gamePos = this.gamePosToMapPos(position);
if (onWater)
return this.navalPassMap[gamePos[0] + this.width*gamePos[1]];
let ret = this.landPassMap[gamePos[0] + this.width*gamePos[1]];
if (ret === 1)
getAccessValue(position, onWater)
{
// quick spiral search.
const indx = [ [-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1], [1, 0], [1, -1], [0, -1]];
for (const i of indx)
const gamePos = this.gamePosToMapPos(position);
if (onWater)
return this.navalPassMap[gamePos[0] + this.width*gamePos[1]];
let ret = this.landPassMap[gamePos[0] + this.width*gamePos[1]];
if (ret === 1)
{
const id0 = gamePos[0] + i[0];
const id1 = gamePos[1] + i[1];
if (id0 < 0 || id0 >= this.width || id1 < 0 || id1 >= this.width)
continue;
ret = this.landPassMap[id0 + this.width*id1];
if (ret !== 1)
return ret;
}
}
return ret;
};
Accessibility.prototype.getTrajectTo = function(start, end)
{
const pstart = this.gamePosToMapPos(start);
const istart = pstart[0] + pstart[1]*this.width;
const pend = this.gamePosToMapPos(end);
const iend = pend[0] + pend[1]*this.width;
let onLand = true;
if (this.landPassMap[istart] <= 1 && this.navalPassMap[istart] > 1)
onLand = false;
if (this.landPassMap[istart] <= 1 && this.navalPassMap[istart] <= 1)
return false;
let endRegion = this.landPassMap[iend];
if (endRegion <= 1 && this.navalPassMap[iend] > 1)
endRegion = this.navalPassMap[iend];
else if (endRegion <= 1)
return false;
const startRegion = onLand ? this.landPassMap[istart] : this.navalPassMap[istart];
return this.getTrajectToIndex(startRegion, endRegion);
};
/**
* Return a "path" of accessibility indexes from one point to another, including the start and the end indexes
* this can tell you what sea zone you need to have a dock on, for example.
* assumes a land unit unless start point is over deep water.
*/
Accessibility.prototype.getTrajectToIndex = function(istart, iend)
{
if (istart === iend)
return [istart];
const trajects = new Set();
const explored = new Set();
trajects.add([istart]);
explored.add(istart);
while (trajects.size)
{
for (const traj of trajects)
{
const ilast = traj[traj.length-1];
for (const inew of this.regionLinks[ilast])
// quick spiral search.
const indx = [ [-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1], [1, 0], [1, -1], [0, -1]];
for (const i of indx)
{
if (inew === iend)
return traj.concat(iend);
if (explored.has(inew))
const id0 = gamePos[0] + i[0];
const id1 = gamePos[1] + i[1];
if (id0 < 0 || id0 >= this.width || id1 < 0 || id1 >= this.width)
continue;
trajects.add(traj.concat(inew));
explored.add(inew);
ret = this.landPassMap[id0 + this.width*id1];
if (ret !== 1)
return ret;
}
trajects.delete(traj);
}
}
return undefined;
};
Accessibility.prototype.getRegionSize = function(position, onWater)
{
const pos = this.gamePosToMapPos(position);
const index = pos[0] + pos[1]*this.width;
const ID = onWater === true ? this.navalPassMap[index] : this.landPassMap[index];
if (this.regionSize[ID] === undefined)
return 0;
return this.regionSize[ID];
};
Accessibility.prototype.getRegionSizei = function(index, onWater)
{
if (this.regionSize[this.landPassMap[index]] === undefined && (!onWater || this.regionSize[this.navalPassMap[index]] === undefined))
return 0;
if (onWater && this.regionSize[this.navalPassMap[index]] > this.regionSize[this.landPassMap[index]])
return this.regionSize[this.navalPassMap[index]];
return this.regionSize[this.landPassMap[index]];
};
/** Implementation of a fast flood fill. Reasonably good performances for JS. */
Accessibility.prototype.floodFill = function(startIndex, value, onWater)
{
if (value > this.maxRegions)
{
error("AI accessibility map: too many regions.");
this.landPassMap[startIndex] = 1;
this.navalPassMap[startIndex] = 1;
return false;
return ret;
}
if (!onWater && this.landPassMap[startIndex] != 0 || onWater && this.navalPassMap[startIndex] != 0)
return false; // already painted.
let floodFor = "land";
if (this.map[startIndex] === this.IMPASSABLE)
getTrajectTo(start, end)
{
this.landPassMap[startIndex] = 1;
this.navalPassMap[startIndex] = 1;
return false;
const pstart = this.gamePosToMapPos(start);
const istart = pstart[0] + pstart[1]*this.width;
const pend = this.gamePosToMapPos(end);
const iend = pend[0] + pend[1]*this.width;
let onLand = true;
if (this.landPassMap[istart] <= 1 && this.navalPassMap[istart] > 1)
onLand = false;
if (this.landPassMap[istart] <= 1 && this.navalPassMap[istart] <= 1)
return false;
let endRegion = this.landPassMap[iend];
if (endRegion <= 1 && this.navalPassMap[iend] > 1)
endRegion = this.navalPassMap[iend];
else if (endRegion <= 1)
return false;
const startRegion = onLand ? this.landPassMap[istart] : this.navalPassMap[istart];
return this.getTrajectToIndex(startRegion, endRegion);
}
if (onWater === true)
/**
* Return a "path" of accessibility indexes from one point to another, including the start and the end indexes
* this can tell you what sea zone you need to have a dock on, for example.
* assumes a land unit unless start point is over deep water.
*/
getTrajectToIndex(istart, iend)
{
if (this.map[startIndex] !== this.DEEP_WATER && this.map[startIndex] !== this.SHALLOW_WATER)
if (istart === iend)
return [istart];
const trajects = new Set();
const explored = new Set();
trajects.add([istart]);
explored.add(istart);
while (trajects.size)
{
this.navalPassMap[startIndex] = 1; // impassable for naval
return false; // do nothing
for (const traj of trajects)
{
const ilast = traj[traj.length-1];
for (const inew of this.regionLinks[ilast])
{
if (inew === iend)
return traj.concat(iend);
if (explored.has(inew))
continue;
trajects.add(traj.concat(inew));
explored.add(inew);
}
trajects.delete(traj);
}
}
floodFor = "water";
}
else if (this.map[startIndex] === this.DEEP_WATER)
{
this.landPassMap[startIndex] = 1; // impassable for land
return false;
return undefined;
}
// here we'll be able to start.
for (let i = this.regionSize.length; i <= value; ++i)
getRegionSize(position, onWater)
{
this.regionLinks.push([]);
this.regionSize.push(0);
this.regionType.push("inaccessible");
const pos = this.gamePosToMapPos(position);
const index = pos[0] + pos[1]*this.width;
const ID = onWater === true ? this.navalPassMap[index] : this.landPassMap[index];
if (this.regionSize[ID] === undefined)
return 0;
return this.regionSize[ID];
}
const w = this.width;
const h = this.height;
// Get x and y from index
const IndexArray = [startIndex];
let newIndex;
while (IndexArray.length)
getRegionSizei(index, onWater)
{
newIndex = IndexArray.pop();
if (this.regionSize[this.landPassMap[index]] === undefined && (!onWater || this.regionSize[this.navalPassMap[index]] === undefined))
return 0;
if (onWater && this.regionSize[this.navalPassMap[index]] > this.regionSize[this.landPassMap[index]])
return this.regionSize[this.navalPassMap[index]];
return this.regionSize[this.landPassMap[index]];
}
let y = 0;
// vertical iteration
while (true)
/** Implementation of a fast flood fill. Reasonably good performances for JS. */
floodFill(startIndex, value, onWater)
{
if (value > this.maxRegions)
{
--y;
const index = newIndex + w*y;
if (index < 0)
break;
if (floodFor === "land" && this.landPassMap[index] === 0 && this.map[index] !== this.IMPASSABLE && this.map[index] !== this.DEEP_WATER)
continue;
if (floodFor === "water" && this.navalPassMap[index] === 0 && (this.map[index] === this.DEEP_WATER || this.map[index] === this.SHALLOW_WATER))
continue;
break;
} // should actually break
++y;
let reachLeft = false;
let reachRight = false;
let index;
do
error("AI accessibility map: too many regions.");
this.landPassMap[startIndex] = 1;
this.navalPassMap[startIndex] = 1;
return false;
}
if (!onWater && this.landPassMap[startIndex] != 0 || onWater && this.navalPassMap[startIndex] != 0)
return false; // already painted.
let floodFor = "land";
if (this.map[startIndex] === terrainStates.IMPASSABLE)
{
index = newIndex + w*y;
this.landPassMap[startIndex] = 1;
this.navalPassMap[startIndex] = 1;
return false;
}
if (floodFor === "land" && this.landPassMap[index] === 0 && this.map[index] !== this.IMPASSABLE && this.map[index] !== this.DEEP_WATER)
if (onWater === true)
{
if (this.map[startIndex] !== terrainStates.DEEP_WATER &&
this.map[startIndex] !== terrainStates.SHALLOW_WATER)
{
this.landPassMap[index] = value;
this.regionSize[value]++;
this.navalPassMap[startIndex] = 1; // impassable for naval
return false; // do nothing
}
else if (floodFor === "water" && this.navalPassMap[index] === 0 && (this.map[index] === this.DEEP_WATER || this.map[index] === this.SHALLOW_WATER))
floodFor = "water";
}
else if (this.map[startIndex] === terrainStates.DEEP_WATER)
{
this.landPassMap[startIndex] = 1; // impassable for land
return false;
}
// here we'll be able to start.
for (let i = this.regionSize.length; i <= value; ++i)
{
this.regionLinks.push([]);
this.regionSize.push(0);
this.regionType.push("inaccessible");
}
const w = this.width;
const h = this.height;
// Get x and y from index
const IndexArray = [startIndex];
let newIndex;
while (IndexArray.length)
{
newIndex = IndexArray.pop();
let y = 0;
// vertical iteration
while (true)
{
this.navalPassMap[index] = value;
this.regionSize[value]++;
}
else
--y;
const index = newIndex + w*y;
if (index < 0)
break;
if (floodFor === "land" && this.landPassMap[index] === 0 &&
this.map[index] !== terrainStates.IMPASSABLE &&
this.map[index] !== terrainStates.DEEP_WATER)
{
continue;
}
if (floodFor === "water" && this.navalPassMap[index] === 0 &&
(this.map[index] === terrainStates.DEEP_WATER ||
this.map[index] === terrainStates.SHALLOW_WATER))
{
continue;
}
break;
if (index%w > 0)
{
if (floodFor === "land" && this.landPassMap[index -1] === 0 && this.map[index -1] !== this.IMPASSABLE && this.map[index -1] !== this.DEEP_WATER)
{
if (!reachLeft)
{
IndexArray.push(index -1);
reachLeft = true;
}
}
else if (floodFor === "water" && this.navalPassMap[index -1] === 0 && (this.map[index -1] === this.DEEP_WATER || this.map[index -1] === this.SHALLOW_WATER))
{
if (!reachLeft)
{
IndexArray.push(index -1);
reachLeft = true;
}
}
else if (reachLeft)
reachLeft = false;
}
if (index%w < w - 1)
{
if (floodFor === "land" && this.landPassMap[index +1] === 0 && this.map[index +1] !== this.IMPASSABLE && this.map[index +1] !== this.DEEP_WATER)
{
if (!reachRight)
{
IndexArray.push(index +1);
reachRight = true;
}
}
else if (floodFor === "water" && this.navalPassMap[index +1] === 0 && (this.map[index +1] === this.DEEP_WATER || this.map[index +1] === this.SHALLOW_WATER))
{
if (!reachRight)
{
IndexArray.push(index +1);
reachRight = true;
}
}
else if (reachRight)
reachRight = false;
}
} // should actually break
++y;
} while (index/w < h-1); // should actually break
let reachLeft = false;
let reachRight = false;
let index;
do
{
index = newIndex + w*y;
if (floodFor === "land" && this.landPassMap[index] === 0 &&
this.map[index] !== terrainStates.IMPASSABLE &&
this.map[index] !== terrainStates.DEEP_WATER)
{
this.landPassMap[index] = value;
this.regionSize[value]++;
}
else if (floodFor === "water" && this.navalPassMap[index] === 0 &&
(this.map[index] === terrainStates.DEEP_WATER ||
this.map[index] === terrainStates.SHALLOW_WATER))
{
this.navalPassMap[index] = value;
this.regionSize[value]++;
}
else
break;
if (index%w > 0)
{
if (floodFor === "land" && this.landPassMap[index -1] === 0 &&
this.map[index -1] !== terrainStates.IMPASSABLE &&
this.map[index -1] !== terrainStates.DEEP_WATER)
{
if (!reachLeft)
{
IndexArray.push(index -1);
reachLeft = true;
}
}
else if (floodFor === "water" && this.navalPassMap[index -1] === 0 &&
(this.map[index -1] === terrainStates.DEEP_WATER ||
this.map[index -1] === terrainStates.SHALLOW_WATER))
{
if (!reachLeft)
{
IndexArray.push(index -1);
reachLeft = true;
}
}
else if (reachLeft)
reachLeft = false;
}
if (index%w < w - 1)
{
if (floodFor === "land" && this.landPassMap[index +1] === 0 &&
this.map[index +1] !== terrainStates.IMPASSABLE &&
this.map[index +1] !== terrainStates.DEEP_WATER)
{
if (!reachRight)
{
IndexArray.push(index +1);
reachRight = true;
}
}
else if (floodFor === "water" && this.navalPassMap[index +1] === 0 &&
(this.map[index +1] === terrainStates.DEEP_WATER ||
this.map[index +1] === terrainStates.SHALLOW_WATER))
{
if (!reachRight)
{
IndexArray.push(index +1);
reachRight = true;
}
}
else if (reachRight)
reachRight = false;
}
++y;
} while (index/w < h-1); // should actually break
}
return true;
}
return true;
};
}

View file

@ -0,0 +1,17 @@
/* eslint-disable prefer-const -- Mods should be able to change them */
export let IMPASSABLE = 0;
/**
* non-passable by land units
*/
export let DEEP_WATER = 200;
/**
* passable by land units and water units
*/
export let SHALLOW_WATER = 201;
/**
* passable by land units
*/
export let LAND = 255;
/* eslint-enable prefer-const */

View file

@ -6,171 +6,173 @@ import { Queue } from "simulation/ai/petra/queue.js";
import { QueueManager } from "simulation/ai/petra/queueManager.js";
import { gameAnalysis } from "simulation/ai/petra/startingStrategy.js";
export function PetraBot(settings)
export class PetraBot extends BaseAI
{
BaseAI.call(this, settings);
// played turn, because Petra doesn't play every turn.
this.turn = 0;
this.playedTurn = 0;
this.elapsedTime = 0;
this.uniqueIDs = {
"armies": 1, // starts at 1 to allow easier tests on armies ID existence
"bases": 1, // base manager ID starts at one because "0" means "no base" on the map
"plans": 0, // training/building/research plans
"transports": 1 // transport plans start at 1 because 0 might be used as none
};
this.Config = new Config(settings.difficulty, settings.behavior);
this.savedEvents = {};
}
PetraBot.prototype = Object.create(BaseAI.prototype);
PetraBot.prototype.CustomInit = function(gameState)
{
if (this.isDeserialized)
constructor(settings)
{
// WARNING: the deserializations should not modify the metadatas infos inside their init functions
this.canPlay = this.data.canPlay;
this.turn = this.data.turn;
this.playedTurn = this.data.playedTurn;
this.elapsedTime = this.data.elapsedTime;
this.savedEvents = this.data.savedEvents;
super(settings);
// played turn, because Petra doesn't play every turn.
this.turn = 0;
this.playedTurn = 0;
this.elapsedTime = 0;
this.uniqueIDs = {
"armies": 1, // starts at 1 to allow easier tests on armies ID existence
"bases": 1, // base manager ID starts at one because "0" means "no base" on the map
"plans": 0, // training/building/research plans
"transports": 1 // transport plans start at 1 because 0 might be used as none
};
this.Config = new Config(settings.difficulty, settings.behavior);
this.savedEvents = {};
}
CustomInit(gameState)
{
if (this.isDeserialized)
{
// WARNING: the deserializations should not modify the metadatas infos inside their init functions
this.canPlay = this.data.canPlay;
this.turn = this.data.turn;
this.playedTurn = this.data.playedTurn;
this.elapsedTime = this.data.elapsedTime;
this.savedEvents = this.data.savedEvents;
for (const key in this.savedEvents)
{
for (const i in this.savedEvents[key])
{
const evt = this.savedEvents[key][i];
const evtmod = {};
for (const keyevt in evt)
{
evtmod[keyevt] = evt[keyevt];
this.savedEvents[key][i] = evtmod;
}
}
}
this.Config.Deserialize(this.data.config);
this.queueManager = new QueueManager(this.Config, {});
this.queueManager.Deserialize(gameState, this.data.queueManager);
this.queues = this.queueManager.queues;
this.HQ = new Headquarters(this.Config, true);
this.HQ.init(gameState, this.queues);
this.HQ.Deserialize(gameState, this.data.HQ);
this.uniqueIDs = this.data.uniqueIDs;
this.isDeserialized = false;
this.data = undefined;
// initialisation needed after the completion of the deserialization
this.HQ.postinit(gameState);
}
else
{
this.Config.setConfig(gameState);
// this.queues can only be modified by the queue manager or things will go awry.
this.queues = {};
for (const i in this.Config.priorities)
this.queues[i] = new Queue();
this.queueManager = new QueueManager(this.Config, this.queues);
this.HQ = new Headquarters(this.Config, false);
this.HQ.init(gameState, this.queues);
// Try to analyze our starting position and set a strategy.
this.canPlay = gameAnalysis(this.HQ, gameState);
}
}
OnUpdate(sharedScript)
{
if (this.isDeserialized)
this.Init(PlayerID, sharedScript);
if (this.gameFinished || this.gameState.playerData.state == "defeated")
return;
for (const i in sharedScript.events)
{
if (i == "AIMetadata") // not used inside petra
continue;
if (this.savedEvents[i] !== undefined)
this.savedEvents[i] = this.savedEvents[i].concat(sharedScript.events[i]);
else
this.savedEvents[i] = sharedScript.events[i];
}
// Run the update every n turns, offset depending on player ID to balance the load
this.elapsedTime = this.gameState.getTimeElapsed() / 1000;
if (!this.playedTurn || (this.turn + this.player) % 8 == 5)
{
Engine.ProfileStart("PetraBot bot (player " + this.player +")");
this.playedTurn++;
if (!this.canPlay)
{
Engine.ProfileStop();
return;
}
this.HQ.update(this.gameState, this.queues, this.savedEvents);
this.queueManager.update(this.gameState);
for (const i in this.savedEvents)
this.savedEvents[i] = [];
Engine.ProfileStop();
}
this.turn++;
}
Serialize()
{
if (this.isDeserialized)
return this.data;
const savedEvents = {};
for (const key in this.savedEvents)
{
for (const i in this.savedEvents[key])
savedEvents[key] = this.savedEvents[key].slice();
for (const i in savedEvents[key])
{
const evt = this.savedEvents[key][i];
if (!savedEvents[key][i])
continue;
const evt = savedEvents[key][i];
const evtmod = {};
for (const keyevt in evt)
{
evtmod[keyevt] = evt[keyevt];
this.savedEvents[key][i] = evtmod;
}
savedEvents[key][i] = evtmod;
}
}
this.Config.Deserialize(this.data.config);
this.queueManager = new QueueManager(this.Config, {});
this.queueManager.Deserialize(gameState, this.data.queueManager);
this.queues = this.queueManager.queues;
this.HQ = new Headquarters(this.Config, true);
this.HQ.init(gameState, this.queues);
this.HQ.Deserialize(gameState, this.data.HQ);
this.uniqueIDs = this.data.uniqueIDs;
this.isDeserialized = false;
this.data = undefined;
// initialisation needed after the completion of the deserialization
this.HQ.postinit(gameState);
return {
"canPlay": this.canPlay,
"uniqueIDs": this.uniqueIDs,
"turn": this.turn,
"playedTurn": this.playedTurn,
"elapsedTime": this.elapsedTime,
"savedEvents": savedEvents,
"config": this.Config.Serialize(),
"queueManager": this.queueManager.Serialize(),
"HQ": this.HQ.Serialize()
};
}
else
Deserialize(data, sharedScript)
{
this.Config.setConfig(gameState);
// this.queues can only be modified by the queue manager or things will go awry.
this.queues = {};
for (const i in this.Config.priorities)
this.queues[i] = new Queue();
this.queueManager = new QueueManager(this.Config, this.queues);
this.HQ = new Headquarters(this.Config, false);
this.HQ.init(gameState, this.queues);
// Try to analyze our starting position and set a strategy.
this.canPlay = gameAnalysis(this.HQ, gameState);
this.isDeserialized = true;
this.data = data;
}
};
}
PetraBot.prototype.OnUpdate = function(sharedScript)
{
if (this.isDeserialized)
this.Init(PlayerID, sharedScript);
if (this.gameFinished || this.gameState.playerData.state == "defeated")
return;
for (const i in sharedScript.events)
{
if (i == "AIMetadata") // not used inside petra
continue;
if (this.savedEvents[i] !== undefined)
this.savedEvents[i] = this.savedEvents[i].concat(sharedScript.events[i]);
else
this.savedEvents[i] = sharedScript.events[i];
}
// Run the update every n turns, offset depending on player ID to balance the load
this.elapsedTime = this.gameState.getTimeElapsed() / 1000;
if (!this.playedTurn || (this.turn + this.player) % 8 == 5)
{
Engine.ProfileStart("PetraBot bot (player " + this.player +")");
this.playedTurn++;
if (!this.canPlay)
{
Engine.ProfileStop();
return;
}
this.HQ.update(this.gameState, this.queues, this.savedEvents);
this.queueManager.update(this.gameState);
for (const i in this.savedEvents)
this.savedEvents[i] = [];
Engine.ProfileStop();
}
this.turn++;
};
PetraBot.prototype.Serialize = function()
{
if (this.isDeserialized)
return this.data;
const savedEvents = {};
for (const key in this.savedEvents)
{
savedEvents[key] = this.savedEvents[key].slice();
for (const i in savedEvents[key])
{
if (!savedEvents[key][i])
continue;
const evt = savedEvents[key][i];
const evtmod = {};
for (const keyevt in evt)
evtmod[keyevt] = evt[keyevt];
savedEvents[key][i] = evtmod;
}
}
return {
"canPlay": this.canPlay,
"uniqueIDs": this.uniqueIDs,
"turn": this.turn,
"playedTurn": this.playedTurn,
"elapsedTime": this.elapsedTime,
"savedEvents": savedEvents,
"config": this.Config.Serialize(),
"queueManager": this.queueManager.Serialize(),
"HQ": this.HQ.Serialize()
};
};
PetraBot.prototype.Deserialize = function(data, sharedScript)
{
this.isDeserialized = true;
this.data = data;
};

Some files were not shown because too many files have changed in this diff Show more