@jjlmoya/utils-tabletop 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -0
- package/src/entries.ts +4 -0
- package/src/tests/locale_completeness.test.ts +2 -9
- package/src/tests/tool_validation.test.ts +1 -2
- package/src/tool/mus-scoreboard/component.astro +21 -11
- package/src/tool/rpg-settlement-exploration-map-generator/.keep +1 -0
- package/src/tool/rpg-settlement-exploration-map-generator/bibliography.astro +11 -0
- package/src/tool/rpg-settlement-exploration-map-generator/bibliography.ts +7 -0
- package/src/tool/rpg-settlement-exploration-map-generator/component.astro +66 -0
- package/src/tool/rpg-settlement-exploration-map-generator/controller.ts +231 -0
- package/src/tool/rpg-settlement-exploration-map-generator/dom-views.ts +174 -0
- package/src/tool/rpg-settlement-exploration-map-generator/editor.ts +85 -0
- package/src/tool/rpg-settlement-exploration-map-generator/entry.ts +27 -0
- package/src/tool/rpg-settlement-exploration-map-generator/evaluator.ts +8 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/de.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/en.ts +63 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/es.ts +233 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/fr.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/id.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/it.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/ja.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/ko.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/nl.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/pl.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/pt.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/ru.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/sv.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/tr.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/zh.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/index.ts +4 -0
- package/src/tool/rpg-settlement-exploration-map-generator/logic.test.ts +65 -0
- package/src/tool/rpg-settlement-exploration-map-generator/logic.ts +288 -0
- package/src/tool/rpg-settlement-exploration-map-generator/rpg-settlement-exploration-map-generator.css +885 -0
- package/src/tool/rpg-settlement-exploration-map-generator/seo.astro +11 -0
- package/src/tool/rpg-settlement-exploration-map-generator/storage.test.ts +12 -0
- package/src/tool/rpg-settlement-exploration-map-generator/storage.ts +24 -0
- package/src/tool/rpg-settlement-exploration-map-generator/ui.ts +81 -0
- package/src/tools.ts +2 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { SettlementMapLocaleContent, SettlementMapUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: SettlementMapUI = {
|
|
6
|
+
intro: '生成并编辑用于跑团探索的跑团聚落地图。选择地形、规模、建筑风格,并通过右键上下文菜单编辑网格。',
|
|
7
|
+
seedLabel: '生成种子与聚落名称',
|
|
8
|
+
seedHint: '决定随机但可复现的街道与建筑物布局 pattern。',
|
|
9
|
+
randomSeed: '随机生成新名称',
|
|
10
|
+
environmentLabel: '周边地形环境',
|
|
11
|
+
forest: '森林',
|
|
12
|
+
plains: '平原',
|
|
13
|
+
coast: '海岸',
|
|
14
|
+
river: '河流',
|
|
15
|
+
mountain: '山脉',
|
|
16
|
+
styleLabel: '建筑与视觉风格',
|
|
17
|
+
styleHint: '改变聚落屋顶、墙体、建筑材料以及植物外观。',
|
|
18
|
+
styleTimber: '半木构',
|
|
19
|
+
styleStone: '石砌',
|
|
20
|
+
styleCoastal: '沿海',
|
|
21
|
+
styleHighland: '高地',
|
|
22
|
+
styleMedieval: '中世纪',
|
|
23
|
+
styleEdo: '江户风格',
|
|
24
|
+
styleSahelian: '萨赫勒风格',
|
|
25
|
+
sizeLabel: '聚落规模大小',
|
|
26
|
+
hamlet: '村庄仔',
|
|
27
|
+
hamletHint: '小型歇脚点',
|
|
28
|
+
village: '村庄',
|
|
29
|
+
villageHint: '区域中心',
|
|
30
|
+
town: '城镇',
|
|
31
|
+
townHint: '繁华枢纽',
|
|
32
|
+
homesLabel: '民居住宅数量',
|
|
33
|
+
homesHint: '调节所选规模级别内的住宅建筑总数量。',
|
|
34
|
+
servicesLabel: '设施与地标建筑',
|
|
35
|
+
serviceListHint: '选择基础功能建筑或添加自定义命名地标。',
|
|
36
|
+
newServicePlaceholder: '输入地标或设施名称',
|
|
37
|
+
addService: '添加',
|
|
38
|
+
removeService: '移除',
|
|
39
|
+
serviceTavern: '酒馆',
|
|
40
|
+
serviceSmithy: '铁匠铺',
|
|
41
|
+
serviceTemple: '神庙',
|
|
42
|
+
serviceMarket: '集市',
|
|
43
|
+
serviceStable: '马厩',
|
|
44
|
+
serviceHall: '市政厅',
|
|
45
|
+
generate: '重新生成地图',
|
|
46
|
+
mapRegionLabel: '可编辑的跑团聚落探索地图',
|
|
47
|
+
mapSummary: '聚落平面图',
|
|
48
|
+
buildings: '建筑',
|
|
49
|
+
paths: '道路',
|
|
50
|
+
services: '设施',
|
|
51
|
+
terrain: '水域',
|
|
52
|
+
legendLabel: '地图图例',
|
|
53
|
+
legendHome: '住宅',
|
|
54
|
+
legendService: '设施',
|
|
55
|
+
legendPath: '道路',
|
|
56
|
+
legendWater: '水域',
|
|
57
|
+
legendWild: '森林',
|
|
58
|
+
editLabel: '网格直接编辑',
|
|
59
|
+
toolSelect: '查看',
|
|
60
|
+
toolBuilding: '建筑',
|
|
61
|
+
toolPath: '道路',
|
|
62
|
+
toolWater: '水域',
|
|
63
|
+
toolTree: '树木',
|
|
64
|
+
toolErase: '擦除',
|
|
65
|
+
serviceSelectLabel: '新设施标签',
|
|
66
|
+
clickHint: '右键点击或长按网格以打开操作菜单。',
|
|
67
|
+
selectedHint: '已选网格:',
|
|
68
|
+
shareLink: '复制链接',
|
|
69
|
+
linkCopied: '链接已复制到剪贴板',
|
|
70
|
+
exportPng: 'PNG',
|
|
71
|
+
exportSvg: 'SVG',
|
|
72
|
+
exportJson: '保存 JSON',
|
|
73
|
+
importJson: '打开 JSON',
|
|
74
|
+
importError: '无效的聚落地图文件。',
|
|
75
|
+
mapData: '地图数据',
|
|
76
|
+
readyBadge: '准备探索',
|
|
77
|
+
serviceNone: '无标签',
|
|
78
|
+
contextMenuLabel: '网格菜单',
|
|
79
|
+
contextInspect: '查看网格',
|
|
80
|
+
contextBuilding: '添加建筑',
|
|
81
|
+
contextPath: '添加道路',
|
|
82
|
+
contextWater: '添加水域',
|
|
83
|
+
contextTree: '添加树木',
|
|
84
|
+
contextErase: '擦除网格',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const faq = [
|
|
88
|
+
{ question: '这款跑团聚落探索地图生成器可以创建什么内容?', answer: '您可以为跑团游戏和世界观构建生成详细且直观的村庄、小镇和城市平面图。建筑、道路、水域和植被会根据逻辑规则自动布署。' },
|
|
89
|
+
{ question: '更改生成种子(seed)会改变地图布局吗?', answer: '是的。相同的种子和参数设置每次都会生成完全相同的初始地图。这使得在笔记中保存地图并与玩家共享变得非常简单。' },
|
|
90
|
+
{ question: '提供哪些聚落规模选项?', answer: '您可以选择村庄仔、村庄和城镇三种规模。建筑密度、道路网络和基础设施规模会自动按比例缩放。' },
|
|
91
|
+
{ question: '可以自定义建筑风格吗?', answer: '可以,提供 7 种风格:中世纪、江户风格、萨赫勒风格、半木构、石砌、沿海和高地。风格会改变屋顶、墙壁材质和植被外观。' },
|
|
92
|
+
{ question: '周边环境是否有不同地形选择?', answer: '您可以将聚落布置在森林、平原、海岸、河畔或山脉中,以便有机地融入您的世界地图。' },
|
|
93
|
+
{ question: '像酒馆这样的设施 meihan 是如何布局的?', answer: '酒馆、铁匠铺和集市等建筑会根据权重算法合理分布在聚落中心或边缘区域。' },
|
|
94
|
+
{ question: '可以添加自定义的独特地标吗?', answer: '可以,您可以添加自定义命名的地点,这些地点会作为清晰的标牌显示在建筑物上方并保存在地图数据中。' },
|
|
95
|
+
{ question: '可以手动编辑地图吗?', answer: '当然可以。通过任意网格上的右键菜单,您可以直接添加或删除建筑、道路、水域和树木。' },
|
|
96
|
+
{ question: '编辑时道路和水域如何连接?', answer: '道路交叉口和相邻的水域网格会自动融合成平滑自然的外形,没有杂乱边缘。' },
|
|
97
|
+
{ question: '如何保存或分享生成的地图?', answer: '您可以复制直接分享链接,也可以保存完整的 JSON 文件以便日后重新加载与编辑。' },
|
|
98
|
+
{ question: '支持哪些导出格式?', answer: '支持用于 VTT 虚拟桌面的 PNG,用于打印和矢量图的 SVG,以及用于保存所有编辑数据的 JSON。' },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const howTo = [
|
|
102
|
+
{ name: '选择生成种子', text: '使用随机生成的名称或输入您自己的词汇来创建初始布局 pattern。' },
|
|
103
|
+
{ name: '选择地形与风格', text: '根据战役氛围调整周边环境和建筑视觉 style。' },
|
|
104
|
+
{ name: '设置聚落规模', text: '选择村庄仔、村庄或城镇,并根据需要微调住宅数量。' },
|
|
105
|
+
{ name: '添加设施地标', text: '启用默认功能建筑或添加自定义命名地标。' },
|
|
106
|
+
{ name: '编辑网格细节', text: '使用右键菜单对道路、建筑或水域进行直接修改。' },
|
|
107
|
+
{ name: '导出与共享', text: '复制链接或将地图保存为 PNG、SVG 或 JSON 格式。' },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
111
|
+
const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: '跑团聚落与探索地图生成器', operatingSystem: 'All', applicationCategory: 'GameApplication', description: '支持可复现种子与网格直接编辑的跑团聚落探索地图生成器。' };
|
|
112
|
+
const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: '如何生成跑团聚落探索地图', step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) };
|
|
113
|
+
|
|
114
|
+
const schemas: WithContext<SoftwareApplication | FAQPage | HowTo>[] = [faqSchema, appSchema, howToSchema];
|
|
115
|
+
|
|
116
|
+
export const content: SettlementMapLocaleContent = {
|
|
117
|
+
slug: 'rpg-settlement-exploration-map-generator',
|
|
118
|
+
title: '跑团聚落与探索地图生成器',
|
|
119
|
+
description: '使用可复现种子、7 种视觉风格与网格直接编辑功能,为跑团游戏设计村庄和城镇地图。',
|
|
120
|
+
ui,
|
|
121
|
+
seo: [
|
|
122
|
+
{ type: 'title', text: '为桌面角色扮演游戏设计生动的聚落地图探索系统', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: '为您的桌面角色扮演游戏(TRPG / 跑团)和奇幻世界观架空构建设计包含规划合理的道路网络、中央广场、防御工事和核心设施建筑的完整村庄、聚落、村落与小镇。这款基于浏览器构建的地图生成器可以生成清晰直观的俯视视角平面地图,民居住宅、主要道路、集市广场、水系运河与绿化景观有机融为一体,构成一个充满丰富细节且富有高度说服力的探险空间。无论是作为冒险者旅途中的临时歇脚点、玩家角色的起始村庄总部、公会大本营,还是充斥着敌对派系势力、隐藏秘密与精彩剧情任务的繁华贸易枢纽,它都是极为理想且高效的辅助设计工具。每个生成的地点都显得生动自然、层次分明,随时可以投入到一场引人入胜的游戏会话探险中。' },
|
|
124
|
+
{ type: 'title', text: '可复现的生成种子(Seed)系统', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: '每个生成的聚落都基于一个易于记忆和记录的文本种子(Seed)。完全相同的种子和参数设置能够保证每次都精准无误地重现最初的地图布局。这对于希望将详细地图笔记保存下来以备后续游戏会话使用、或者希望与其他地下城主(DM / GM)共享地图而不用担心基础布局产生任何微小偏差的城主来说,是非常实用且不可或缺的功能。通过更换种子,您可以立即探索全新的、独一无二的街道与建筑布局 pattern,同时在创造奇幻世界的过程中保持完全的掌控力与创作自由。' },
|
|
126
|
+
{ type: 'list', items: ['新种子可在选定的规模范围内生成独特的道路与建筑布局 pattern。', '为城主笔记和战役资料提供完全的可复现性与一致性体验。', '在地图中保持合理的城市规划逻辑与空间比例关系。', '便于与玩家分享,作为探索冒险的稳定起点与地图参照。'] },
|
|
127
|
+
{ type: 'title', text: '从微型村庄到繁华城镇的规模动态缩放', level: 2 },
|
|
128
|
+
{ type: 'paragraph', html: '从路边的小型村落歇脚点,到匹配聚落规模且具有多变建筑密度与道路形态的大型城镇,您可以自由选择。每个规模级别都有其内部建筑逻辑与住宅数量范围,为冒险与探险活动搭建完美的舞台,让每一次探索都充满未知的惊喜与故事发展的可能性。' },
|
|
129
|
+
{ type: 'table', headers: ['规模等级', '住宅数量', '区域特征', '战役应用场景'], rows: [['村庄仔', '3-18', '小巧安静', '路边歇脚点或偏远孤立聚落'], ['村庄', '4-28', '区域中心', '玩家基地与起始探险地点'], ['城镇', '6-42', '密集繁华', '区域贸易枢纽与调查任务地点']] },
|
|
130
|
+
{ type: 'title', text: '七种视觉建筑风格营造丰富奇幻氛围', level: 2 },
|
|
131
|
+
{ type: 'paragraph', html: '赋予每个聚落独一无二的视觉标识与文化氛围。中世纪、江户风格、萨赫勒风格、半木构、石砌、沿海和高地风格会改变屋顶造型、墙体材料、色彩点缀以及周边植被外观,精准反映奇幻世界中特定区域的地理环境与文化特色。每处细节都能极大增强场景的代入感与艺术美感。' },
|
|
132
|
+
{ type: 'list', items: ['中世纪与半木构风格适合经典奇幻与森林村庄环境。', '石砌与高地风格适合险峻山區、城堡与前线哨所基地。', '沿海风格适合带有木质码头与海岸植物的港口城镇。', '江户风格适合东方奇幻设定、木质结构与特色屋顶外观。', '萨赫勒风格适合炎热地区、泥砖建筑与沙漠绿洲聚落。'] },
|
|
133
|
+
{ type: 'title', text: '设施与地标标牌的智能算法布局', level: 2 },
|
|
134
|
+
{ type: 'paragraph', html: '酒馆、铁匠铺、神庙和市政厅等功能建筑会利用权重算法,在聚落中心与边缘区域之间按照建筑逻辑合理分布。您添加的自定义地点会作为清晰易读的标牌直接悬浮在建筑物上方,极大方便了游戏过程中的方位辨识,增强了玩家的沉浸体验与方向感。' },
|
|
135
|
+
{ type: 'title', text: '与战役世界地形环境的自然融合', level: 2 },
|
|
136
|
+
{ type: 'paragraph', html: '将聚落放置在森林、平原、海岸、河畔或山脉中,使其自然地融入您世界地图的周边野外环境中。植被和水流形态会根据所选的自然环境自动调整,展现出无缝衔接的地形地貌特色。' },
|
|
137
|
+
{ type: 'title', text: '在浏览器中直接进行便捷的网格编辑', level: 2 },
|
|
138
|
+
{ type: 'paragraph', html: '在电脑端通过右键点击,或在移动设备上通过长按,轻松添加或删除住宅、道路、水域和树木。根据战役剧本的特定需求随时调整地图,无需借助外部图像编辑软件或复杂的绘图工具即可在现场补充关键要素与防卫设施。' },
|
|
139
|
+
{ type: 'list', items: ['添加关键地点、瞭望塔或防御木栅栏等战术要素。', '无需外部绘图软件即可实现快速现场修改与实时预览。', '将所有手动修改直接保存在地图数据与导出的文件里。', '在电脑、平板和手机端均可流畅运行与便捷操作。'] },
|
|
140
|
+
{ type: 'title', text: '动态道路与水域自动平滑连接技术', level: 2 },
|
|
141
|
+
{ type: 'paragraph', html: '街道交叉口和相邻的水域网格会自动融合成平滑、美观且自然的形态,消除生硬边缘,为您的聚落呈现出干净专业的高质量地图视图,提升画面的精致度。' },
|
|
142
|
+
{ type: 'title', text: '保存与共享以便后续会话长期使用', level: 2 },
|
|
143
|
+
{ type: 'paragraph', html: '轻松复制直接编辑链接,或将完整的地图保存为 JSON 文件,以便在接下来的游戏会话中继续完善您的聚落,绝不会丢失任何关键细节与个性化调整。' },
|
|
144
|
+
{ type: 'title', text: '满足各种跑团需求的导出格式支持', level: 2 },
|
|
145
|
+
{ type: 'table', headers: ['导出格式', '主要用途', '包含内容'], rows: [['PNG', 'VTT 虚拟桌面与游戏笔记', '高清图像文件'], ['SVG', '打印与矢量图形编辑', '无损清晰矢量图'], ['JSON', '后续二次编辑', '包含所有网格的完整地图数据']] },
|
|
146
|
+
{ type: 'tip', title: '地下城主准备建议', html: '在游戏会话开始前,设定好种子、规模与地形,添加核心设施地标,并在地图上完成最后的细节微调与战术部署。' },
|
|
147
|
+
],
|
|
148
|
+
faq,
|
|
149
|
+
bibliography,
|
|
150
|
+
howTo,
|
|
151
|
+
schemas,
|
|
152
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { rpgSettlementExplorationMapGenerator } from './entry';
|
|
3
|
+
export * from './entry';
|
|
4
|
+
export const RPG_SETTLEMENT_EXPLORATION_MAP_GENERATOR_TOOL: ToolDefinition = { entry: rpgSettlementExplorationMapGenerator, Component: () => import('./component.astro'), SEOComponent: () => import('./seo.astro'), BibliographyComponent: () => import('./bibliography.astro') };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { addBuilding, addPath, addWater, DEFAULT_CONFIG, eraseAt, generateSettlement, homeRange, normalizeConfig, settlementPreset } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('settlement map logic', () => {
|
|
5
|
+
it('rebuilds the same map from the same setup', () => {
|
|
6
|
+
expect(generateSettlement(DEFAULT_CONFIG)).toEqual(generateSettlement(DEFAULT_CONFIG));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('normalizes size dependent home ranges and invalid values', () => {
|
|
10
|
+
const config = normalizeConfig({ size: 'hamlet', homes: 100, environment: 'unknown' as never, services: ['tavern', 'tavern', 'invalid' as never] });
|
|
11
|
+
expect(config.homes).toBe(18);
|
|
12
|
+
expect(config.environment).toBe('forest');
|
|
13
|
+
expect(config.services).toEqual(['tavern', 'invalid']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('supports direct map edits', () => {
|
|
17
|
+
const map = generateSettlement({ ...DEFAULT_CONFIG, homes: 10 });
|
|
18
|
+
const withBuilding = addBuilding(map, { x: 1, y: 1 }, 'market');
|
|
19
|
+
const withPath = addPath(withBuilding, { x: 4, y: 4 });
|
|
20
|
+
const withWater = addWater(withPath, { x: 8, y: 8 });
|
|
21
|
+
const erased = eraseAt(withWater, { x: 8, y: 8 });
|
|
22
|
+
expect(withBuilding.buildings.length).toBeGreaterThanOrEqual(map.buildings.length);
|
|
23
|
+
expect(withPath.paths.length).toBeGreaterThan(withBuilding.paths.length - 1);
|
|
24
|
+
expect(erased.water).not.toContainEqual({ x: 8, y: 8 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('supports small settlements and connects every home to the plaza', () => {
|
|
28
|
+
const map = generateSettlement({ ...DEFAULT_CONFIG, size: 'hamlet', homes: 3 });
|
|
29
|
+
expect(homeRange('hamlet').min).toBe(3);
|
|
30
|
+
expect(map.buildings).toHaveLength(3);
|
|
31
|
+
expect(map.paths).toHaveLength(map.buildings.length);
|
|
32
|
+
map.paths.forEach((path) => expect(path.points.at(-1)).toEqual({ x: map.plaza.x + 3, y: map.plaza.y + 2 }));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('changes the urban preset when the footprint changes', () => {
|
|
36
|
+
expect(settlementPreset('hamlet')).toEqual({ homes: 6, services: ['tavern', 'stable'] });
|
|
37
|
+
expect(settlementPreset('town').homes).toBeGreaterThan(settlementPreset('village').homes);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses the seed to choose a varied pattern within each settlement type', () => {
|
|
41
|
+
const first = generateSettlement({ ...DEFAULT_CONFIG, size: 'town', seed: 'Oak Mere' });
|
|
42
|
+
const second = generateSettlement({ ...DEFAULT_CONFIG, size: 'town', seed: 'Red Cairn' });
|
|
43
|
+
expect(first.buildings.map(({ x, y }) => `${x},${y}`)).not.toEqual(second.buildings.map(({ x, y }) => `${x},${y}`));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stores the visual settlement character in the generated map', () => {
|
|
47
|
+
const map = generateSettlement({ ...DEFAULT_CONFIG, style: 'stone' });
|
|
48
|
+
expect(map.config.style).toBe('stone');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('turns a painted water cell into part of the terrain', () => {
|
|
52
|
+
const map = generateSettlement({ ...DEFAULT_CONFIG, homes: 3, size: 'hamlet' });
|
|
53
|
+
const target = map.buildings[0]!;
|
|
54
|
+
const painted = addWater(map, { x: target.x, y: target.y });
|
|
55
|
+
expect(painted.water).toContainEqual({ x: target.x, y: target.y });
|
|
56
|
+
expect(painted.buildings).not.toContainEqual(target);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps roads finite when a river crosses the whole map', () => {
|
|
60
|
+
const map = generateSettlement({ ...DEFAULT_CONFIG, environment: 'river', size: 'town', homes: 24 });
|
|
61
|
+
expect(map.water.length).toBeGreaterThan(0);
|
|
62
|
+
expect(map.paths).toHaveLength(map.buildings.length);
|
|
63
|
+
expect(Math.max(...map.paths.map((path) => path.points.length))).toBeLessThan(map.width + map.height);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
export type Environment = 'forest' | 'plains' | 'coast' | 'river' | 'mountain';
|
|
2
|
+
export type SettlementSize = 'hamlet' | 'village' | 'town';
|
|
3
|
+
export type SettlementStyle = 'timber' | 'stone' | 'coastal' | 'highland' | 'medieval' | 'edo' | 'sahelian';
|
|
4
|
+
export type ServiceType = string;
|
|
5
|
+
export type EditTool = 'select' | 'building' | 'path' | 'water' | 'tree' | 'erase';
|
|
6
|
+
|
|
7
|
+
export interface SettlementConfig { seed: string; environment: Environment; size: SettlementSize; style: SettlementStyle; homes: number; services: ServiceType[]; }
|
|
8
|
+
export interface Point { x: number; y: number }
|
|
9
|
+
export interface Building { id: string; x: number; y: number; width: number; height: number; service: ServiceType | null }
|
|
10
|
+
export interface PathRoute { points: Point[] }
|
|
11
|
+
export interface Plaza { x: number; y: number; width: number; height: number }
|
|
12
|
+
|
|
13
|
+
export interface SettlementMap {
|
|
14
|
+
config: SettlementConfig;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
plaza: Plaza;
|
|
18
|
+
buildings: Building[];
|
|
19
|
+
paths: PathRoute[];
|
|
20
|
+
water: Point[];
|
|
21
|
+
wild: Point[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type RandomSource = () => number;
|
|
25
|
+
export interface HomeRange { min: number; max: number }
|
|
26
|
+
export interface SettlementPreset { homes: number; services: ServiceType[] }
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_CONFIG: SettlementConfig = { seed: 'Willow Mere Crossing', environment: 'forest', size: 'village', style: 'medieval', homes: 14, services: ['tavern', 'smithy', 'temple', 'market'] };
|
|
29
|
+
const SIZE_PRESETS: Record<SettlementSize, SettlementPreset> = { hamlet: { homes: 6, services: ['tavern', 'stable'] }, village: { homes: 14, services: ['tavern', 'smithy', 'temple', 'market'] }, town: { homes: 24, services: ['tavern', 'smithy', 'temple', 'market', 'stable', 'hall'] } };
|
|
30
|
+
const DIMENSIONS: Record<SettlementSize, { width: number; height: number }> = { hamlet: { width: 34, height: 24 }, village: { width: 42, height: 28 }, town: { width: 52, height: 34 } };
|
|
31
|
+
const HOME_RANGES: Record<SettlementSize, HomeRange> = { hamlet: { min: 3, max: 18 }, village: { min: 4, max: 28 }, town: { min: 6, max: 42 } };
|
|
32
|
+
|
|
33
|
+
export function homeRange(size: SettlementSize): HomeRange { return HOME_RANGES[size]; }
|
|
34
|
+
export function settlementPreset(size: SettlementSize): SettlementPreset { return { homes: SIZE_PRESETS[size].homes, services: [...SIZE_PRESETS[size].services] }; }
|
|
35
|
+
|
|
36
|
+
const clamp = (val: number, min: number, max: number): number => Math.min(max, Math.max(min, Math.round(val)));
|
|
37
|
+
const isEnv = (val: unknown): val is Environment => ['forest', 'plains', 'coast', 'river', 'mountain'].includes(String(val));
|
|
38
|
+
const isSize = (val: unknown): val is SettlementSize => ['hamlet', 'village', 'town'].includes(String(val));
|
|
39
|
+
const isStyle = (val: unknown): val is SettlementStyle => ['timber', 'stone', 'coastal', 'highland', 'medieval', 'edo', 'sahelian'].includes(String(val));
|
|
40
|
+
|
|
41
|
+
export function normalizeConfig(input: Partial<SettlementConfig>): SettlementConfig {
|
|
42
|
+
const size = isSize(input.size) ? input.size : DEFAULT_CONFIG.size;
|
|
43
|
+
const range = HOME_RANGES[size];
|
|
44
|
+
const services = Array.isArray(input.services) ? input.services.filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map((v) => v.trim()).slice(0, 32) : DEFAULT_CONFIG.services;
|
|
45
|
+
return { seed: String(input.seed || DEFAULT_CONFIG.seed).slice(0, 48), environment: isEnv(input.environment) ? input.environment : DEFAULT_CONFIG.environment, size, style: isStyle(input.style) ? input.style : DEFAULT_CONFIG.style, homes: clamp(input.homes ?? DEFAULT_CONFIG.homes, range.min, range.max), services: [...new Set(services)] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createRandom(seed: string): RandomSource {
|
|
49
|
+
let h = 2166136261;
|
|
50
|
+
for (let i = 0; i < seed.length; i += 1) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
51
|
+
let state = h >>> 0;
|
|
52
|
+
return () => { state += 0x6d2b79f5; let v = state; v = Math.imul(v ^ (v >>> 15), v | 1); v ^= v + Math.imul(v ^ (v >>> 7), v | 61); return ((v ^ (v >>> 14)) >>> 0) / 4294967296; };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const randomInt = (rnd: RandomSource, min: number, max: number): number => Math.floor(rnd() * (max - min + 1)) + min;
|
|
56
|
+
|
|
57
|
+
function routeBetween(from: Point, to: Point): Point[] {
|
|
58
|
+
const points: Point[] = [{ ...from }];
|
|
59
|
+
let cur = { ...from };
|
|
60
|
+
while (cur.x !== to.x || cur.y !== to.y) { cur = cur.x !== to.x ? { x: cur.x + Math.sign(to.x - cur.x), y: cur.y } : { x: cur.x, y: cur.y + Math.sign(to.y - cur.y) }; points.push({ ...cur }); }
|
|
61
|
+
return points;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createWater(env: Environment, w: number, h: number, rnd: RandomSource): Point[] {
|
|
65
|
+
const res: Point[] = [];
|
|
66
|
+
if (env === 'coast') {
|
|
67
|
+
const edge = rnd() < 0.5 ? w - 1 : 0;
|
|
68
|
+
for (let y = 0; y < h; y += 1) { const d = 3 + Math.floor(rnd() * 3); for (let o = 0; o < d; o += 1) res.push({ x: edge === 0 ? o : w - 1 - o, y }); }
|
|
69
|
+
}
|
|
70
|
+
if (env === 'river') {
|
|
71
|
+
let x = Math.floor(w * (0.35 + rnd() * 0.3));
|
|
72
|
+
for (let y = 0; y < h; y += 1) { x = clamp(x + randomInt(rnd, -1, 1), 2, w - 3); res.push({ x, y }, { x: x + 1, y }); }
|
|
73
|
+
}
|
|
74
|
+
return res;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildingCells(buildings: Building[], plaza: Plaza): Set<string> {
|
|
78
|
+
const cells = new Set<string>();
|
|
79
|
+
buildings.forEach((b) => { for (let y = b.y - 2; y < b.y + b.height + 2; y += 1) for (let x = b.x - 2; x < b.x + b.width + 2; x += 1) cells.add(`${x},${y}`); });
|
|
80
|
+
for (let y = plaza.y - 2; y < plaza.y + plaza.height + 2; y += 1) for (let x = plaza.x - 2; x < plaza.x + plaza.width + 2; x += 1) cells.add(`${x},${y}`);
|
|
81
|
+
return cells;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getWildChance(env: Environment): number {
|
|
85
|
+
if (env === 'forest') return 0.12;
|
|
86
|
+
if (env === 'mountain') return 0.09;
|
|
87
|
+
return 0.07;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface WildContext { config: SettlementConfig; dimensions: { width: number; height: number }; random: RandomSource; water: Set<string>; buildings: Building[]; plaza: Plaza }
|
|
91
|
+
|
|
92
|
+
function createWild(ctx: WildContext): Point[] {
|
|
93
|
+
const res: Point[] = [];
|
|
94
|
+
const chance = getWildChance(ctx.config.environment);
|
|
95
|
+
const blocked = buildingCells(ctx.buildings, ctx.plaza);
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
const attempts = Math.floor(ctx.dimensions.width * ctx.dimensions.height * chance * 3);
|
|
98
|
+
for (let i = 0; i < attempts && res.length < Math.floor(ctx.dimensions.width * ctx.dimensions.height * chance); i += 1) {
|
|
99
|
+
const pt = { x: randomInt(ctx.random, 1, ctx.dimensions.width - 2), y: randomInt(ctx.random, 1, ctx.dimensions.height - 2) };
|
|
100
|
+
const key = `${pt.x},${pt.y}`;
|
|
101
|
+
if (!ctx.water.has(key) && !blocked.has(key) && !seen.has(key)) { seen.add(key); res.push(pt); }
|
|
102
|
+
}
|
|
103
|
+
return res;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const overlaps = (b: Building, o: Building): boolean => b.x - 1 < o.x + o.width && b.x + b.width + 1 > o.x && b.y - 1 < o.y + o.height && b.y + b.height + 1 > o.y;
|
|
107
|
+
const inPlaza = (b: Building, p: Plaza): boolean => b.x < p.x + p.width && b.x + b.width > p.x && b.y < p.y + p.height && b.y + b.height > p.y;
|
|
108
|
+
const plazaCenter = (p: Plaza): Point => ({ x: p.x + Math.floor(p.width / 2), y: p.y + Math.floor(p.height / 2) });
|
|
109
|
+
const buildingCenter = (b: Building): Point => ({ x: b.x + Math.floor(b.width / 2), y: b.y + Math.floor(b.height / 2) });
|
|
110
|
+
|
|
111
|
+
interface GridContext { spots: Point[]; size: SettlementSize; dimensions: { width: number; height: number }; plaza: Plaza; homes: number; variant: number }
|
|
112
|
+
|
|
113
|
+
function calcStagger(size: SettlementSize, variant: number, row: number, col: number): number {
|
|
114
|
+
if (size === 'town') return variant === 1 ? row % 2 : 0;
|
|
115
|
+
if (variant === 2) return col % 2;
|
|
116
|
+
return row % 2;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface LayoutMetrics { columns: number; xStep: number; yStep: number; startX: number; startY: number; xBias: number; yBias: number }
|
|
120
|
+
|
|
121
|
+
function getLayoutMetrics(ctx: GridContext): LayoutMetrics {
|
|
122
|
+
let columns = 7;
|
|
123
|
+
let xStep = 5;
|
|
124
|
+
let yStep = 4;
|
|
125
|
+
if (ctx.size === 'hamlet') { columns = 4; xStep = 6; yStep = 7; }
|
|
126
|
+
else if (ctx.size === 'village') { columns = 6; xStep = 5; yStep = 5; }
|
|
127
|
+
|
|
128
|
+
const rows = Math.ceil(Math.max(ctx.homes, columns) / columns);
|
|
129
|
+
const startX = Math.max(2, Math.floor(plazaCenter(ctx.plaza).x - ((columns - 1) * xStep + 4) / 2));
|
|
130
|
+
const startY = Math.max(2, Math.floor(plazaCenter(ctx.plaza).y - ((rows - 1) * yStep + 3) / 2));
|
|
131
|
+
const xBias = [0, 1, -1][ctx.variant % 3]!;
|
|
132
|
+
const yBias = [0, -1, 1][(ctx.variant + 1) % 3]!;
|
|
133
|
+
return { columns, xStep, yStep, startX, startY, xBias, yBias };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function addGridSpots(ctx: GridContext): void {
|
|
137
|
+
const m = getLayoutMetrics(ctx);
|
|
138
|
+
const rows = Math.ceil(Math.max(ctx.homes, m.columns) / m.columns);
|
|
139
|
+
for (let r = 0; r < rows; r += 1) {
|
|
140
|
+
for (let c = 0; c < m.columns; c += 1) {
|
|
141
|
+
const stagger = calcStagger(ctx.size, ctx.variant, r, c);
|
|
142
|
+
const extraY = ctx.variant === 2 ? c % 2 : m.yBias;
|
|
143
|
+
const spot = { x: m.startX + c * m.xStep + stagger + m.xBias, y: m.startY + r * m.yStep + extraY };
|
|
144
|
+
if (spot.x + 4 < ctx.dimensions.width - 1 && spot.y + 3 < ctx.dimensions.height - 1) ctx.spots.push(spot);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getHamletAnchors(plaza: Plaza, variant: number): Point[] {
|
|
150
|
+
const raw = [{ x: plaza.x - 5, y: plaza.y - 4 }, { x: plaza.x + plaza.width + 1, y: plaza.y - 4 }, { x: plaza.x - 5, y: plaza.y + plaza.height + 1 }, { x: plaza.x + plaza.width + 1, y: plaza.y + plaza.height + 1 }];
|
|
151
|
+
return raw.map((_, i) => raw[(i + variant) % raw.length]!);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function candidateSpots(ctx: Omit<GridContext, 'spots'>): Point[] {
|
|
155
|
+
const spots: Point[] = [];
|
|
156
|
+
if (ctx.size === 'hamlet') spots.push(...getHamletAnchors(ctx.plaza, ctx.variant));
|
|
157
|
+
addGridSpots({ ...ctx, spots });
|
|
158
|
+
return spots;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildingPattern(style: SettlementStyle, index: number, variant: number): { width: number; height: number } {
|
|
162
|
+
const patterns: Record<SettlementStyle, { width: number; height: number }[]> = {
|
|
163
|
+
timber: [{ width: 3, height: 2 }, { width: 4, height: 2 }, { width: 3, height: 3 }, { width: 2, height: 2 }],
|
|
164
|
+
stone: [{ width: 4, height: 2 }, { width: 3, height: 3 }, { width: 4, height: 3 }, { width: 3, height: 2 }],
|
|
165
|
+
coastal: [{ width: 4, height: 2 }, { width: 3, height: 2 }, { width: 4, height: 3 }, { width: 2, height: 2 }],
|
|
166
|
+
highland: [{ width: 3, height: 3 }, { width: 3, height: 2 }, { width: 2, height: 3 }, { width: 4, height: 2 }],
|
|
167
|
+
medieval: [{ width: 4, height: 2 }, { width: 3, height: 3 }, { width: 4, height: 3 }, { width: 3, height: 2 }],
|
|
168
|
+
edo: [{ width: 4, height: 2 }, { width: 3, height: 2 }, { width: 4, height: 2 }, { width: 3, height: 3 }],
|
|
169
|
+
sahelian: [{ width: 3, height: 2 }, { width: 4, height: 2 }, { width: 3, height: 3 }, { width: 4, height: 2 }],
|
|
170
|
+
};
|
|
171
|
+
return patterns[style][(index + variant) % patterns[style].length]!;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function servicePlacement(service: ServiceType, rnd: RandomSource): 'center' | 'edge' | 'mixed' {
|
|
175
|
+
if (/hall|town|ayunt/i.test(service)) return rnd() < 0.82 ? 'center' : 'mixed';
|
|
176
|
+
if (/stable|smith|forge|establ/i.test(service)) return rnd() < 0.8 ? 'edge' : 'mixed';
|
|
177
|
+
if (/temple|market|tavern/i.test(service)) return rnd() < 0.7 ? 'center' : 'mixed';
|
|
178
|
+
return 'mixed';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function servicePriority(service: ServiceType): number {
|
|
182
|
+
if (/hall|town|ayunt/i.test(service)) return 0;
|
|
183
|
+
if (/temple|market|tavern/i.test(service)) return 1;
|
|
184
|
+
if (/stable|smith|forge|establ/i.test(service)) return 2;
|
|
185
|
+
return 3;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ScoreContext { building: Building; placement: 'center' | 'edge' | 'mixed'; dim: { width: number; height: number }; plaza: Plaza; rnd: RandomSource }
|
|
189
|
+
|
|
190
|
+
function calcPreference(placement: 'center' | 'edge' | 'mixed', centerDist: number, edgeDist: number): number {
|
|
191
|
+
if (placement === 'edge') return edgeDist;
|
|
192
|
+
if (placement === 'center') return centerDist;
|
|
193
|
+
return centerDist * 0.5 + edgeDist * 0.5;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function serviceScore(ctx: ScoreContext): number {
|
|
197
|
+
const c = buildingCenter(ctx.building);
|
|
198
|
+
const centerDist = Math.hypot(c.x - plazaCenter(ctx.plaza).x, c.y - plazaCenter(ctx.plaza).y) / Math.hypot(ctx.dim.width, ctx.dim.height);
|
|
199
|
+
const edgeDist = Math.min(c.x, c.y, ctx.dim.width - c.x, ctx.dim.height - c.y) / Math.min(ctx.dim.width, ctx.dim.height);
|
|
200
|
+
return calcPreference(ctx.placement, centerDist, edgeDist) + ctx.rnd() * 0.22;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface AssignContext { config: SettlementConfig; buildings: Building[]; dimensions: { width: number; height: number }; plaza: Plaza; variant: number }
|
|
204
|
+
|
|
205
|
+
function assignServices(ctx: AssignContext): Building[] {
|
|
206
|
+
const rnd = createRandom(`${ctx.config.seed}:${ctx.config.size}:${ctx.config.style}:services:${ctx.variant}`);
|
|
207
|
+
const available = [...ctx.buildings];
|
|
208
|
+
const services = [...ctx.config.services].sort((a, b) => servicePriority(a) - servicePriority(b) || rnd() - 0.5);
|
|
209
|
+
services.forEach((service) => {
|
|
210
|
+
if (!available.length) return;
|
|
211
|
+
const placement = servicePlacement(service, rnd);
|
|
212
|
+
const ranked = available.map((building) => ({ building, score: serviceScore({ building, placement, dim: ctx.dimensions, plaza: ctx.plaza, rnd }) })).sort((a, b) => a.score - b.score);
|
|
213
|
+
const selected = ranked[0]!.building;
|
|
214
|
+
selected.service = service;
|
|
215
|
+
available.splice(available.indexOf(selected), 1);
|
|
216
|
+
});
|
|
217
|
+
return ctx.buildings;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function placeBuildings(config: SettlementConfig, placement: { dimensions: { width: number; height: number }; plaza: Plaza; water: Set<string> }, variant: number): Building[] {
|
|
221
|
+
const buildings: Building[] = [];
|
|
222
|
+
const { dimensions, plaza, water } = placement;
|
|
223
|
+
const spots = candidateSpots({ size: config.size, dimensions, plaza, homes: config.homes, variant });
|
|
224
|
+
for (const [i, spot] of spots.entries()) {
|
|
225
|
+
if (buildings.length >= config.homes) break;
|
|
226
|
+
const pat = buildingPattern(config.style, i, variant);
|
|
227
|
+
const b: Building = { id: `building-${buildings.length + 1}`, x: spot.x, y: spot.y, width: pat.width, height: pat.height, service: null };
|
|
228
|
+
const occupied = Array.from({ length: b.height }, (_, r) => Array.from({ length: b.width }, (_, c) => water.has(`${b.x + c},${b.y + r}`))).flat().some(Boolean);
|
|
229
|
+
if (!occupied && !inPlaza(b, plaza) && !buildings.some((o) => overlaps(b, o))) buildings.push(b);
|
|
230
|
+
}
|
|
231
|
+
return assignServices({ config, buildings, dimensions, plaza, variant });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createPaths(buildings: Building[], plaza: Plaza): PathRoute[] {
|
|
235
|
+
const anchor = plazaCenter(plaza);
|
|
236
|
+
return buildings.map((b) => ({ points: routeBetween(buildingCenter(b), anchor) }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function generateSettlement(input: Partial<SettlementConfig>): SettlementMap {
|
|
240
|
+
const config = normalizeConfig(input);
|
|
241
|
+
const dimensions = DIMENSIONS[config.size];
|
|
242
|
+
const plaza: Plaza = { x: Math.floor(dimensions.width / 2) - 3, y: Math.floor(dimensions.height / 2) - 2, width: 6, height: 4 };
|
|
243
|
+
const random = createRandom(config.seed);
|
|
244
|
+
const layoutVariant = Math.floor(createRandom(`${config.seed}:${config.size}:${config.style}:layout`)() * 3);
|
|
245
|
+
const water = createWater(config.environment, dimensions.width, dimensions.height, random);
|
|
246
|
+
const waterSet = new Set(water.map((p) => `${p.x},${p.y}`));
|
|
247
|
+
const buildings = placeBuildings(config, { dimensions, plaza, water: waterSet }, layoutVariant);
|
|
248
|
+
return { config, width: dimensions.width, height: dimensions.height, plaza, buildings, paths: createPaths(buildings, plaza), water, wild: createWild({ config, dimensions, random, water: waterSet, buildings, plaza }) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getPlaza(map: SettlementMap): Plaza { return map.plaza ?? { x: Math.floor(map.width / 2) - 3, y: Math.floor(map.height / 2) - 2, width: 6, height: 4 }; }
|
|
252
|
+
|
|
253
|
+
export function addBuilding(map: SettlementMap, point: Point, service: ServiceType | null = null): SettlementMap {
|
|
254
|
+
const b: Building = { id: `building-${map.buildings.length + 1}`, x: clamp(point.x, 1, map.width - 4), y: clamp(point.y, 1, map.height - 3), width: 3, height: 2, service };
|
|
255
|
+
if (map.buildings.some((other) => overlaps(b, other)) || inPlaza(b, getPlaza(map))) return map;
|
|
256
|
+
const buildings = [...map.buildings, b];
|
|
257
|
+
return { ...map, buildings, paths: createPaths(buildings, getPlaza(map)) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function addPath(map: SettlementMap, point: Point): SettlementMap {
|
|
261
|
+
const path = { points: [{ x: clamp(point.x, 0, map.width - 1), y: clamp(point.y, 0, map.height - 1) }, { x: clamp(point.x + 2, 0, map.width - 1), y: clamp(point.y, 0, map.height - 1) }] };
|
|
262
|
+
return { ...map, paths: [...map.paths, path] };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function addWater(map: SettlementMap, point: Point): SettlementMap {
|
|
266
|
+
const waterPoint = { x: clamp(point.x, 0, map.width - 1), y: clamp(point.y, 0, map.height - 1) };
|
|
267
|
+
const key = `${waterPoint.x},${waterPoint.y}`;
|
|
268
|
+
if (map.water.some((item) => `${item.x},${item.y}` === key)) return map;
|
|
269
|
+
const buildings = map.buildings.filter((b) => waterPoint.x < b.x || waterPoint.x >= b.x + b.width || waterPoint.y < b.y || waterPoint.y >= b.y + b.height);
|
|
270
|
+
const wild = map.wild.filter((item) => item.x !== waterPoint.x || item.y !== waterPoint.y);
|
|
271
|
+
const water = [...map.water, waterPoint];
|
|
272
|
+
return { ...map, buildings, wild, water, paths: createPaths(buildings, getPlaza(map)) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function addTree(map: SettlementMap, point: Point): SettlementMap {
|
|
276
|
+
const tree = { x: clamp(point.x, 0, map.width - 1), y: clamp(point.y, 0, map.height - 1) };
|
|
277
|
+
if (map.wild.some((item) => item.x === tree.x && item.y === tree.y)) return map;
|
|
278
|
+
return { ...map, wild: [...map.wild, tree] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function eraseAt(map: SettlementMap, point: Point): SettlementMap {
|
|
282
|
+
const plaza = getPlaza(map);
|
|
283
|
+
const buildings = map.buildings.filter((b) => point.x < b.x || point.x >= b.x + b.width || point.y < b.y || point.y >= b.y + b.height);
|
|
284
|
+
const water = map.water.filter((item) => item.x !== point.x || item.y !== point.y);
|
|
285
|
+
const paths = map.paths.filter((path) => !path.points.some((item) => item.x === point.x && item.y === point.y));
|
|
286
|
+
const wild = map.wild.filter((item) => item.x !== point.x || item.y !== point.y);
|
|
287
|
+
return { ...map, plaza, buildings, water, paths, wild };
|
|
288
|
+
}
|