Compare commits
5
Commits
v1.0.0
...
88ab26dcec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ab26dcec | ||
|
|
0027c8ded0 | ||
|
|
afc49a16e0 | ||
|
|
e1b733ea01 | ||
|
|
c4d6adf8fb |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
mod_rpg_name = Mundo RPG
|
||||||
|
mod_rpg_desc = Mundo de casillas visto desde arriba al estilo de RPG Maker: tres capas de casillas y una de colisiones, movimiento por cuadrícula con WASD, eventos con la tecla de acción E/F, encuentros aleatorios y combates que vuelven al mapa.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_rpg_name = RPG ワールド
|
||||||
|
mod_rpg_desc = RPG Maker 風の見下ろし型タイルワールド:3 つのタイルレイヤーと衝突レイヤー、WASD でのマス目移動、アクションキー E/F のイベント、ランダムエンカウント、マップに戻ってくるバトル。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_rpg_name = RPG 월드
|
||||||
|
mod_rpg_desc = RPG Maker 스타일의 탑다운 타일 월드: 타일 레이어 3개와 충돌 레이어, WASD 격자 이동, 액션 키 E/F 이벤트, 랜덤 인카운터, 맵으로 돌아오는 전투.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = Battle from the map
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = Enemies, comma separated; the win flag is set on victory
|
||||||
|
rpg/schema/field/enemies = Enemies
|
||||||
|
rpg/schema/field/win_flag = Win flag
|
||||||
|
rpg/schema/builtin/rpg_teleport = Teleport on the map
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = A cell and a facing; the map is optional
|
||||||
|
rpg/schema/field/map = Map
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = Facing
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = Combate desde el mapa
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = Enemigos separados por comas; la marca de victoria se pone al ganar
|
||||||
|
rpg/schema/field/enemies = Enemigos
|
||||||
|
rpg/schema/field/win_flag = Marca de victoria
|
||||||
|
rpg/schema/builtin/rpg_teleport = Teletransportar en el mapa
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = Una casilla y una dirección; el mapa es opcional
|
||||||
|
rpg/schema/field/map = Mapa
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = Dirección
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = マップからの戦闘
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = 敵はカンマ区切り。勝つと勝利フラグが立つ
|
||||||
|
rpg/schema/field/enemies = 敵
|
||||||
|
rpg/schema/field/win_flag = 勝利フラグ
|
||||||
|
rpg/schema/builtin/rpg_teleport = マップ上で移動
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = マスと向き。マップは任意
|
||||||
|
rpg/schema/field/map = マップ
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = 向き
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = 맵에서 전투
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = 적은 쉼표로 구분. 이기면 승리 플래그 설정
|
||||||
|
rpg/schema/field/enemies = 적
|
||||||
|
rpg/schema/field/win_flag = 승리 플래그
|
||||||
|
rpg/schema/builtin/rpg_teleport = 맵에서 이동
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = 칸과 방향. 맵은 선택
|
||||||
|
rpg/schema/field/map = 맵
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = 방향
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = Бой с карты
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = Враги через запятую; флаг победы ставится при выигрыше
|
||||||
|
rpg/schema/field/enemies = Враги
|
||||||
|
rpg/schema/field/win_flag = Флаг победы
|
||||||
|
rpg/schema/builtin/rpg_teleport = Переместить на карте
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = Клетка и направление; карта — по желанию
|
||||||
|
rpg/schema/field/map = Карта
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = Направление
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = Trận đấu từ bản đồ
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = Kẻ địch cách nhau bằng dấu phẩy; thắng thì đặt cờ chiến thắng
|
||||||
|
rpg/schema/field/enemies = Kẻ địch
|
||||||
|
rpg/schema/field/win_flag = Cờ chiến thắng
|
||||||
|
rpg/schema/builtin/rpg_teleport = Dịch chuyển trên bản đồ
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = Ô và hướng; bản đồ tùy chọn
|
||||||
|
rpg/schema/field/map = Bản đồ
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = Hướng
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rpg/schema/builtin/rpg_battle = 地图上的战斗
|
||||||
|
rpg/schema/builtin/rpg_battle_desc = 敌人以逗号分隔;获胜时设置胜利标记
|
||||||
|
rpg/schema/field/enemies = 敌人
|
||||||
|
rpg/schema/field/win_flag = 胜利标记
|
||||||
|
rpg/schema/builtin/rpg_teleport = 地图传送
|
||||||
|
rpg/schema/builtin/rpg_teleport_desc = 格子和朝向;地图可选
|
||||||
|
rpg/schema/field/map = 地图
|
||||||
|
rpg/schema/field/x = X
|
||||||
|
rpg/schema/field/y = Y
|
||||||
|
rpg/schema/field/dir = 朝向
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_rpg_name = Thế giới RPG
|
||||||
|
mod_rpg_desc = Thế giới ô nhìn từ trên xuống theo phong cách RPG Maker: ba lớp ô và một lớp va chạm, di chuyển theo lưới bằng WASD, sự kiện bằng phím hành động E/F, chạm trán ngẫu nhiên và các trận chiến quay về bản đồ.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_rpg_name = RPG 世界
|
||||||
|
mod_rpg_desc = RPG Maker 风格的俯视瓦片世界:三层瓦片和一层碰撞层,用 WASD 按网格行走,用动作键 E/F 触发事件,随机遇敌,战斗结束后回到地图。
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"test_sources": [
|
"test_sources": [
|
||||||
"tests/*.cs"
|
"tests/*.cs"
|
||||||
],
|
],
|
||||||
"depends": ["shell2d_base>=1", "ui_base>=1", "dialog_base>=1", "turn_battle_base>=2"],
|
"depends": ["shell2d_base>=1", "ui_base>=1", "time_base>=1", "dialog_base>=1", "turn_battle_base>=2"],
|
||||||
"test_suites": [
|
"test_suites": [
|
||||||
{"id": "walk", "activate": ["--itest=rpg_walk"]}
|
{"id": "walk", "activate": ["--itest=rpg_walk"]}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"Actions": [
|
||||||
|
{
|
||||||
|
"Id": "rpg.battle",
|
||||||
|
"Name": "rpg/schema/builtin/rpg_battle",
|
||||||
|
"Description": "rpg/schema/builtin/rpg_battle_desc",
|
||||||
|
"Category": "battle",
|
||||||
|
"Params": [
|
||||||
|
{
|
||||||
|
"Key": "enemies",
|
||||||
|
"Type": "ref_list",
|
||||||
|
"Kind": "character",
|
||||||
|
"Required": true,
|
||||||
|
"Name": "rpg/schema/field/enemies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": "win_flag",
|
||||||
|
"Type": "flag",
|
||||||
|
"Name": "rpg/schema/field/win_flag"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "rpg.teleport",
|
||||||
|
"Name": "rpg/schema/builtin/rpg_teleport",
|
||||||
|
"Description": "rpg/schema/builtin/rpg_teleport_desc",
|
||||||
|
"Category": "world",
|
||||||
|
"Params": [
|
||||||
|
{
|
||||||
|
"Key": "map",
|
||||||
|
"Type": "ref",
|
||||||
|
"Kind": "rpg_map",
|
||||||
|
"Name": "rpg/schema/field/map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": "x",
|
||||||
|
"Type": "int",
|
||||||
|
"Required": true,
|
||||||
|
"Name": "rpg/schema/field/x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": "y",
|
||||||
|
"Type": "int",
|
||||||
|
"Required": true,
|
||||||
|
"Name": "rpg/schema/field/y"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": "dir",
|
||||||
|
"Type": "choice",
|
||||||
|
"Options": [
|
||||||
|
"Up",
|
||||||
|
"Down",
|
||||||
|
"Left",
|
||||||
|
"Right"
|
||||||
|
],
|
||||||
|
"Name": "rpg/schema/field/dir"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
shader_type canvas_item;
|
||||||
|
|
||||||
|
// Light of an RPG map (RpgLighting): the tiles and actors are multiplied by the ambient
|
||||||
|
// colour of the time of day (time_weights blends the four phases like timed_sprite), and
|
||||||
|
// point lights (the player, lit events) add to it. Lights only add up to white, so by day
|
||||||
|
// they are invisible and at night they open circles in the dark. Positions and radii are
|
||||||
|
// in canvas pixels, the space of world_pos below.
|
||||||
|
|
||||||
|
uniform vec4 day_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||||
|
uniform vec4 morning_color : source_color = vec4(0.86, 0.72, 0.84, 1.0);
|
||||||
|
uniform vec4 evening_color : source_color = vec4(0.84, 0.72, 0.92, 1.0);
|
||||||
|
uniform vec4 night_color : source_color = vec4(0.2, 0.26, 0.42, 1.0);
|
||||||
|
uniform vec4 time_weights = vec4(0.0, 1.0, 0.0, 0.0);
|
||||||
|
|
||||||
|
// A map that sets its own ambient (indoors) uses it instead of the time of day.
|
||||||
|
uniform bool use_ambient = false;
|
||||||
|
uniform vec4 ambient_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
uniform vec2 light_positions[16];
|
||||||
|
uniform vec4 light_colors[16];
|
||||||
|
uniform float light_radii[16];
|
||||||
|
uniform int light_count = 0;
|
||||||
|
|
||||||
|
varying vec2 world_pos;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
world_pos = (MODEL_MATRIX * vec4(VERTEX, 0.0, 1.0)).xy;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec4 color = texture(TEXTURE, UV) * COLOR;
|
||||||
|
|
||||||
|
vec3 ambient = use_ambient
|
||||||
|
? ambient_color.rgb
|
||||||
|
: (morning_color * time_weights.x + day_color * time_weights.y
|
||||||
|
+ evening_color * time_weights.z + night_color * time_weights.w).rgb;
|
||||||
|
|
||||||
|
vec3 light = vec3(0.0);
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
if (i >= light_count)
|
||||||
|
break;
|
||||||
|
float k = clamp(1.0 - distance(world_pos, light_positions[i]) / max(light_radii[i], 1.0), 0.0, 1.0);
|
||||||
|
light += light_colors[i].rgb * light_colors[i].a * k * k;
|
||||||
|
}
|
||||||
|
|
||||||
|
COLOR = vec4(color.rgb * min(ambient + light, vec3(1.0)), color.a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Autotiles in the RPG Maker A2 layout: one picture of 2×3 tiles describes a terrain
|
||||||
|
/// (water, a path, a cliff top), and each map cell is assembled from four quarter tiles
|
||||||
|
/// chosen by its eight neighbours — so an author paints "water" and the shores draw
|
||||||
|
/// themselves. A tileset entry becomes an autotile by naming that picture
|
||||||
|
/// (<see cref="RpgTileInfo.Autotile"/>); its index is any number outside the atlas.
|
||||||
|
/// <para/>
|
||||||
|
/// The block in quarter units (4 columns × 6 rows): the top-left tile is the palette
|
||||||
|
/// preview; the top-right tile holds the four inner corners; the 2×2 tiles below are a
|
||||||
|
/// framed patch whose outer ring gives outer corners and edges and whose middle gives the
|
||||||
|
/// fill. For a quarter, the two neighbours along its sides and the one across its corner
|
||||||
|
/// decide: both sides alike and the corner too — fill; both sides alike but not the
|
||||||
|
/// corner — inner corner; only one side alike — the edge along the other; none — the
|
||||||
|
/// outer corner. Cells past the map edge count as alike, so a terrain runs off the map
|
||||||
|
/// without a border. The world (<see cref="RpgAutotileLayer"/>) and the map editor draw
|
||||||
|
/// through <see cref="Quarters"/> and <see cref="Draw"/>, so both show the same cells.
|
||||||
|
/// </summary>
|
||||||
|
public static class RpgAutotiles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Source quarters (in quarter units of the block) for the top-left, top-right,
|
||||||
|
/// bottom-left and bottom-right quarter of a cell, from which neighbours are alike.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector2I[] Quarters(bool up, bool down, bool left, bool right,
|
||||||
|
bool upLeft, bool upRight, bool downLeft, bool downRight)
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
Quarter(up, left, upLeft, new Vector2I(2, 0), left ? 2 : 0, up ? 4 : 2),
|
||||||
|
Quarter(up, right, upRight, new Vector2I(3, 0), right ? 1 : 3, up ? 4 : 2),
|
||||||
|
Quarter(down, left, downLeft, new Vector2I(2, 1), left ? 2 : 0, down ? 3 : 5),
|
||||||
|
Quarter(down, right, downRight, new Vector2I(3, 1), right ? 1 : 3, down ? 3 : 5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The quarters of the cell (<paramref name="x"/>, <paramref name="y"/>) of
|
||||||
|
/// <paramref name="layer"/>, whose tile is the autotile <paramref name="index"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector2I[] QuartersAt(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
|
||||||
|
{
|
||||||
|
return Quarters(
|
||||||
|
Alike(grid, layer, x, y - 1, index), Alike(grid, layer, x, y + 1, index),
|
||||||
|
Alike(grid, layer, x - 1, y, index), Alike(grid, layer, x + 1, y, index),
|
||||||
|
Alike(grid, layer, x - 1, y - 1, index), Alike(grid, layer, x + 1, y - 1, index),
|
||||||
|
Alike(grid, layer, x - 1, y + 1, index), Alike(grid, layer, x + 1, y + 1, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pixel rectangle of a quarter in the block picture of a tileset with <paramref name="tileSize"/>.</summary>
|
||||||
|
public static Rect2 SourceRect(Vector2I quarter, int tileSize)
|
||||||
|
{
|
||||||
|
var half = tileSize / 2f;
|
||||||
|
return new Rect2(quarter.X * half, quarter.Y * half, half, half);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draws a cell of an autotile into <paramref name="cell"/> on <paramref name="target"/>.</summary>
|
||||||
|
public static void Draw(CanvasItem target, Texture2D block, Rect2 cell, Vector2I[] quarters, int tileSize, Color modulate)
|
||||||
|
{
|
||||||
|
var half = cell.Size / 2f;
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
var offset = new Vector2(i % 2 * half.X, i / 2 * half.Y);
|
||||||
|
target.DrawTextureRectRegion(block, new Rect2(cell.Position + offset, half), SourceRect(quarters[i], tileSize), modulate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Source rectangle of the block's preview tile (its top-left tile), for palettes.</summary>
|
||||||
|
public static Rect2 PreviewRect(int tileSize) => new(0, 0, tileSize, tileSize);
|
||||||
|
|
||||||
|
private static Vector2I Quarter(bool sideA, bool sideB, bool corner, Vector2I inner, int column, int row)
|
||||||
|
{
|
||||||
|
if (sideA && sideB && !corner)
|
||||||
|
return inner;
|
||||||
|
return new Vector2I(column, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Alike(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
|
||||||
|
=> !grid.InBounds(x, y) || grid.GetTile(layer, x, y) == index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One page of a map event, as in RPG Maker: the event's look and effect while the
|
||||||
|
/// page's <see cref="Condition"/> holds. Every field left out (null) is the event's
|
||||||
|
/// own; an empty <see cref="Sprite"/>/<see cref="Icon"/> clears it. The last page whose
|
||||||
|
/// condition holds is the active one; the event's own fields are page 0 and count only
|
||||||
|
/// while the event's own condition holds (<see cref="RpgEventPages"/>).
|
||||||
|
/// </summary>
|
||||||
|
public class RpgEventPage
|
||||||
|
{
|
||||||
|
/// <summary>Scenario condition of the page; empty = always.</summary>
|
||||||
|
public string Condition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Charset of the page ("" = none).</summary>
|
||||||
|
public string Sprite { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Atlas tile of the page (-1 = none).</summary>
|
||||||
|
public int? Tile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Item icon of the page ("" = none).</summary>
|
||||||
|
public string Icon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Facing the event turns to when the page becomes active.</summary>
|
||||||
|
public RpgDirection? Dir { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How the page starts.</summary>
|
||||||
|
public RpgEventTrigger? Trigger { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the page blocks walking.</summary>
|
||||||
|
public bool? Solid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scenario file of the page.</summary>
|
||||||
|
public string Scenario { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Inline scenario lines of the page.</summary>
|
||||||
|
public List<string> Commands { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Teleport of the page.</summary>
|
||||||
|
public RpgTeleport Teleport { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Autonomous movement of the page.</summary>
|
||||||
|
public RpgMoveRoute Route { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Light of the page (a lamp lit by a page).</summary>
|
||||||
|
public RpgLight Light { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the active page of an event and builds the event as that page shows it. Page
|
||||||
|
/// index 0 is the event's own fields (active while the event's own condition holds),
|
||||||
|
/// 1… are <see cref="RpgMapEvent.Pages"/>; the last page whose condition holds wins, -1
|
||||||
|
/// means none holds and the event is hidden. Pure: the caller passes the condition check.
|
||||||
|
/// </summary>
|
||||||
|
public static class RpgEventPages
|
||||||
|
{
|
||||||
|
/// <summary>The index of the active page, or -1.</summary>
|
||||||
|
public static int ActiveIndex(RpgMapEvent mapEvent, Func<string, bool> holds)
|
||||||
|
{
|
||||||
|
if (mapEvent == null)
|
||||||
|
return -1;
|
||||||
|
var pages = mapEvent.Pages;
|
||||||
|
if (pages != null)
|
||||||
|
{
|
||||||
|
for (var i = pages.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (pages[i] != null && Holds(pages[i].Condition, holds))
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Holds(mapEvent.Condition, holds) ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The event as page <paramref name="index"/> shows it: the event itself for 0, else a
|
||||||
|
/// copy with the page's fields over the event's. Null for an index out of range.
|
||||||
|
/// </summary>
|
||||||
|
public static RpgMapEvent Effective(RpgMapEvent mapEvent, int index)
|
||||||
|
{
|
||||||
|
if (mapEvent == null || index < 0)
|
||||||
|
return null;
|
||||||
|
if (index == 0)
|
||||||
|
return mapEvent;
|
||||||
|
if (mapEvent.Pages == null || index > mapEvent.Pages.Count || mapEvent.Pages[index - 1] == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var page = mapEvent.Pages[index - 1];
|
||||||
|
var hasLook = page.Sprite != null || page.Tile.HasValue || page.Icon != null;
|
||||||
|
return new RpgMapEvent
|
||||||
|
{
|
||||||
|
Id = mapEvent.Id,
|
||||||
|
X = mapEvent.X,
|
||||||
|
Y = mapEvent.Y,
|
||||||
|
// A page that names any look replaces the whole look, so a chest page with an
|
||||||
|
// Icon does not keep the closed chest's Tile underneath it.
|
||||||
|
Sprite = hasLook ? page.Sprite : mapEvent.Sprite,
|
||||||
|
Tile = hasLook ? page.Tile ?? -1 : mapEvent.Tile,
|
||||||
|
Icon = hasLook ? page.Icon : mapEvent.Icon,
|
||||||
|
Dir = page.Dir ?? mapEvent.Dir,
|
||||||
|
Trigger = page.Trigger ?? mapEvent.Trigger,
|
||||||
|
Solid = page.Solid ?? (hasLook ? null : mapEvent.Solid),
|
||||||
|
Scenario = page.Scenario ?? mapEvent.Scenario,
|
||||||
|
Commands = page.Commands ?? mapEvent.Commands,
|
||||||
|
Teleport = page.Teleport ?? mapEvent.Teleport,
|
||||||
|
Route = page.Route ?? mapEvent.Route,
|
||||||
|
Light = page.Light ?? mapEvent.Light,
|
||||||
|
Condition = page.Condition,
|
||||||
|
ModId = mapEvent.ModId,
|
||||||
|
File = mapEvent.File,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Holds(string condition, Func<string, bool> holds)
|
||||||
|
=> string.IsNullOrWhiteSpace(condition) || (holds != null && holds(condition));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A light an event gives off (a lamp, a fire, a lit window), drawn by
|
||||||
|
/// <see cref="RpgLighting"/>. It brightens the cells around the event towards their
|
||||||
|
/// own colours, so it shows at dusk and at night and is lost in daylight.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgLight
|
||||||
|
{
|
||||||
|
/// <summary>Reach in cells from the event's centre; the light fades to nothing there.</summary>
|
||||||
|
public float Radius { get; set; } = 3f;
|
||||||
|
|
||||||
|
/// <summary>Colour (<c>#rrggbb</c>, optionally <c>#rrggbbaa</c>: alpha is the strength).</summary>
|
||||||
|
public string Color { get; set; } = "#ffd8a0";
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-3
@@ -16,7 +16,10 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <para/>
|
/// <para/>
|
||||||
/// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by
|
/// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by
|
||||||
/// <see cref="RpgMapGrid"/>; a dependent mod replaces a whole map by id, the
|
/// <see cref="RpgMapGrid"/>; a dependent mod replaces a whole map by id, the
|
||||||
/// usual entity override.
|
/// usual entity override. The map file holds only the ground: what stands on it
|
||||||
|
/// (NPCs, doors, chests, pickups) are events in <c>map_events/<id>/</c> of any
|
||||||
|
/// mod, merged by <see cref="RpgMapEventResolver"/>, so a mod adds to another
|
||||||
|
/// mod's map without replacing it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[EntityKind("rpg_map")]
|
[EntityKind("rpg_map")]
|
||||||
public class RpgMap : IIdentifiable
|
public class RpgMap : IIdentifiable
|
||||||
@@ -49,10 +52,14 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Where the player appears when entering the map without a teleport target.</summary>
|
/// <summary>Where the player appears when entering the map without a teleport target.</summary>
|
||||||
public RpgTeleport Spawn { get; set; } = new();
|
public RpgTeleport Spawn { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>Events placed on the map.</summary>
|
|
||||||
public List<RpgMapEvent> Events { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Random battles; null for a safe map.</summary>
|
/// <summary>Random battles; null for a safe map.</summary>
|
||||||
public RpgEncounterTable Encounters { get; set; }
|
public RpgEncounterTable Encounters { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixed light colour of the map (<c>#rrggbb</c>), e.g. a lit room; null = the time of day
|
||||||
|
/// lights the map (<see cref="RpgLighting"/>).
|
||||||
|
/// </summary>
|
||||||
|
public string Ambient { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+35
-3
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Cthangover.Mods.Rpg
|
namespace Cthangover.Mods.Rpg
|
||||||
{
|
{
|
||||||
@@ -7,7 +8,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and
|
/// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and
|
||||||
/// what it does are independent:
|
/// what it does are independent:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>look — a charset <see cref="Sprite"/>, or one atlas <see cref="Tile"/>, or nothing (an invisible trigger);</item>
|
/// <item>look — a charset <see cref="Sprite"/>, or one atlas <see cref="Tile"/>, or an item <see cref="Icon"/>, or nothing (an invisible trigger);</item>
|
||||||
/// <item>effect — a <see cref="Teleport"/>, handled natively, or a dialog built
|
/// <item>effect — a <see cref="Teleport"/>, handled natively, or a dialog built
|
||||||
/// from a <see cref="Scenario"/> file followed by inline <see cref="Commands"/>
|
/// from a <see cref="Scenario"/> file followed by inline <see cref="Commands"/>
|
||||||
/// (the full scenario DSL, so <c>action rpg.battle …</c>, <c>flag …</c>, quests).</item>
|
/// (the full scenario DSL, so <c>action rpg.battle …</c>, <c>flag …</c>, quests).</item>
|
||||||
@@ -15,6 +16,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// A <see cref="Condition"/> in the scenario condition language hides the
|
/// A <see cref="Condition"/> in the scenario condition language hides the
|
||||||
/// event while false; it is re-checked whenever game state changes, so a
|
/// event while false; it is re-checked whenever game state changes, so a
|
||||||
/// defeated boss (<c>flag boss_dead 1</c>) disappears at once.
|
/// defeated boss (<c>flag boss_dead 1</c>) disappears at once.
|
||||||
|
/// <para/>
|
||||||
|
/// Events are stored apart from their map, in <c>map_events/<map>/*.json</c>
|
||||||
|
/// of any mod (<see cref="RpgMapEventResolver"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RpgMapEvent
|
public class RpgMapEvent
|
||||||
{
|
{
|
||||||
@@ -33,13 +37,19 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none.</summary>
|
/// <summary>Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none.</summary>
|
||||||
public int Tile { get; set; } = -1;
|
public int Tile { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picture in the <c>items</c> group (what an item's <c>Sprite</c> names), drawn fitted into the
|
||||||
|
/// cell when the event has neither a sprite nor a tile: an item lying on the ground.
|
||||||
|
/// </summary>
|
||||||
|
public string Icon { get; set; }
|
||||||
|
|
||||||
/// <summary>Initial facing of a sprite.</summary>
|
/// <summary>Initial facing of a sprite.</summary>
|
||||||
public RpgDirection Dir { get; set; } = RpgDirection.Down;
|
public RpgDirection Dir { get; set; } = RpgDirection.Down;
|
||||||
|
|
||||||
/// <summary>When the event fires.</summary>
|
/// <summary>When the event fires.</summary>
|
||||||
public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action;
|
public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action;
|
||||||
|
|
||||||
/// <summary>The event occupies its cell. Null means "solid when it is visible" (a sprite or a tile).</summary>
|
/// <summary>The event occupies its cell. Null means "solid when it is visible" (a sprite, a tile or an icon).</summary>
|
||||||
public bool? Solid { get; set; }
|
public bool? Solid { get; set; }
|
||||||
|
|
||||||
/// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary>
|
/// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary>
|
||||||
@@ -54,7 +64,29 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Scenario condition; the event exists only while it is true.</summary>
|
/// <summary>Scenario condition; the event exists only while it is true.</summary>
|
||||||
public string Condition { get; set; }
|
public string Condition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Further pages (RPG Maker's event pages): the last one whose condition holds replaces
|
||||||
|
/// the event's look and effect; with pages, a false own <see cref="Condition"/> only
|
||||||
|
/// turns off page 0 (<see cref="RpgEventPages"/>).
|
||||||
|
/// </summary>
|
||||||
|
public List<RpgEventPage> Pages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Autonomous movement (<see cref="RpgMoveRoute"/>); null = the event stands still.</summary>
|
||||||
|
public RpgMoveRoute Route { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Light the event gives off (<see cref="RpgLight"/>); null = none.</summary>
|
||||||
|
public RpgLight Light { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mod the event was read from (set by the event resolver, not stored).</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string ModId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mod-relative path of the file the event was read from (set by the event resolver, not stored).</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string File { get; set; }
|
||||||
|
|
||||||
/// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary>
|
/// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary>
|
||||||
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0);
|
[JsonIgnore]
|
||||||
|
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0 || !string.IsNullOrEmpty(Icon));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One file of map events, <c>map_events/<map id>/<any name>.json</c>:
|
||||||
|
/// <c>{"Events": [ … ]}</c>. The folder names the map, so a file holds the events
|
||||||
|
/// of one map, and a mod may split them over as many files as it likes.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMapEventFile
|
||||||
|
{
|
||||||
|
/// <summary>The events of the file, in drawing order.</summary>
|
||||||
|
public List<RpgMapEvent> Events { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the event list of every map from event files read in mod load order.
|
||||||
|
/// A new id is appended; an id already present (ignoring case) is replaced in
|
||||||
|
/// place, so the later file wins and keeps the original's drawing order. Mods
|
||||||
|
/// load after their dependencies, so the engine's replacement rule holds:
|
||||||
|
/// ship the same event id and declare <c>depends</c> on the owner to replace
|
||||||
|
/// it, ship a new id to add to any mod's map.
|
||||||
|
/// <para/>
|
||||||
|
/// Pure data, so the merge rules are tested without a mod set.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMapEventMerge
|
||||||
|
{
|
||||||
|
/// <summary>Events per map id (case-insensitive).</summary>
|
||||||
|
public Dictionary<string, List<RpgMapEvent>> Maps { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the events of one file of <paramref name="modId"/> for map <paramref name="map"/>;
|
||||||
|
/// each event gets <see cref="RpgMapEvent.ModId"/> and <see cref="RpgMapEvent.File"/>.
|
||||||
|
/// Returns the ids that replaced an earlier event.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Add(string map, string modId, string file, IEnumerable<RpgMapEvent> events)
|
||||||
|
{
|
||||||
|
var replaced = new List<string>();
|
||||||
|
if (!Maps.TryGetValue(map, out var list))
|
||||||
|
{
|
||||||
|
list = new List<RpgMapEvent>();
|
||||||
|
Maps[map] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var mapEvent in events ?? Array.Empty<RpgMapEvent>())
|
||||||
|
{
|
||||||
|
if (mapEvent == null || string.IsNullOrWhiteSpace(mapEvent.Id))
|
||||||
|
continue;
|
||||||
|
mapEvent.ModId = modId;
|
||||||
|
mapEvent.File = file;
|
||||||
|
var index = list.FindIndex(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
list.Add(mapEvent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list[index] = mapEvent;
|
||||||
|
replaced.Add(mapEvent.Id);
|
||||||
|
}
|
||||||
|
return replaced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Collects map events from <c>map_events/<map id>/**/*.json</c> of every
|
||||||
|
/// loaded mod in load order (files of one mod sorted by path), then from the
|
||||||
|
/// studio overlays, and merges them per map with <see cref="RpgMapEventMerge"/>.
|
||||||
|
/// <para/>
|
||||||
|
/// Events live apart from the map file (<c>maps/<id>.json</c> holds only
|
||||||
|
/// tiles, collision, spawn and encounters) so that any mod can put its own NPC,
|
||||||
|
/// chest or quest trigger on another mod's map without replacing the map, and
|
||||||
|
/// so that drawing and placing do not fight over one big file. Events of a map
|
||||||
|
/// no mod defines are kept and simply never shown.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMapEventResolver : ModAssetResolver
|
||||||
|
{
|
||||||
|
/// <summary>Group folder of event files.</summary>
|
||||||
|
public const string Directory = "map_events";
|
||||||
|
|
||||||
|
private Dictionary<string, List<RpgMapEvent>> cache;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "rpg_map_events";
|
||||||
|
|
||||||
|
/// <summary>Events per map id, merged; cached until the next mod reload or <see cref="ClearCache"/>.</summary>
|
||||||
|
public Dictionary<string, List<RpgMapEvent>> Collect()
|
||||||
|
{
|
||||||
|
Context.Initialize();
|
||||||
|
if (cache != null)
|
||||||
|
return cache;
|
||||||
|
|
||||||
|
var merge = new RpgMapEventMerge();
|
||||||
|
foreach (var modId in Context.GetOrderedModIds())
|
||||||
|
CollectMod(Context.Mods[modId].FileProvider, modId, merge);
|
||||||
|
foreach (var overlay in ModOverlays.All)
|
||||||
|
CollectMod(overlay.Value, overlay.Key, merge);
|
||||||
|
|
||||||
|
cache = merge.Maps;
|
||||||
|
GameLogger.Log("RPG", $"map events: {cache.Values.Sum(l => l.Count)} event(s) on {cache.Count} map(s)");
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void ClearCache() => cache = null;
|
||||||
|
|
||||||
|
private void CollectMod(IModFileProvider provider, string modId, RpgMapEventMerge merge)
|
||||||
|
{
|
||||||
|
foreach (var mapDir in provider.ListFiles(Directory).Where(e => e.EndsWith("/")).OrderBy(e => e, System.StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var map = mapDir.TrimEnd('/');
|
||||||
|
map = map.Substring(map.LastIndexOf('/') + 1);
|
||||||
|
var files = new List<string>();
|
||||||
|
ListJson(provider, mapDir.TrimEnd('/'), files);
|
||||||
|
foreach (var file in files.OrderBy(f => f, System.StringComparer.Ordinal))
|
||||||
|
ReadFile(provider, modId, map, file, merge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ListJson(IModFileProvider provider, string dir, List<string> files)
|
||||||
|
{
|
||||||
|
foreach (var entry in provider.ListFiles(dir))
|
||||||
|
{
|
||||||
|
if (entry.EndsWith("/"))
|
||||||
|
ListJson(provider, entry.TrimEnd('/'), files);
|
||||||
|
else if (entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
files.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadFile(IModFileProvider provider, string modId, string map, string file, RpgMapEventMerge merge)
|
||||||
|
{
|
||||||
|
var text = Context.ReadResolvedText(modId, file, provider);
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Deserialize<RpgMapEventFile>(text, JsonOptionsProvider.ReadOptions);
|
||||||
|
foreach (var id in merge.Add(map, modId, file, data?.Events))
|
||||||
|
GameLogger.Log("RPG", $"map '{map}': event '{id}' replaced by mod '{modId}' ({file})");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("RPG", $"map events '{modId}/{file}': broken JSON: {ex.Message}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>Access to the merged map events of <see cref="RpgMapEventResolver"/>.</summary>
|
||||||
|
public static class RpgMapEvents
|
||||||
|
{
|
||||||
|
/// <summary>The events of a map (an empty list when it has none); the list is shared, do not change it.</summary>
|
||||||
|
public static IReadOnlyList<RpgMapEvent> Of(string map)
|
||||||
|
{
|
||||||
|
var maps = ModManager.Instance.GetResolver<RpgMapEventResolver>()?.Collect();
|
||||||
|
return map != null && maps != null && maps.TryGetValue(map, out var list) ? list : new List<RpgMapEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the collected events, so the next <see cref="Of"/> reads the files again (after an editor save).</summary>
|
||||||
|
public static void Invalidate() => ModManager.Instance.GetResolver<RpgMapEventResolver>()?.ClearCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Autonomous movement of an event (RPG Maker's "move route" / "autonomous movement"):
|
||||||
|
/// <c>"Route": { "Steps": ["left", "left", "wait", "right", "right"], "Every": 0.8 }</c>.
|
||||||
|
/// Steps: <c>up</c>, <c>down</c>, <c>left</c>, <c>right</c>, <c>random</c> (a random
|
||||||
|
/// direction), <c>toward</c> / <c>away</c> (relative to the player), <c>wait</c>, and
|
||||||
|
/// <c>face_up</c> … <c>face_right</c> (turn without moving). A step into a blocked cell is
|
||||||
|
/// skipped (the event turns that way). The route pauses while a dialog runs or the player
|
||||||
|
/// cannot walk; positions are not saved, so an event starts from its cell on every visit.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMoveRoute
|
||||||
|
{
|
||||||
|
/// <summary>The steps, repeated from the start when <see cref="Repeat"/> is set.</summary>
|
||||||
|
public List<string> Steps { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Seconds between steps.</summary>
|
||||||
|
public double Every { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>Whether the route starts over after the last step.</summary>
|
||||||
|
public bool Repeat { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One step of an <see cref="RpgMoveRoute"/> resolved for the current positions: a
|
||||||
|
/// direction (null for <c>wait</c> or an unknown word) and whether the event walks or only
|
||||||
|
/// turns. Pure, so routes are tested without a map.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct RpgRouteStep
|
||||||
|
{
|
||||||
|
/// <summary>Creates a step.</summary>
|
||||||
|
public RpgRouteStep(RpgDirection? direction, bool moves)
|
||||||
|
{
|
||||||
|
Direction = direction;
|
||||||
|
Moves = moves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where to walk or turn; null = stand.</summary>
|
||||||
|
public RpgDirection? Direction { get; }
|
||||||
|
|
||||||
|
/// <summary>True for a walk, false for a turn or a wait.</summary>
|
||||||
|
public bool Moves { get; }
|
||||||
|
|
||||||
|
/// <summary>Resolves <paramref name="word"/> (any case) for an event at <paramref name="self"/>.</summary>
|
||||||
|
public static RpgRouteStep Resolve(string word, Vector2I self, Vector2I player, Random random)
|
||||||
|
{
|
||||||
|
var step = (word ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
switch (step)
|
||||||
|
{
|
||||||
|
case "up": return new RpgRouteStep(RpgDirection.Up, true);
|
||||||
|
case "down": return new RpgRouteStep(RpgDirection.Down, true);
|
||||||
|
case "left": return new RpgRouteStep(RpgDirection.Left, true);
|
||||||
|
case "right": return new RpgRouteStep(RpgDirection.Right, true);
|
||||||
|
case "face_up": return new RpgRouteStep(RpgDirection.Up, false);
|
||||||
|
case "face_down": return new RpgRouteStep(RpgDirection.Down, false);
|
||||||
|
case "face_left": return new RpgRouteStep(RpgDirection.Left, false);
|
||||||
|
case "face_right": return new RpgRouteStep(RpgDirection.Right, false);
|
||||||
|
case "random": return new RpgRouteStep((RpgDirection)(random ?? Random.Shared).Next(4), true);
|
||||||
|
case "toward": return new RpgRouteStep(Toward(self, player), true);
|
||||||
|
case "away": return new RpgRouteStep(Toward(self, player) is { } d ? RpgDirections.Opposite(d) : null, true);
|
||||||
|
default: return new RpgRouteStep(null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The direction that shortens the longer axis of the distance to <paramref name="target"/>; null when already there.</summary>
|
||||||
|
public static RpgDirection? Toward(Vector2I self, Vector2I target)
|
||||||
|
{
|
||||||
|
var delta = target - self;
|
||||||
|
if (delta == Vector2I.Zero)
|
||||||
|
return null;
|
||||||
|
if (Math.Abs(delta.X) >= Math.Abs(delta.Y))
|
||||||
|
return delta.X > 0 ? RpgDirection.Right : RpgDirection.Left;
|
||||||
|
return delta.Y > 0 ? RpgDirection.Down : RpgDirection.Up;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,5 +21,14 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// "tree_crown"). Not a locale key: players never see it.
|
/// "tree_crown"). Not a locale key: players never see it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Tag { get; set; }
|
public string Tag { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Light the tile gives off wherever it lies on a map (a lamp, a lit window); null = none.</summary>
|
||||||
|
public RpgLight Light { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picture of an autotile block (RPG Maker A2 layout, see <see cref="RpgAutotiles"/>) in the
|
||||||
|
/// tilesets group; set, the entry is an autotile and <see cref="Index"/> should lie outside the atlas.
|
||||||
|
/// </summary>
|
||||||
|
public string Autotile { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,30 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Per-cell properties; cells without an entry are walkable.</summary>
|
/// <summary>Per-cell properties; cells without an entry are walkable.</summary>
|
||||||
public List<RpgTileInfo> Tiles { get; set; } = new();
|
public List<RpgTileInfo> Tiles { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>The autotile entry with this index (<see cref="RpgTileInfo.Autotile"/> set), or null.</summary>
|
||||||
|
public RpgTileInfo AutotileOf(int index)
|
||||||
|
{
|
||||||
|
if (index < 0)
|
||||||
|
return null;
|
||||||
|
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
|
||||||
|
{
|
||||||
|
if (tile.Index == index && !string.IsNullOrEmpty(tile.Autotile))
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Light of the atlas cell (<see cref="RpgTileInfo.Light"/>), or null.</summary>
|
||||||
|
public RpgLight LightOf(int index)
|
||||||
|
{
|
||||||
|
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
|
||||||
|
{
|
||||||
|
if (tile.Index == index && tile.Light != null)
|
||||||
|
return tile.Light;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary>
|
/// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary>
|
||||||
public bool IsSolid(int index)
|
public bool IsSolid(int index)
|
||||||
{
|
{
|
||||||
|
|||||||
+45
-2
@@ -15,6 +15,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <see cref="StepFinished"/>. The actor's <see cref="Cell"/> changes at the
|
/// <see cref="StepFinished"/>. The actor's <see cref="Cell"/> changes at the
|
||||||
/// start of a step, so two actors never walk into the same cell. The actor
|
/// start of a step, so two actors never walk into the same cell. The actor
|
||||||
/// layer sorts children by Y, which keeps whoever is lower in front.
|
/// layer sorts children by Y, which keeps whoever is lower in front.
|
||||||
|
/// The sprite uses the actor's material (the map's light, set by the view).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class RpgActor : Node2D
|
public partial class RpgActor : Node2D
|
||||||
{
|
{
|
||||||
@@ -42,9 +43,21 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>True while a step is in progress.</summary>
|
/// <summary>True while a step is in progress.</summary>
|
||||||
public bool IsMoving => progress < 1f;
|
public bool IsMoving => progress < 1f;
|
||||||
|
|
||||||
/// <summary>The map event this actor shows; null for the player.</summary>
|
/// <summary>The map event this actor shows (its definition, all pages); null for the player.</summary>
|
||||||
public RpgMapEvent Event { get; }
|
public RpgMapEvent Event { get; }
|
||||||
|
|
||||||
|
/// <summary>The event as its active page shows it (<see cref="RpgEventPages.Effective"/>); null while no page is active.</summary>
|
||||||
|
public RpgMapEvent Current { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Index of the active page (<see cref="RpgEventPages.ActiveIndex"/>); -2 before the first refresh.</summary>
|
||||||
|
public int PageIndex { get; set; } = -2;
|
||||||
|
|
||||||
|
/// <summary>Next step of the active page's route.</summary>
|
||||||
|
public int RouteIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seconds until the next route step.</summary>
|
||||||
|
public double RouteTimer { get; set; }
|
||||||
|
|
||||||
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
|
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
|
||||||
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
|
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
|
||||||
{
|
{
|
||||||
@@ -52,12 +65,21 @@ namespace Cthangover.Mods.Rpg
|
|||||||
Event = mapEvent;
|
Event = mapEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the shown charset, tile or icon (a page change shows another one).</summary>
|
||||||
|
public void ClearLook()
|
||||||
|
{
|
||||||
|
if (sprite == null)
|
||||||
|
return;
|
||||||
|
sprite.QueueFree();
|
||||||
|
sprite = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
|
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
|
||||||
public void UseCharset(Texture2D texture)
|
public void UseCharset(Texture2D texture)
|
||||||
{
|
{
|
||||||
if (texture == null)
|
if (texture == null)
|
||||||
return;
|
return;
|
||||||
sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false };
|
sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false, UseParentMaterial = true };
|
||||||
var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f);
|
var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f);
|
||||||
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
|
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
|
||||||
AddChild(sprite);
|
AddChild(sprite);
|
||||||
@@ -73,12 +95,33 @@ namespace Cthangover.Mods.Rpg
|
|||||||
{
|
{
|
||||||
Texture = atlas,
|
Texture = atlas,
|
||||||
Centered = false,
|
Centered = false,
|
||||||
|
UseParentMaterial = true,
|
||||||
RegionEnabled = true,
|
RegionEnabled = true,
|
||||||
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
|
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
|
||||||
};
|
};
|
||||||
AddChild(sprite);
|
AddChild(sprite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows an item picture fitted into three quarters of the cell and centred on it,
|
||||||
|
/// so an icon of any size lies on the ground like a tile-sized object.
|
||||||
|
/// </summary>
|
||||||
|
public void UseIcon(Texture2D texture)
|
||||||
|
{
|
||||||
|
if (texture == null)
|
||||||
|
return;
|
||||||
|
var fit = tileSize * 0.75f / Mathf.Max(1, Mathf.Max(texture.GetWidth(), texture.GetHeight()));
|
||||||
|
sprite = new Sprite2D
|
||||||
|
{
|
||||||
|
Texture = texture,
|
||||||
|
Position = new Vector2(tileSize, tileSize) / 2f,
|
||||||
|
Scale = new Vector2(fit, fit),
|
||||||
|
TextureFilter = TextureFilterEnum.Linear,
|
||||||
|
UseParentMaterial = true,
|
||||||
|
};
|
||||||
|
AddChild(sprite);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
|
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
|
||||||
public void Place(Vector2I cell, RpgDirection facing)
|
public void Place(Vector2I cell, RpgDirection facing)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The autotile cells of one map layer, drawn as a child of that layer's
|
||||||
|
/// <see cref="TileMapLayer"/> — right above its plain tiles, below the next layer — with
|
||||||
|
/// the layer's material (the map's light). A tile map cannot hold them: each cell is four
|
||||||
|
/// quarters picked from its neighbours (<see cref="RpgAutotiles"/>), so the cells and their
|
||||||
|
/// quarters are worked out once when the map is built and drawn by <c>_Draw</c>, which
|
||||||
|
/// Godot caches until the next redraw.
|
||||||
|
/// </summary>
|
||||||
|
public partial class RpgAutotileLayer : Node2D
|
||||||
|
{
|
||||||
|
private readonly List<(Rect2 Cell, Texture2D Block, Vector2I[] Quarters)> cells = new();
|
||||||
|
private readonly int tileSize;
|
||||||
|
|
||||||
|
/// <summary>Collects the autotile cells of <paramref name="layer"/>; cells whose picture is missing stay empty.</summary>
|
||||||
|
public RpgAutotileLayer(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset, RpgTextures textures)
|
||||||
|
{
|
||||||
|
Name = layer + "Autotiles";
|
||||||
|
UseParentMaterial = true;
|
||||||
|
tileSize = tileset.TileSize;
|
||||||
|
for (var y = 0; y < grid.Height; y++)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < grid.Width; x++)
|
||||||
|
{
|
||||||
|
var index = grid.GetTile(layer, x, y);
|
||||||
|
var info = tileset.AutotileOf(index);
|
||||||
|
var block = info == null ? null : textures.Tileset(info.Autotile);
|
||||||
|
if (block == null)
|
||||||
|
continue;
|
||||||
|
var rect = new Rect2(x * tileSize, y * tileSize, tileSize, tileSize);
|
||||||
|
cells.Add((rect, block, RpgAutotiles.QuartersAt(grid, layer, x, y, index)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of drawn autotile cells.</summary>
|
||||||
|
public int Count => cells.Count;
|
||||||
|
|
||||||
|
/// <summary>Draws every collected cell.</summary>
|
||||||
|
public override void _Draw()
|
||||||
|
{
|
||||||
|
foreach (var (cell, block, quarters) in cells)
|
||||||
|
RpgAutotiles.Draw(this, block, cell, quarters, tileSize, Colors.White);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Mods.Time;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Light of a mounted map. One <c>rpg_lit</c> material is shared by the tile layers and
|
||||||
|
/// every actor (their sprites use the parent's material), so the whole map is lit by the
|
||||||
|
/// same ambient and the same lights.
|
||||||
|
/// <para/>
|
||||||
|
/// The ambient follows the game clock with the phases and cross-fades of the novel
|
||||||
|
/// backgrounds (<see cref="UiLightController.CalculateTimeWeights"/>; dark mode forces night,
|
||||||
|
/// the light switch off keeps day), unless the map fixes its own (<see cref="RpgMap.Ambient"/>,
|
||||||
|
/// a lit room). The lights are the player's own glow, the tiles whose tileset entry has a
|
||||||
|
/// <see cref="RpgLight"/> (lamps and windows light every map they are drawn on, collected
|
||||||
|
/// once per map) and the shown events with a light on their active page. The nearest to the
|
||||||
|
/// player win, at most <see cref="MaxLights"/>. They are pushed every frame in canvas
|
||||||
|
/// pixels, because the view moves with the player and actors walk.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RpgLighting
|
||||||
|
{
|
||||||
|
/// <summary>Lights the shader takes (its arrays' size).</summary>
|
||||||
|
public const int MaxLights = 16;
|
||||||
|
|
||||||
|
/// <summary>Reach of the player's glow in cells.</summary>
|
||||||
|
public const float PlayerRadius = 3.5f;
|
||||||
|
|
||||||
|
private static readonly Color PlayerColor = new(1f, 0.9f, 0.75f, 0.75f);
|
||||||
|
private static readonly Vector4 Day = new(0, 1, 0, 0);
|
||||||
|
private static readonly Vector4 Night = new(0, 0, 0, 1);
|
||||||
|
|
||||||
|
private readonly Vector2[] positions = new Vector2[MaxLights];
|
||||||
|
private readonly Color[] colors = new Color[MaxLights];
|
||||||
|
private readonly float[] radii = new float[MaxLights];
|
||||||
|
private readonly List<(Vector2I Cell, RpgLight Light)> tileLights = new();
|
||||||
|
private readonly List<(Vector2 Centre, RpgLight Light)> candidates = new();
|
||||||
|
|
||||||
|
/// <summary>The shared material; null when the shader is missing (the map is drawn unlit).</summary>
|
||||||
|
public ShaderMaterial Material { get; }
|
||||||
|
|
||||||
|
/// <summary>Creates the material for <paramref name="map"/>, applies its fixed ambient and collects the lit tiles.</summary>
|
||||||
|
public RpgLighting(RpgMap map, RpgMapGrid grid, RpgTileset tileset)
|
||||||
|
{
|
||||||
|
if (tileset != null)
|
||||||
|
tileLights.AddRange(TileLights(grid, tileset));
|
||||||
|
|
||||||
|
var shader = Shaders.Resolve("rpg_lit");
|
||||||
|
if (shader == null)
|
||||||
|
return;
|
||||||
|
Material = new ShaderMaterial { Shader = shader };
|
||||||
|
var ambient = !string.IsNullOrEmpty(map.Ambient) && Color.HtmlIsValid(map.Ambient);
|
||||||
|
Material.SetShaderParameter("use_ambient", ambient);
|
||||||
|
if (ambient)
|
||||||
|
Material.SetShaderParameter("ambient_color", Color.FromHtml(map.Ambient));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ambient weights for an hour 0–24: night in dark mode, day while lighting is
|
||||||
|
/// switched off, otherwise the phase blend of the time shaders.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector4 Weights(float hour, bool darkMode, bool useLight)
|
||||||
|
{
|
||||||
|
if (darkMode)
|
||||||
|
return Night;
|
||||||
|
if (!useLight)
|
||||||
|
return Day;
|
||||||
|
return UiLightController.CalculateTimeWeights(hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cells of <paramref name="grid"/> whose tile (on any layer) gives off light, with that light.</summary>
|
||||||
|
public static List<(Vector2I Cell, RpgLight Light)> TileLights(RpgMapGrid grid, RpgTileset tileset)
|
||||||
|
{
|
||||||
|
var result = new List<(Vector2I, RpgLight)>();
|
||||||
|
for (var y = 0; y < grid.Height; y++)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < grid.Width; x++)
|
||||||
|
{
|
||||||
|
foreach (var layer in new[] { RpgLayer.Back, RpgLayer.Main, RpgLayer.Front })
|
||||||
|
{
|
||||||
|
var light = tileset.LightOf(grid.GetTile(layer, x, y));
|
||||||
|
if (light == null)
|
||||||
|
continue;
|
||||||
|
result.Add((new Vector2I(x, y), light));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pushes the ambient of the current hour and the lights of <paramref name="view"/>.</summary>
|
||||||
|
public void Update(RpgWorldView view, IEnumerable<RpgActor> events)
|
||||||
|
{
|
||||||
|
if (Material == null || view.Player == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var controller = UiLightController.Instance;
|
||||||
|
var hour = GameData.Instance?.Runtime?.Time?.Normalized ?? 12f;
|
||||||
|
Material.SetShaderParameter("time_weights", Weights(hour, controller?.IsDarkMode == true, controller?.IsUseLight != false));
|
||||||
|
|
||||||
|
var half = new Vector2(view.TileSize, view.TileSize) / 2f;
|
||||||
|
candidates.Clear();
|
||||||
|
foreach (var (cell, light) in tileLights)
|
||||||
|
candidates.Add((new Vector2(cell.X, cell.Y) * view.TileSize + half, light));
|
||||||
|
foreach (var actor in events)
|
||||||
|
{
|
||||||
|
if (actor.Visible && actor.Current?.Light != null)
|
||||||
|
candidates.Add((actor.Position + half, actor.Current.Light));
|
||||||
|
}
|
||||||
|
|
||||||
|
var cellPixels = view.TileSize * view.GlobalScale.X;
|
||||||
|
var player = view.Player.Position + half;
|
||||||
|
positions[0] = view.ToGlobal(player);
|
||||||
|
colors[0] = PlayerColor;
|
||||||
|
radii[0] = PlayerRadius * cellPixels;
|
||||||
|
var count = 1;
|
||||||
|
foreach (var (centre, light) in candidates.OrderBy(c => c.Centre.DistanceSquaredTo(player)))
|
||||||
|
{
|
||||||
|
if (count >= MaxLights)
|
||||||
|
break;
|
||||||
|
positions[count] = view.ToGlobal(centre);
|
||||||
|
colors[count] = Color.HtmlIsValid(light.Color ?? "") ? Color.FromHtml(light.Color) : Colors.White;
|
||||||
|
radii[count] = light.Radius * cellPixels;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Material.SetShaderParameter("light_count", count);
|
||||||
|
Material.SetShaderParameter("light_positions", positions);
|
||||||
|
Material.SetShaderParameter("light_colors", colors);
|
||||||
|
Material.SetShaderParameter("light_radii", radii);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// Image lookup of the RPG world: tileset atlases in the <c>tilesets</c> file
|
/// Image lookup of the RPG world: tileset atlases in the <c>tilesets</c> file
|
||||||
/// group, charsets in <c>charsets</c>. Both are file groups (last mod wins),
|
/// group, charsets in <c>charsets</c>. Both are file groups (last mod wins),
|
||||||
/// not texture groups, so their names never collide with backgrounds or
|
/// not texture groups, so their names never collide with backgrounds or
|
||||||
/// icons. Decoded textures are kept for the life of one world, which builds
|
/// icons; item pictures for <see cref="RpgMapEvent.Icon"/> come from the
|
||||||
|
/// <c>items</c> group the inventory uses. Decoded textures are kept for the life of one world, which builds
|
||||||
/// and owns an instance; the editor keeps its own.
|
/// and owns an instance; the editor keeps its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RpgTextures
|
public class RpgTextures
|
||||||
@@ -19,6 +20,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>File group of charset images.</summary>
|
/// <summary>File group of charset images.</summary>
|
||||||
public const string CharsetGroup = "charsets";
|
public const string CharsetGroup = "charsets";
|
||||||
|
|
||||||
|
/// <summary>File group of item pictures (an item's <c>Sprite</c>).</summary>
|
||||||
|
public const string IconGroup = "items";
|
||||||
|
|
||||||
private readonly Dictionary<string, Texture2D> cache = new();
|
private readonly Dictionary<string, Texture2D> cache = new();
|
||||||
|
|
||||||
/// <summary>Tileset atlas image, or null (logged by the loader).</summary>
|
/// <summary>Tileset atlas image, or null (logged by the loader).</summary>
|
||||||
@@ -27,6 +31,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Charset image, or null.</summary>
|
/// <summary>Charset image, or null.</summary>
|
||||||
public Texture2D Charset(string path) => Load(CharsetGroup, path);
|
public Texture2D Charset(string path) => Load(CharsetGroup, path);
|
||||||
|
|
||||||
|
/// <summary>Item picture, or null.</summary>
|
||||||
|
public Texture2D Icon(string path) => Load(IconGroup, path);
|
||||||
|
|
||||||
private Texture2D Load(string group, string path)
|
private Texture2D Load(string group, string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
|||||||
+89
-17
@@ -12,7 +12,8 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// scene is entered and freed when the scene is left.
|
/// scene is entered and freed when the scene is left.
|
||||||
/// <para/>
|
/// <para/>
|
||||||
/// Draw order (children, bottom to top): back tiles, main tiles, the actor
|
/// Draw order (children, bottom to top): back tiles, main tiles, the actor
|
||||||
/// layer (Y-sorted: events and the player), front tiles. The whole node is
|
/// layer (Y-sorted: events and the player), front tiles; each tile layer draws its
|
||||||
|
/// autotile cells over its plain tiles (<see cref="RpgAutotileLayer"/>). The whole node is
|
||||||
/// scaled by the map's zoom with nearest filtering and follows the player
|
/// scaled by the map's zoom with nearest filtering and follows the player
|
||||||
/// like a camera, clamped to the map edges; a map smaller than the screen is
|
/// like a camera, clamped to the map edges; a map smaller than the screen is
|
||||||
/// centred.
|
/// centred.
|
||||||
@@ -23,6 +24,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// event on the cell fires, else the encounter table rolls. The action key
|
/// event on the cell fires, else the encounter table rolls. The action key
|
||||||
/// fires the action event in front of the player (or under them); during a
|
/// fires the action event in front of the player (or under them); during a
|
||||||
/// dialog it advances the dialog instead.
|
/// dialog it advances the dialog instead.
|
||||||
|
/// <para/>
|
||||||
|
/// Layers and actors share the material of <see cref="RpgLighting"/> (time of day, the
|
||||||
|
/// player's glow, lit events), updated after the camera each frame.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class RpgWorldView : Node2D
|
public partial class RpgWorldView : Node2D
|
||||||
{
|
{
|
||||||
@@ -32,6 +36,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
private RpgMapGrid grid;
|
private RpgMapGrid grid;
|
||||||
private Node2D actorLayer;
|
private Node2D actorLayer;
|
||||||
private Texture2D atlas;
|
private Texture2D atlas;
|
||||||
|
private RpgLighting lighting;
|
||||||
|
|
||||||
/// <summary>The mounted map.</summary>
|
/// <summary>The mounted map.</summary>
|
||||||
public RpgMap Map { get; private set; }
|
public RpgMap Map { get; private set; }
|
||||||
@@ -59,6 +64,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error);
|
GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error);
|
||||||
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null;
|
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null;
|
||||||
var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null;
|
var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null;
|
||||||
|
lighting = new RpgLighting(map, grid, Tileset);
|
||||||
|
|
||||||
AddChild(BuildLayer(RpgLayer.Back, tileSet));
|
AddChild(BuildLayer(RpgLayer.Back, tileSet));
|
||||||
AddChild(BuildLayer(RpgLayer.Main, tileSet));
|
AddChild(BuildLayer(RpgLayer.Main, tileSet));
|
||||||
@@ -66,11 +72,11 @@ namespace Cthangover.Mods.Rpg
|
|||||||
AddChild(actorLayer);
|
AddChild(actorLayer);
|
||||||
AddChild(BuildLayer(RpgLayer.Front, tileSet));
|
AddChild(BuildLayer(RpgLayer.Front, tileSet));
|
||||||
|
|
||||||
foreach (var mapEvent in map.Events ?? new List<RpgMapEvent>())
|
foreach (var mapEvent in RpgMapEvents.Of(map.ID))
|
||||||
AddEventActor(mapEvent);
|
AddEventActor(mapEvent);
|
||||||
RefreshEvents();
|
RefreshEvents();
|
||||||
|
|
||||||
Player = new RpgActor(TileSize) { Name = "Player" };
|
Player = new RpgActor(TileSize) { Name = "Player", Material = lighting.Material };
|
||||||
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
|
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
|
||||||
Player.StepFinished += OnPlayerStepFinished;
|
Player.StepFinished += OnPlayerStepFinished;
|
||||||
actorLayer.AddChild(Player);
|
actorLayer.AddChild(Player);
|
||||||
@@ -92,7 +98,22 @@ namespace Cthangover.Mods.Rpg
|
|||||||
public void RefreshEvents()
|
public void RefreshEvents()
|
||||||
{
|
{
|
||||||
foreach (var actor in eventActors.Values)
|
foreach (var actor in eventActors.Values)
|
||||||
actor.Visible = string.IsNullOrWhiteSpace(actor.Event.Condition) || ScenarioCondition.Evaluate(actor.Event.Condition);
|
{
|
||||||
|
var index = RpgEventPages.ActiveIndex(actor.Event, ScenarioCondition.Evaluate);
|
||||||
|
actor.Visible = index >= 0;
|
||||||
|
if (index < 0 || index == actor.PageIndex)
|
||||||
|
continue;
|
||||||
|
var first = actor.PageIndex == -2;
|
||||||
|
actor.PageIndex = index;
|
||||||
|
actor.Current = RpgEventPages.Effective(actor.Event, index);
|
||||||
|
actor.RouteIndex = 0;
|
||||||
|
actor.RouteTimer = actor.Current.Route?.Every ?? 0;
|
||||||
|
ApplyLook(actor);
|
||||||
|
if (first)
|
||||||
|
actor.Place(new Vector2I(actor.Event.X, actor.Event.Y), actor.Current.Dir);
|
||||||
|
else if (index > 0 && actor.Event.Pages[index - 1].Dir.HasValue)
|
||||||
|
actor.Face(actor.Current.Dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary>
|
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary>
|
||||||
@@ -105,7 +126,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
return false;
|
return false;
|
||||||
foreach (var actor in eventActors.Values)
|
foreach (var actor in eventActors.Values)
|
||||||
{
|
{
|
||||||
if (actor.Visible && actor.Event.IsSolid && actor.Cell == cell)
|
if (actor.Visible && actor.Current?.IsSolid == true && actor.Cell == cell)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -119,7 +140,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
{
|
{
|
||||||
foreach (var actor in eventActors.Values)
|
foreach (var actor in eventActors.Values)
|
||||||
{
|
{
|
||||||
if (actor.Visible && actor.Cell == cell && actor.Event.Trigger == trigger)
|
if (actor.Visible && actor.Cell == cell && actor.Current?.Trigger == trigger)
|
||||||
return actor;
|
return actor;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -141,13 +162,16 @@ namespace Cthangover.Mods.Rpg
|
|||||||
|
|
||||||
if (target.Cell != Player.Cell)
|
if (target.Cell != Player.Cell)
|
||||||
target.Face(RpgDirections.Opposite(Player.Facing));
|
target.Face(RpgDirections.Opposite(Player.Facing));
|
||||||
return RpgEventRunner.Run(target.Event, Map.ID);
|
return RpgEventRunner.Run(target.Current, Map.ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Walking: one decision per frame while the player stands still.</summary>
|
/// <summary>Walking: one decision per frame while the player stands still.</summary>
|
||||||
public override void _Process(double delta)
|
public override void _Process(double delta)
|
||||||
{
|
{
|
||||||
if (Player != null && !Player.IsMoving && !RpgInput.IsWalkBlocked(this))
|
var blocked = RpgInput.IsWalkBlocked(this);
|
||||||
|
if (!blocked)
|
||||||
|
WalkRoutes(delta);
|
||||||
|
if (Player != null && !Player.IsMoving && !blocked)
|
||||||
{
|
{
|
||||||
var held = RpgInput.HeldDirection();
|
var held = RpgInput.HeldDirection();
|
||||||
if (held.HasValue)
|
if (held.HasValue)
|
||||||
@@ -160,6 +184,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateCamera();
|
UpdateCamera();
|
||||||
|
lighting.Update(this, eventActors.Values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The action key: advances a running dialog, otherwise interacts.</summary>
|
/// <summary>The action key: advances a running dialog, otherwise interacts.</summary>
|
||||||
@@ -188,7 +213,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
|
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
|
||||||
if (touch != null)
|
if (touch != null)
|
||||||
{
|
{
|
||||||
RpgEventRunner.Run(touch.Event, Map.ID);
|
RpgEventRunner.Run(touch.Current, Map.ID);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +224,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
|
|
||||||
private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet)
|
private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet)
|
||||||
{
|
{
|
||||||
var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet };
|
var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet, Material = lighting.Material };
|
||||||
if (tileSet == null)
|
if (tileSet == null)
|
||||||
return node;
|
return node;
|
||||||
for (var y = 0; y < grid.Height; y++)
|
for (var y = 0; y < grid.Height; y++)
|
||||||
@@ -207,10 +232,15 @@ namespace Cthangover.Mods.Rpg
|
|||||||
for (var x = 0; x < grid.Width; x++)
|
for (var x = 0; x < grid.Width; x++)
|
||||||
{
|
{
|
||||||
var index = grid.GetTile(layer, x, y);
|
var index = grid.GetTile(layer, x, y);
|
||||||
if (index >= 0)
|
if (index >= 0 && Tileset.AutotileOf(index) == null)
|
||||||
node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index));
|
node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var autotiles = new RpgAutotileLayer(grid, layer, Tileset, textures);
|
||||||
|
if (autotiles.Count > 0)
|
||||||
|
node.AddChild(autotiles);
|
||||||
|
else
|
||||||
|
autotiles.Free();
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,16 +252,58 @@ namespace Cthangover.Mods.Rpg
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id };
|
// The look and the cell come with the first page (RefreshEvents).
|
||||||
if (!string.IsNullOrEmpty(mapEvent.Sprite))
|
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id, Material = lighting.Material };
|
||||||
actor.UseCharset(textures.Charset(mapEvent.Sprite));
|
|
||||||
else if (mapEvent.Tile >= 0 && Tileset != null)
|
|
||||||
actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, mapEvent.Tile));
|
|
||||||
actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir);
|
|
||||||
actorLayer.AddChild(actor);
|
actorLayer.AddChild(actor);
|
||||||
eventActors[mapEvent.Id] = actor;
|
eventActors[mapEvent.Id] = actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows the active page's charset, tile or icon (nothing for an invisible trigger).</summary>
|
||||||
|
private void ApplyLook(RpgActor actor)
|
||||||
|
{
|
||||||
|
var page = actor.Current;
|
||||||
|
actor.ClearLook();
|
||||||
|
if (!string.IsNullOrEmpty(page.Sprite))
|
||||||
|
actor.UseCharset(textures.Charset(page.Sprite));
|
||||||
|
else if (page.Tile >= 0 && Tileset != null)
|
||||||
|
actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, page.Tile));
|
||||||
|
else if (!string.IsNullOrEmpty(page.Icon))
|
||||||
|
actor.UseIcon(textures.Icon(page.Icon));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances the routes of the shown events (<see cref="RpgMoveRoute"/>): one step per
|
||||||
|
/// <c>Every</c> seconds; a walk into a blocked cell or onto the player only turns.
|
||||||
|
/// </summary>
|
||||||
|
private void WalkRoutes(double delta)
|
||||||
|
{
|
||||||
|
foreach (var actor in eventActors.Values)
|
||||||
|
{
|
||||||
|
var route = actor.Current?.Route;
|
||||||
|
if (!actor.Visible || route?.Steps == null || route.Steps.Count == 0 || actor.IsMoving)
|
||||||
|
continue;
|
||||||
|
actor.RouteTimer -= delta;
|
||||||
|
if (actor.RouteTimer > 0)
|
||||||
|
continue;
|
||||||
|
actor.RouteTimer = Math.Max(0.1, route.Every);
|
||||||
|
if (actor.RouteIndex >= route.Steps.Count)
|
||||||
|
{
|
||||||
|
if (!route.Repeat)
|
||||||
|
continue;
|
||||||
|
actor.RouteIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var step = RpgRouteStep.Resolve(route.Steps[actor.RouteIndex++], actor.Cell, Player?.Cell ?? actor.Cell, random);
|
||||||
|
if (!step.Direction.HasValue)
|
||||||
|
continue;
|
||||||
|
var next = actor.Cell + RpgDirections.Delta(step.Direction.Value);
|
||||||
|
if (step.Moves && CanEnter(next) && next != Player?.Cell)
|
||||||
|
actor.StepTo(step.Direction.Value);
|
||||||
|
else
|
||||||
|
actor.Face(step.Direction.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateCamera()
|
private void UpdateCamera()
|
||||||
{
|
{
|
||||||
if (Player == null || GetParent() is not Control host)
|
if (Player == null || GetParent() is not Control host)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "RPG Walk Test",
|
"name": "RPG Walk Test",
|
||||||
"description": "Integration test of the rpg mod on the rpg_demo game: real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots=<dir> saves screenshots (windowed runs only).",
|
"description": "Integration test of the rpg mod on the rpg_plus game (its rpg_demo_game world): real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. A pickup the suite adds to the village through its own map_events file is picked up and must stay gone after leaving the map and coming back, and after a save is loaded. Activated by --itest=rpg_walk with --game=rpg_plus --new (the prologue is clicked through first); --shots=<dir> saves screenshots (windowed runs only).",
|
||||||
"sources": [
|
"sources": [
|
||||||
"src/*.cs"
|
"src/*.cs"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"Events": [
|
||||||
|
{
|
||||||
|
"Id": "rpg_walk_mushroom",
|
||||||
|
"X": 18,
|
||||||
|
"Y": 14,
|
||||||
|
"Icon": "rpg_demo/forest_mushroom",
|
||||||
|
"Trigger": "Touch",
|
||||||
|
"Solid": false,
|
||||||
|
"Commands": [
|
||||||
|
"ptext \"Found: a mushroom\"",
|
||||||
|
"action item.add id=rpg_demo/mushroom count=1",
|
||||||
|
"flag rpg_village_rpg_walk_mushroom_taken 1",
|
||||||
|
"end"
|
||||||
|
],
|
||||||
|
"Condition": "rpg_village_rpg_walk_mushroom_taken != 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Cthangover.Core.Factories.Impls;
|
|||||||
using Cthangover.Core.Games;
|
using Cthangover.Core.Games;
|
||||||
using Cthangover.Core.Scenes;
|
using Cthangover.Core.Scenes;
|
||||||
using Cthangover.Core.Settings;
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.Items;
|
||||||
using Cthangover.Core.Utils;
|
using Cthangover.Core.Utils;
|
||||||
using Cthangover.Mods.Interface.View;
|
using Cthangover.Mods.Interface.View;
|
||||||
using Godot;
|
using Godot;
|
||||||
@@ -17,6 +18,13 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
|
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
|
||||||
/// it reads a keyboard (physical keys, held state, unhandled action key).
|
/// it reads a keyboard (physical keys, held state, unhandled action key).
|
||||||
/// <para/>
|
/// <para/>
|
||||||
|
/// A pickup is checked across map rebuilds: the suite's own
|
||||||
|
/// <c>map_events/rpg_village/rpg_walk_test.json</c> lays a mushroom on the demo's
|
||||||
|
/// village (an event added by another mod, shaped like the map editor's
|
||||||
|
/// pickups); it is picked up, the player leaves the map and comes back, then
|
||||||
|
/// saves, loads and comes back again — it must never reappear nor give a
|
||||||
|
/// second item.
|
||||||
|
/// <para/>
|
||||||
/// One step per timer tick; a step either waits (returns without
|
/// One step per timer tick; a step either waits (returns without
|
||||||
/// advancing) or checks and moves on. A failed check is recorded and the
|
/// advancing) or checks and moves on. A failed check is recorded and the
|
||||||
/// run goes on; an exception ends it. Result line:
|
/// run goes on; an exception ends it. Result line:
|
||||||
@@ -26,17 +34,26 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
public partial class RpgWalkTestDriver : Node
|
public partial class RpgWalkTestDriver : Node
|
||||||
{
|
{
|
||||||
private const float TickSeconds = 0.1f;
|
private const float TickSeconds = 0.1f;
|
||||||
|
|
||||||
|
// The battle opens with an intro animation (the backdrop zooms in): shots wait it out.
|
||||||
|
private const int BattleIntroTicks = 20;
|
||||||
private const float TimeoutSeconds = 90f;
|
private const float TimeoutSeconds = 90f;
|
||||||
private const string Village = "rpg_village";
|
private const string Village = "rpg_village";
|
||||||
private const string House = "rpg_elder_house";
|
private const string House = "rpg_elder_house";
|
||||||
private const string WinFlag = "rpg_test_win";
|
private const string WinFlag = "rpg_test_win";
|
||||||
private const string SaveSlot = "slot_9901";
|
private const string SaveSlot = "slot_9901";
|
||||||
|
private const string PickupId = "rpg_walk_mushroom";
|
||||||
|
private const string PickupFlag = "rpg_village_rpg_walk_mushroom_taken";
|
||||||
|
private const string Mushroom = "rpg_demo/mushroom";
|
||||||
|
|
||||||
|
private static readonly Vector2I PickupCell = new(18, 14);
|
||||||
|
|
||||||
private readonly string shotsDir;
|
private readonly string shotsDir;
|
||||||
private readonly List<string> failures = new();
|
private readonly List<string> failures = new();
|
||||||
private int passed;
|
private int passed;
|
||||||
private int step;
|
private int step;
|
||||||
private int waitTicks;
|
private int waitTicks;
|
||||||
|
private int mushroomsBefore;
|
||||||
private double elapsed;
|
private double elapsed;
|
||||||
|
|
||||||
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
||||||
@@ -51,6 +68,10 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
|
|
||||||
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
||||||
|
|
||||||
|
private static IInventory Inventory => GameData.Instance.Runtime.Inventory;
|
||||||
|
|
||||||
|
private static string Flag(string name) => GameData.Instance.Runtime.Flags.TryGetValue(name, out var value) ? value : null;
|
||||||
|
|
||||||
private void Tick()
|
private void Tick()
|
||||||
{
|
{
|
||||||
if (step < 0)
|
if (step < 0)
|
||||||
@@ -79,6 +100,8 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
switch (step)
|
switch (step)
|
||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
|
if (Boot.IsReady && SkipPrologue())
|
||||||
|
return;
|
||||||
if (Boot.IsReady && View?.Map?.ID == Village)
|
if (Boot.IsReady && View?.Map?.ID == Village)
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
@@ -142,17 +165,63 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
case 9:
|
case 9:
|
||||||
if (AdvanceDialog())
|
if (AdvanceDialog())
|
||||||
return;
|
return;
|
||||||
|
// A pickup this suite adds to the demo's village (map_events of another mod).
|
||||||
|
mushroomsBefore = Inventory.CheckCount(Mushroom);
|
||||||
|
Check(View.FindEvent(PickupId)?.Visible == true, "a test mod's map_events file adds a pickup to the demo's village");
|
||||||
|
View.PlacePlayer(PickupCell.X - 1, PickupCell.Y, RpgDirection.Right);
|
||||||
|
Press(Key.D, true);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 10:
|
||||||
|
if (View.Player.Cell.X < PickupCell.X)
|
||||||
|
return;
|
||||||
|
Press(Key.D, false);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 11:
|
||||||
|
// Stepping on the pickup starts its dialog; the flag is set when it ends.
|
||||||
|
if (AdvanceDialog() || Flag(PickupFlag) != "1")
|
||||||
|
return;
|
||||||
|
Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"stepping on the pickup adds the item, {Inventory.CheckCount(Mushroom)} now");
|
||||||
|
Check(View.FindEvent(PickupId)?.Visible == false, "the picked item leaves the map");
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = House, X = 6, Y = 8, Dir = RpgDirection.Up });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 12:
|
||||||
|
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 13:
|
||||||
|
if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
CheckPickupGone("after leaving the map and coming back");
|
||||||
|
Press(Key.D, true);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 14:
|
||||||
|
if (View.Player.Cell.X < PickupCell.X)
|
||||||
|
return;
|
||||||
|
Press(Key.D, false);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 15:
|
||||||
|
if (View.Player.IsMoving || ++waitTicks < 5)
|
||||||
|
return;
|
||||||
|
Check(RpgInput.FindDialogBox()?.IsRunning != true && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1,
|
||||||
|
"stepping on the empty cell again gives nothing");
|
||||||
View.PlacePlayer(6, 7, RpgDirection.Up);
|
View.PlacePlayer(6, 7, RpgDirection.Up);
|
||||||
Press(Key.W, true);
|
Press(Key.W, true);
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 10:
|
case 16:
|
||||||
if (!View.Player.IsMoving && View.Map.ID == Village)
|
if (!View.Player.IsMoving && View.Map.ID == Village)
|
||||||
return;
|
return;
|
||||||
Press(Key.W, false);
|
Press(Key.W, false);
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 11:
|
case 17:
|
||||||
if (View?.Map?.ID != House)
|
if (View?.Map?.ID != House)
|
||||||
return;
|
return;
|
||||||
if (AdvanceDialog())
|
if (AdvanceDialog())
|
||||||
@@ -163,35 +232,35 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
|
ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 12:
|
case 18:
|
||||||
Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once");
|
Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once");
|
||||||
ScenarioCondition.SetFlag("rpg_wolf_dead", "0");
|
ScenarioCondition.SetFlag("rpg_wolf_dead", "0");
|
||||||
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
|
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
|
||||||
Godot.Engine.TimeScale = 4;
|
Godot.Engine.TimeScale = 4;
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 13:
|
case 19:
|
||||||
if (GetTree().CurrentScene is not BattleSceneContext battle)
|
if (GetTree().CurrentScene is not BattleSceneContext battle)
|
||||||
return;
|
return;
|
||||||
// The core's auto turn only passes, so the hero would lose: once the
|
// The core's auto turn only passes, so the hero would lose: once the
|
||||||
// core waits for the player, take the platform's victory path.
|
// core waits for the player, take the platform's victory path.
|
||||||
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput)
|
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput)
|
||||||
return;
|
return;
|
||||||
// The shot waits for the fade-in too, otherwise it catches the blank transition.
|
// The shot waits for the fade-in and the intro animation, otherwise it catches the zoomed backdrop.
|
||||||
if (GodotSceneService.IsTransitioning)
|
if (GodotSceneService.IsTransitioning || ++waitTicks < BattleIntroTicks)
|
||||||
return;
|
return;
|
||||||
Shot("battle");
|
Shot("battle");
|
||||||
battle.ShowWinground();
|
battle.ShowWinground();
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 14:
|
case 20:
|
||||||
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
|
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
|
||||||
return;
|
return;
|
||||||
Shot("victory");
|
Shot("victory");
|
||||||
button.EmitSignal(BaseButton.SignalName.Pressed);
|
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 15:
|
case 21:
|
||||||
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
|
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
|
||||||
return;
|
return;
|
||||||
Godot.Engine.TimeScale = 1;
|
Godot.Engine.TimeScale = 1;
|
||||||
@@ -202,19 +271,27 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
GameSession.OpenMainMenu(GetTree());
|
GameSession.OpenMainMenu(GetTree());
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 16:
|
case 22:
|
||||||
if (RpgWorldHost.Current != null || ++waitTicks < 5)
|
if (RpgWorldHost.Current != null || ++waitTicks < 5)
|
||||||
return;
|
return;
|
||||||
Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads");
|
Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads");
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 17:
|
case 23:
|
||||||
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5)
|
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5)
|
||||||
return;
|
return;
|
||||||
Shot("loaded");
|
Shot("loaded");
|
||||||
var dialog = RpgInput.FindDialogBox();
|
var dialog = RpgInput.FindDialogBox();
|
||||||
Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})");
|
Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})");
|
||||||
Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}");
|
Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}");
|
||||||
|
Check(Flag(PickupFlag) == "1" && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, "the save keeps the pickup's flag and the picked item");
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 24:
|
||||||
|
if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
CheckPickupGone("after loading the save");
|
||||||
SaveService.DeleteSave(SaveSlot);
|
SaveService.DeleteSave(SaveSlot);
|
||||||
Finish();
|
Finish();
|
||||||
return;
|
return;
|
||||||
@@ -237,7 +314,36 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The map was built again (a teleport or a load): the picked item must stay gone and give nothing.</summary>
|
||||||
|
private void CheckPickupGone(string when)
|
||||||
|
{
|
||||||
|
var pickup = View.FindEvent(PickupId);
|
||||||
|
Check(pickup != null && !pickup.Visible, $"the picked item does not come back {when}");
|
||||||
|
Check(View.EventAt(PickupCell, RpgEventTrigger.Touch) == null, $"its cell has no pickup to step on {when}");
|
||||||
|
Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"the inventory still holds exactly the one picked item {when}");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
|
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
|
||||||
|
/// <summary>
|
||||||
|
/// The game opens with a novel-style prologue (no map yet): its lines are clicked
|
||||||
|
/// through and every choice takes the first answer. True while it runs.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SkipPrologue()
|
||||||
|
{
|
||||||
|
// Asking for the dialog box before the first scene is up caches the wrong node.
|
||||||
|
var scene = ((SceneTree)Engine.GetMainLoop()).Root.GetNodeOrNull<SceneManager>("SceneManager")?.CurrentSceneName;
|
||||||
|
if (string.IsNullOrEmpty(scene) || RpgWorldHost.Current?.View?.Map != null)
|
||||||
|
return false;
|
||||||
|
var box = RpgInput.FindDialogBox();
|
||||||
|
if (box == null || !box.IsRunning)
|
||||||
|
return false;
|
||||||
|
if (box.IsWaitingAnswer)
|
||||||
|
box.TrySelectVariant(0);
|
||||||
|
else
|
||||||
|
box.NextAction();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private bool AdvanceDialog()
|
private bool AdvanceDialog()
|
||||||
{
|
{
|
||||||
var box = RpgInput.FindDialogBox();
|
var box = RpgInput.FindDialogBox();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
.Select(a => a.Substring("--shots=".Length))
|
.Select(a => a.Substring("--shots=".Length))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
GameLogger.Log("TEST", $"RpgWalkTestInitializer: activating rpg_walk, shots={shots ?? "off"}");
|
GameLogger.Log("TEST", $"RpgWalkTestInitializer: activating rpg_walk, shots={shots ?? "off"}");
|
||||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new RpgWalkTestDriver(shots));
|
ModRoot.AddDeferred(new RpgWalkTestDriver(shots));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
namespace Cthangover.Mods.Rpg.Tests
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>Autotile cells are assembled from quarters of the A2 block picked by their neighbours.</summary>
|
||||||
|
public class RpgAutotilesTests
|
||||||
|
{
|
||||||
|
private const int Water = 40;
|
||||||
|
|
||||||
|
private static bool Is(Vector2I[] quarters, params int[] xy)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
if (quarters[i] != new Vector2I(xy[i * 2], xy[i * 2 + 1]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("autotiles/quarters_by_neighbours")]
|
||||||
|
static void QuartersByNeighbours(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.True(Is(RpgAutotiles.Quarters(false, false, false, false, false, false, false, false), 0, 2, 3, 2, 0, 5, 3, 5),
|
||||||
|
"alone: the four outer corners");
|
||||||
|
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, true, true, true, true, true, true), 2, 4, 1, 4, 2, 3, 1, 3),
|
||||||
|
"surrounded: the fill of the patch");
|
||||||
|
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, true, true, false, false, false, false), 2, 0, 3, 0, 2, 1, 3, 1),
|
||||||
|
"sides alike, corners not: the inner corners");
|
||||||
|
ModAssert.True(Is(RpgAutotiles.Quarters(false, true, true, true, false, false, true, true), 2, 2, 1, 2, 2, 3, 1, 3),
|
||||||
|
"the top row of a pond: top edge above, fill below");
|
||||||
|
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, false, true, false, true, false, true), 0, 4, 1, 4, 0, 3, 1, 3),
|
||||||
|
"the left column: left edge");
|
||||||
|
var corner = RpgAutotiles.Quarters(true, true, true, true, false, true, true, true);
|
||||||
|
ModAssert.True(corner[0] == new Vector2I(2, 0) && corner[3] == new Vector2I(1, 3), "only the missing corner turns inner");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("autotiles/cells_read_their_layer_and_the_map_edge_counts_alike")]
|
||||||
|
static void QuartersAt(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var grid = new RpgMapGrid(3, 3);
|
||||||
|
grid.SetTile(RpgLayer.Back, 0, 0, Water);
|
||||||
|
grid.SetTile(RpgLayer.Back, 1, 0, Water);
|
||||||
|
grid.SetTile(RpgLayer.Main, 0, 1, Water);
|
||||||
|
|
||||||
|
var corner = RpgAutotiles.QuartersAt(grid, RpgLayer.Back, 0, 0, Water);
|
||||||
|
ModAssert.True(corner[0] == new Vector2I(2, 4), "off the map counts as water: the top-left quarter is fill");
|
||||||
|
ModAssert.True(corner[2] == new Vector2I(2, 5), "below lies grass on this layer: bottom edge (the main layer is not read)");
|
||||||
|
ModAssert.True(corner[1] == new Vector2I(1, 4), "to the right is water");
|
||||||
|
|
||||||
|
var end = RpgAutotiles.QuartersAt(grid, RpgLayer.Back, 1, 0, Water);
|
||||||
|
ModAssert.True(end[1] == new Vector2I(3, 4), "the right end: right edge");
|
||||||
|
ModAssert.True(end[3] == new Vector2I(3, 5), "and its outer bottom-right corner");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("autotiles/source_and_preview_rects")]
|
||||||
|
static void Rects(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.True(RpgAutotiles.SourceRect(new Vector2I(3, 5), 32) == new Rect2(48, 80, 16, 16), "quarters are half tiles");
|
||||||
|
ModAssert.True(RpgAutotiles.SourceRect(Vector2I.Zero, 48) == new Rect2(0, 0, 24, 24), "of any tile size");
|
||||||
|
ModAssert.True(RpgAutotiles.PreviewRect(32) == new Rect2(0, 0, 32, 32), "the preview is the top-left tile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
namespace Cthangover.Mods.Rpg.Tests
|
||||||
|
{
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>Event pages pick the last page whose condition holds; routes resolve their steps; a page light replaces the event's.</summary>
|
||||||
|
public class RpgEventPagesTests
|
||||||
|
{
|
||||||
|
private static RpgMapEvent Chest() => JsonSerializer.Deserialize<RpgMapEvent>(@"{
|
||||||
|
""Id"": ""chest"", ""X"": 3, ""Y"": 2, ""Tile"": 40, ""Commands"": [""flag chest_open 1""],
|
||||||
|
""Pages"": [
|
||||||
|
{ ""Condition"": ""chest_open == 1"", ""Icon"": ""rpg_demo/coin"", ""Commands"": [""text \""Empty.\""""] },
|
||||||
|
{ ""Condition"": ""guard_back == 1"", ""Sprite"": ""village/guard"", ""Dir"": ""left"", ""Solid"": true }
|
||||||
|
]
|
||||||
|
}", JsonOptionsProvider.ReadOptions);
|
||||||
|
|
||||||
|
private static Func<string, bool> Flags(params string[] holding) => c => Array.IndexOf(holding, c) >= 0;
|
||||||
|
|
||||||
|
[ModTest("pages/last_holding_page_wins")]
|
||||||
|
static void LastHoldingPageWins(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var chest = Chest();
|
||||||
|
ModAssert.Equal(0, RpgEventPages.ActiveIndex(chest, Flags()), "no page holds: the event itself");
|
||||||
|
ModAssert.Equal(1, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1")), "the opened page");
|
||||||
|
ModAssert.Equal(2, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1", "guard_back == 1")), "the later page wins");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("pages/own_condition_only_turns_off_page_zero")]
|
||||||
|
static void OwnConditionOnlyTurnsOffPageZero(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var chest = Chest();
|
||||||
|
chest.Condition = "chest_here == 1";
|
||||||
|
ModAssert.Equal(-1, RpgEventPages.ActiveIndex(chest, Flags()), "nothing holds: hidden");
|
||||||
|
ModAssert.Equal(1, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1")), "a page shows it without the own condition");
|
||||||
|
ModAssert.Equal(0, RpgEventPages.ActiveIndex(new RpgMapEvent { Id = "plain" }, Flags()), "an event without pages and condition");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("pages/effective_merges_page_over_event")]
|
||||||
|
static void EffectiveMergesPageOverEvent(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var chest = Chest();
|
||||||
|
ModAssert.True(ReferenceEquals(chest, RpgEventPages.Effective(chest, 0)), "page 0 is the event itself");
|
||||||
|
|
||||||
|
var open = RpgEventPages.Effective(chest, 1);
|
||||||
|
ModAssert.Equal("chest", open.Id, "id kept");
|
||||||
|
ModAssert.Equal(3, open.X, "cell kept");
|
||||||
|
ModAssert.Equal("rpg_demo/coin", open.Icon, "the page's icon");
|
||||||
|
ModAssert.Equal(-1, open.Tile, "a page look replaces the whole look");
|
||||||
|
ModAssert.Equal("text \"Empty.\"", open.Commands[0], "the page's commands");
|
||||||
|
ModAssert.True(open.Trigger == RpgEventTrigger.Action, "trigger inherited");
|
||||||
|
|
||||||
|
var guard = RpgEventPages.Effective(chest, 2);
|
||||||
|
ModAssert.Equal("village/guard", guard.Sprite, "charset of page 2");
|
||||||
|
ModAssert.True(guard.Dir == RpgDirection.Left, "facing of page 2");
|
||||||
|
ModAssert.True(guard.IsSolid, "solid");
|
||||||
|
ModAssert.Equal("flag chest_open 1", guard.Commands[0], "commands inherited from the event");
|
||||||
|
ModAssert.Null(RpgEventPages.Effective(chest, 3), "out of range");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("routes/steps_resolve")]
|
||||||
|
static void StepsResolve(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var self = new Vector2I(5, 5);
|
||||||
|
var player = new Vector2I(2, 6);
|
||||||
|
var left = RpgRouteStep.Resolve("Left", self, player, null);
|
||||||
|
ModAssert.True(left.Moves && left.Direction == RpgDirection.Left, "a walk, any case");
|
||||||
|
var face = RpgRouteStep.Resolve("face_up", self, player, null);
|
||||||
|
ModAssert.True(!face.Moves && face.Direction == RpgDirection.Up, "a turn");
|
||||||
|
ModAssert.Null(RpgRouteStep.Resolve("wait", self, player, null).Direction, "wait stands");
|
||||||
|
ModAssert.Null(RpgRouteStep.Resolve("dance", self, player, null).Direction, "an unknown word stands");
|
||||||
|
ModAssert.True(RpgRouteStep.Resolve("toward", self, player, null).Direction == RpgDirection.Left, "toward along the longer axis");
|
||||||
|
ModAssert.True(RpgRouteStep.Resolve("away", self, player, null).Direction == RpgDirection.Right, "away is the opposite");
|
||||||
|
ModAssert.Null(RpgRouteStep.Toward(self, self), "already there");
|
||||||
|
var random = RpgRouteStep.Resolve("random", self, player, new Random(7));
|
||||||
|
ModAssert.True(random.Moves && random.Direction.HasValue, "random walks somewhere");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("routes/json_defaults")]
|
||||||
|
static void JsonDefaults(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var mapEvent = JsonSerializer.Deserialize<RpgMapEvent>(@"{ ""Id"": ""cat"", ""Route"": { ""Steps"": [""random""] } }", JsonOptionsProvider.ReadOptions);
|
||||||
|
ModAssert.CloseTo(1.0, mapEvent.Route.Every, what: "a step a second by default");
|
||||||
|
ModAssert.True(mapEvent.Route.Repeat, "repeats by default");
|
||||||
|
ModAssert.Null(mapEvent.Pages, "no pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("lights/page_light_over_event_light_round_trip")]
|
||||||
|
static void PageLightOverEventLight(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var lamp = JsonSerializer.Deserialize<RpgMapEvent>(@"{
|
||||||
|
""Id"": ""lamp"", ""Tile"": 12, ""Light"": { ""Radius"": 2.5, ""Color"": ""#ff8040"" },
|
||||||
|
""Pages"": [ { ""Condition"": ""lamp_out == 1"", ""Tile"": 13, ""Light"": { ""Radius"": 1 } }, { ""Condition"": ""door == 1"" } ]
|
||||||
|
}", JsonOptionsProvider.ReadOptions);
|
||||||
|
ModAssert.CloseTo(2.5, lamp.Light.Radius, what: "the event's light");
|
||||||
|
ModAssert.Equal("#ff8040", lamp.Light.Color, "the event's colour");
|
||||||
|
|
||||||
|
var dim = RpgEventPages.Effective(lamp, 1);
|
||||||
|
ModAssert.CloseTo(1, dim.Light.Radius, what: "the page's light replaces it");
|
||||||
|
ModAssert.Equal("#ffd8a0", dim.Light.Color, "a page light without a colour is warm by default");
|
||||||
|
ModAssert.CloseTo(2.5, RpgEventPages.Effective(lamp, 2).Light.Radius, what: "a page without a light keeps the event's");
|
||||||
|
|
||||||
|
var again = JsonSerializer.Deserialize<RpgMapEvent>(JsonSerializer.Serialize(lamp), JsonOptionsProvider.ReadOptions);
|
||||||
|
ModAssert.CloseTo(2.5, again.Light.Radius, what: "radius survives the round trip");
|
||||||
|
ModAssert.Equal("#ff8040", again.Light.Color, "colour survives the round trip");
|
||||||
|
ModAssert.CloseTo(1, again.Pages[0].Light.Radius, what: "the page light survives the round trip");
|
||||||
|
ModAssert.Null(JsonSerializer.Deserialize<RpgMapEvent>(@"{ ""Id"": ""dark"" }", JsonOptionsProvider.ReadOptions).Light, "no light by default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace Cthangover.Mods.Rpg.Tests
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>The map's light: ambient weights from the clock, lit tiles, the fixed ambient of a map.</summary>
|
||||||
|
public class RpgLightingTests
|
||||||
|
{
|
||||||
|
[ModTest("lights/weights_follow_clock_dark_mode_and_switch")]
|
||||||
|
static void WeightsFollowClock(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.CloseTo(1, RpgLighting.Weights(13f, false, true).Y, what: "noon is day");
|
||||||
|
ModAssert.CloseTo(1, RpgLighting.Weights(0.5f, false, true).W, what: "midnight is night");
|
||||||
|
ModAssert.CloseTo(1, RpgLighting.Weights(13f, true, true).W, what: "dark mode is night at noon");
|
||||||
|
ModAssert.CloseTo(1, RpgLighting.Weights(0.5f, false, false).Y, what: "lighting switched off keeps day at midnight");
|
||||||
|
var dawn = RpgLighting.Weights(5f, false, true);
|
||||||
|
ModAssert.CloseTo(1, dawn.X + dawn.W, what: "dawn blends night into morning");
|
||||||
|
ModAssert.True(dawn.X > 0 && dawn.W > 0, "both phases at dawn");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("lights/tile_lights_on_any_layer_once_per_cell")]
|
||||||
|
static void TileLights(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var tileset = new RpgTileset
|
||||||
|
{
|
||||||
|
ID = "lit",
|
||||||
|
Tiles = new List<RpgTileInfo>
|
||||||
|
{
|
||||||
|
new() { Index = 3, Light = new RpgLight { Radius = 4 } },
|
||||||
|
new() { Index = 7, Light = new RpgLight { Radius = 1.5f, Color = "#ffd27a" } },
|
||||||
|
new() { Index = 9, Solid = true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var grid = new RpgMapGrid(4, 3);
|
||||||
|
grid.SetTile(RpgLayer.Front, 1, 0, 3);
|
||||||
|
grid.SetTile(RpgLayer.Main, 2, 2, 7);
|
||||||
|
grid.SetTile(RpgLayer.Back, 2, 2, 3);
|
||||||
|
grid.SetTile(RpgLayer.Main, 0, 1, 9);
|
||||||
|
|
||||||
|
var lights = RpgLighting.TileLights(grid, tileset);
|
||||||
|
ModAssert.Equal(2, lights.Count, "one light per lit cell, unlit tiles give none");
|
||||||
|
ModAssert.True(lights.Exists(l => l.Cell == new Vector2I(1, 0) && Mathf.IsEqualApprox(l.Light.Radius, 4f)), "a front-layer lamp");
|
||||||
|
ModAssert.True(lights.Exists(l => l.Cell == new Vector2I(2, 2)), "a cell lit on two layers counts once");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("lights/map_ambient_json")]
|
||||||
|
static void MapAmbientJson(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var room = JsonSerializer.Deserialize<RpgMap>(@"{ ""ID"": ""room"", ""Ambient"": ""#e8dcc8"" }", JsonOptionsProvider.ReadOptions);
|
||||||
|
ModAssert.Equal("#e8dcc8", room.Ambient, "a lit room");
|
||||||
|
var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(room), JsonOptionsProvider.ReadOptions);
|
||||||
|
ModAssert.Equal("#e8dcc8", again.Ambient, "ambient survives the round trip");
|
||||||
|
ModAssert.Null(JsonSerializer.Deserialize<RpgMap>(@"{ ""ID"": ""field"" }", JsonOptionsProvider.ReadOptions).Ambient, "outdoors by default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,26 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
ModAssert.False(tileset.IsSolid(-1), "empty cell");
|
ModAssert.False(tileset.IsSolid(-1), "empty cell");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ModTest("rules/tileset_light_and_autotile_lookup_round_trip")]
|
||||||
|
static void TilesetLightAndAutotileLookup(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var tileset = System.Text.Json.JsonSerializer.Deserialize<RpgTileset>(@"{
|
||||||
|
""Id"": ""lit"", ""Tiles"": [
|
||||||
|
{ ""Index"": 29, ""Tag"": ""lamp_top"", ""Light"": { ""Radius"": 4, ""Color"": ""#ffcf80"" } },
|
||||||
|
{ ""Index"": 40, ""Solid"": true, ""Tag"": ""water"", ""Autotile"": ""village/water_auto"" }
|
||||||
|
] }", Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
|
tileset = System.Text.Json.JsonSerializer.Deserialize<RpgTileset>(System.Text.Json.JsonSerializer.Serialize(tileset), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
|
|
||||||
|
ModAssert.CloseTo(4, tileset.LightOf(29).Radius, what: "the lamp's light survives the round trip");
|
||||||
|
ModAssert.Equal("#ffcf80", tileset.LightOf(29).Color, "with its colour");
|
||||||
|
ModAssert.Null(tileset.LightOf(40), "the water gives no light");
|
||||||
|
ModAssert.Null(tileset.LightOf(-1), "an empty cell gives no light");
|
||||||
|
ModAssert.Equal("village/water_auto", tileset.AutotileOf(40)?.Autotile, "the autotile entry and its block");
|
||||||
|
ModAssert.Null(tileset.AutotileOf(29), "a plain tile is no autotile");
|
||||||
|
ModAssert.Null(tileset.AutotileOf(-1), "nor an empty cell");
|
||||||
|
ModAssert.True(tileset.IsSolid(40), "an autotile is solid like any tile");
|
||||||
|
}
|
||||||
|
|
||||||
[ModTest("rules/encounter_off_without_table")]
|
[ModTest("rules/encounter_off_without_table")]
|
||||||
static void EncounterOffWithoutTable(ModTestContext ctx)
|
static void EncounterOffWithoutTable(ModTestContext ctx)
|
||||||
{
|
{
|
||||||
|
|||||||
+53
-3
@@ -57,7 +57,6 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
{
|
{
|
||||||
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
|
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
|
||||||
""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
|
""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
|
||||||
""Events"":[{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}}],
|
|
||||||
""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
|
""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
|
||||||
|
|
||||||
var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
@@ -65,12 +64,63 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
|
|
||||||
ModAssert.Equal("m", again.ID, "id");
|
ModAssert.Equal("m", again.ID, "id");
|
||||||
ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
|
ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
|
||||||
|
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
|
||||||
|
ModAssert.Equal("slime", again.Encounters.Groups[0], "encounter group");
|
||||||
|
ModAssert.Equal(2, again.Zoom, "zoom default");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("state/event_file_round_trip")]
|
||||||
|
static void EventFileRoundTrip(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var json = @"{""Events"":[
|
||||||
|
{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}},
|
||||||
|
{""Id"":""herb"",""X"":2,""Y"":1,""Icon"":""rpg_demo/forest_mushroom"",""Commands"":[""end""],""Condition"":""herb != 1""}]}";
|
||||||
|
|
||||||
|
var file = JsonSerializer.Deserialize<RpgMapEventFile>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
|
file.Events[0].ModId = "some_mod";
|
||||||
|
var text = JsonSerializer.Serialize(file);
|
||||||
|
var again = JsonSerializer.Deserialize<RpgMapEventFile>(text, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
|
|
||||||
|
ModAssert.Equal(2, again.Events.Count, "both events");
|
||||||
ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger");
|
ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger");
|
||||||
ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map");
|
ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map");
|
||||||
ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
|
ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
|
||||||
ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
|
ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
|
||||||
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
|
ModAssert.Equal("rpg_demo/forest_mushroom", again.Events[1].Icon, "icon");
|
||||||
ModAssert.Equal(2, again.Zoom, "zoom default");
|
ModAssert.True(again.Events[1].IsSolid, "an icon is drawn, so it blocks unless told otherwise");
|
||||||
|
ModAssert.Equal("herb != 1", again.Events[1].Condition, "condition");
|
||||||
|
ModAssert.Equal("end", again.Events[1].Commands[0], "commands");
|
||||||
|
ModAssert.False(text.Contains("some_mod"), "where an event came from is not written");
|
||||||
|
ModAssert.False(text.Contains("IsSolid"), "the computed solidity is not written");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("state/event_merge_adds_and_replaces")]
|
||||||
|
static void EventMergeAddsAndReplaces(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var merge = new RpgMapEventMerge();
|
||||||
|
merge.Add("village", "base_game", "map_events/village/events.json", new[]
|
||||||
|
{
|
||||||
|
new RpgMapEvent { Id = "elder", X = 1, Y = 1 },
|
||||||
|
new RpgMapEvent { Id = "sign", X = 2, Y = 1 },
|
||||||
|
new RpgMapEvent { Id = " " }
|
||||||
|
});
|
||||||
|
var replaced = merge.Add("VILLAGE", "quest_mod", "map_events/village/quest.json", new[]
|
||||||
|
{
|
||||||
|
new RpgMapEvent { Id = "Elder", X = 5, Y = 5 },
|
||||||
|
new RpgMapEvent { Id = "quest_giver", X = 3, Y = 3 }
|
||||||
|
});
|
||||||
|
merge.Add("forest", "quest_mod", "map_events/forest/a.json", null);
|
||||||
|
|
||||||
|
var village = merge.Maps["village"];
|
||||||
|
ModAssert.Equal(3, village.Count, "an event without an id is skipped, a new id is added");
|
||||||
|
ModAssert.Equal(1, replaced.Count, "one replacement reported");
|
||||||
|
ModAssert.Equal("Elder", replaced[0], "the replacing id");
|
||||||
|
ModAssert.Equal(5, village[0].X, "the later mod's event replaces in place (any case)");
|
||||||
|
ModAssert.Equal("quest_mod", village[0].ModId, "the replacement remembers its mod");
|
||||||
|
ModAssert.Equal("sign", village[1].Id, "the others keep their order");
|
||||||
|
ModAssert.Equal("base_game", village[1].ModId, "and their mod");
|
||||||
|
ModAssert.Equal("map_events/village/quest.json", village[2].File, "an added event remembers its file");
|
||||||
|
ModAssert.Equal(0, merge.Maps["forest"].Count, "a file without events adds nothing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user