@rpg-engine/long-bow 0.8.235 → 0.8.237

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpg-engine/long-bow",
3
- "version": "0.8.235",
3
+ "version": "0.8.237",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -30,7 +30,10 @@
30
30
  "storybook": "start-storybook -p 6006",
31
31
  "build-storybook": "build-storybook",
32
32
  "configure": "./environment/download-credentials.sh",
33
- "update:shared-deps": "yarn add @rpg-engine/shared"
33
+ "update:shared-deps": "yarn add @rpg-engine/shared",
34
+ "preview:spellbook": "vite --config scripts/preview-spellbook/vite.config.ts",
35
+ "verify:spellbook": "jest --config jest.spellbook.config.js --runInBand && tsc -p tsconfig.spellbook.json",
36
+ "verify": "yarn verify:spellbook && tsc --noEmit"
34
37
  },
35
38
  "peerDependencies": {
36
39
  "react": ">=16"
@@ -79,7 +82,14 @@
79
82
  "tsdx": "^0.14.1",
80
83
  "tslib": "^2.3.1",
81
84
  "typescript": "^4.6.2",
82
- "uuid": "^8.3.2"
85
+ "uuid": "^8.3.2",
86
+ "@swc/jest": "0.2.29",
87
+ "@testing-library/react": "12.1.5",
88
+ "jest": "29.7.0",
89
+ "jest-environment-jsdom": "29.7.0",
90
+ "vite": "2.9.16",
91
+ "@vitejs/plugin-react": "1.3.2",
92
+ "@swc/core": "1.3.94"
83
93
  },
84
94
  "dependencies": {
85
95
  "@capacitor/core": "^6.1.0",
@@ -85,13 +85,16 @@ export const ShortcutsSetter: React.FC<ShortcutsSetterProps> = ({
85
85
  {Array.from({ length: 12 }).map((_, i) => (
86
86
  <Shortcut
87
87
  key={i}
88
- onPointerDown={() => {
88
+ type="button"
89
+ aria-label={`${shortcuts[i]?.payload ? 'Clear' : 'Set'} shortcut ${i + 1}`}
90
+ onClick={() => {
89
91
  if (settingShortcutIndex !== -1) setSettingShortcutIndex(-1);
90
92
 
93
+ const wasEmpty = !shortcuts[i] || shortcuts[i].type === ShortcutType.None;
91
94
  removeShortcut(i);
92
95
  if (
93
96
  settingShortcutIndex === -1 &&
94
- (!shortcuts[i] || shortcuts[i].type === ShortcutType.None)
97
+ wasEmpty
95
98
  )
96
99
  setSettingShortcutIndex(i);
97
100
  }}
@@ -99,7 +102,7 @@ export const ShortcutsSetter: React.FC<ShortcutsSetterProps> = ({
99
102
  isBeingSet={settingShortcutIndex === i}
100
103
  id={`shortcutSetter_${i}`}
101
104
  >
102
- {getContent(i)}
105
+ {getContent(i) || i + 1}
103
106
  </Shortcut>
104
107
  ))}
105
108
  </List>
@@ -1,8 +1,6 @@
1
- import { ISpell } from '@rpg-engine/shared';
1
+ import { ISpell, ItemRarities } from '@rpg-engine/shared';
2
2
  import React from 'react';
3
- import styled from 'styled-components';
4
- import { uiColors } from '../../constants/uiColors';
5
- import { uiFonts } from '../../constants/uiFonts';
3
+ import styled, { css } from 'styled-components';
6
4
  import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
7
5
  import { SpellInfoWrapper } from './cards/SpellInfoWrapper';
8
6
 
@@ -18,271 +16,234 @@ export interface ISpellProps {
18
16
  spellKey: string;
19
17
  spell: ISpell;
20
18
  activeCooldown?: number;
19
+ // Rarity the character's copy of this spell was learned at. Absent means unknown, never Common.
20
+ rarity?: ItemRarities;
21
21
  }
22
22
 
23
- const SKILL_NAMES: Record<string, string> = {
24
- magicLevel: 'magic level',
25
- distanceFighting: 'distance fighting',
26
- swordFighting: 'sword fighting',
27
- axeFighting: 'axe fighting',
28
- clubFighting: 'club fighting',
29
- shielding: 'shielding',
30
- fishing: 'fishing',
31
- cooking: 'cooking',
32
- fistFighting: 'fist fighting',
33
- };
34
-
35
- const getSkillName = (attribute: string): string => {
36
- return SKILL_NAMES[attribute] || attribute;
37
- };
23
+ // 'strength' -> 'Strength', 'magicResistance' -> 'Magic Resistance'
24
+ const skillLabel = (skill: string): string =>
25
+ skill.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\b[a-z]/g, letter => letter.toUpperCase());
38
26
 
39
27
  export const Spell: React.FC<ISpellProps> = ({
40
- atlasIMG,
41
- atlasJSON,
42
- spellKey,
43
- charMana,
44
- charMagicLevel,
45
- charSkillLevels,
46
- onPointerUp,
47
- onClick,
48
- isSettingShortcut,
49
- spell,
50
- activeCooldown,
28
+ atlasIMG, atlasJSON, spellKey, charMana, charMagicLevel, charSkillLevels,
29
+ onPointerUp, onClick, isSettingShortcut, spell, activeCooldown = 0, rarity,
51
30
  }) => {
52
- const {
53
- manaCost,
54
- minMagicLevelRequired,
55
- minSkillLevelRequired,
56
- attribute,
57
- magicWords,
58
- name,
59
- description,
60
- } = spell;
61
-
62
- const getRequiredLevel = (): number => {
63
- return minSkillLevelRequired ?? minMagicLevelRequired;
64
- };
65
-
66
- const getCharacterSkillLevel = (): number => {
67
- if (attribute && charSkillLevels) {
68
- return charSkillLevels[attribute] ?? 0;
69
- }
70
- return charMagicLevel;
71
- };
72
-
73
- const requiredLevel = getRequiredLevel();
74
- const characterSkillLevel = getCharacterSkillLevel();
75
- const disabled = isSettingShortcut
76
- ? characterSkillLevel < requiredLevel
77
- : manaCost > charMana || characterSkillLevel < requiredLevel;
78
-
79
- const CONTAINER_STYLE = { width: '32px', height: '32px' };
80
- const IMAGE_SCALE = 2;
81
-
31
+ const { manaCost, minMagicLevelRequired, minSkillLevelRequired, attribute, magicWords, name, description } = spell;
32
+ const usesAttribute = !!attribute && minSkillLevelRequired !== undefined;
33
+ const requiredLevel = usesAttribute ? minSkillLevelRequired! : minMagicLevelRequired;
34
+ const skillLevel = usesAttribute && charSkillLevels ? charSkillLevels[attribute!] ?? 0 : charMagicLevel;
35
+ const skillName = usesAttribute ? attribute! : 'magic';
36
+ const lacksSkill = skillLevel < requiredLevel;
37
+ const lacksMana = !isSettingShortcut && manaCost > charMana;
38
+ const cooling = !isSettingShortcut && activeCooldown > 0;
39
+ const unavailable = lacksSkill || lacksMana || cooling;
40
+ // An unmet requirement is already spelled out (and flagged) by its own row, so only mana, cooldown
41
+ // and shortcut assignment need a note. A ready card has none: clicking it casts.
42
+ const note = lacksSkill ? ''
43
+ : lacksMana ? 'Not enough mana'
44
+ : cooling ? `Ready in ${activeCooldown.toFixed(activeCooldown < 10 ? 1 : 0)}s`
45
+ : isSettingShortcut ? 'Select to assign' : '';
46
+ const knownRarity = rarity && Object.values(ItemRarities).includes(rarity) ? rarity : undefined;
47
+ const descriptionId = `spell-description-${spellKey}`;
48
+ const factsId = `spell-facts-${spellKey}`;
49
+ const statusId = `spell-status-${spellKey}`;
50
+ const rarityId = `spell-rarity-${spellKey}`;
82
51
  const handleClick = onClick ?? onPointerUp;
83
52
 
84
53
  return (
85
54
  <SpellInfoWrapper spell={spell}>
86
- <Container
87
- onClick={handleClick?.bind(null, spellKey)}
88
- isSettingShortcut={isSettingShortcut && !disabled}
55
+ <Card
56
+ type="button"
89
57
  className="spell"
58
+ aria-label={`${isSettingShortcut ? 'Assign' : 'Cast'} ${name}`}
59
+ aria-describedby={`${knownRarity ? `${rarityId} ` : ''}${descriptionId} ${factsId}${note ? ` ${statusId}` : ''} spell-details-${spellKey}`}
60
+ disabled={unavailable}
61
+ $assigning={!!isSettingShortcut}
62
+ onClick={() => handleClick?.(spellKey)}
90
63
  >
91
- {disabled && (
92
- <Overlay>
93
- {characterSkillLevel < requiredLevel
94
- ? `Low ${getSkillName(attribute || 'magic level')} level`
95
- : manaCost > charMana && 'No mana'}
96
- </Overlay>
97
- )}
98
- <SpellImage>
99
- {activeCooldown && activeCooldown > 0 ? (
100
- <span className="cooldown">
101
- {activeCooldown.toFixed(activeCooldown > 10 ? 0 : 1)}
102
- </span>
103
- ) : null}
104
- <SpriteFromAtlas
105
- atlasIMG={atlasIMG}
106
- atlasJSON={atlasJSON}
107
- spriteKey={spell.texturePath ?? ''}
108
- imgScale={IMAGE_SCALE}
109
- containerStyle={CONTAINER_STYLE}
110
- centered
111
- />
112
- </SpellImage>
113
- <Info>
114
- <Title>
115
- <span>{name}</span>
116
- <span className="spell">({magicWords})</span>
117
- </Title>
118
- <Description>{description}</Description>
119
- </Info>
120
-
121
- <Divider />
122
- <Cost>
123
- <span>Mana cost:</span>
124
- <span className="mana">{manaCost}</span>
125
- </Cost>
126
- </Container>
64
+ <Heading>
65
+ <Icon aria-hidden="true">
66
+ <SpriteFromAtlas atlasIMG={atlasIMG} atlasJSON={atlasJSON}
67
+ spriteKey={spell.texturePath ?? ''} imgScale={1.5}
68
+ width={40} height={40} centered />
69
+ </Icon>
70
+ <Name><strong>{name}</strong></Name>
71
+ </Heading>
72
+ <Meta>
73
+ <Words>{magicWords}</Words>
74
+ {knownRarity && <Rarity id={rarityId} data-rarity={knownRarity} $rarity={knownRarity}>{knownRarity}</Rarity>}
75
+ </Meta>
76
+ <Description id={descriptionId}>{description}</Description>
77
+ <Facts id={factsId}>
78
+ <Requirement $unmet={lacksSkill}>{`Requires ${skillLabel(skillName)} ${requiredLevel}`}</Requirement>
79
+ <Stats>
80
+ <Stat><Label>Mana cost</Label><Value $tone={lacksMana ? 'bad' : 'mana'}>{manaCost}</Value></Stat>
81
+ <Stat><Label>Cooldown</Label><Value>{`${spell.cooldown}s`}</Value></Stat>
82
+ </Stats>
83
+ </Facts>
84
+ {note && <Status id={statusId} $unavailable={unavailable}>{note}</Status>}
85
+ </Card>
127
86
  </SpellInfoWrapper>
128
87
  );
129
88
  };
130
89
 
131
- const Container = styled.button<{ isSettingShortcut?: boolean }>`
132
- display: block;
133
- background: none;
134
- border: 2px solid transparent;
135
- border-radius: 1rem;
90
+ const Card = styled.button<{ $assigning: boolean }>`
91
+ box-sizing: border-box;
92
+ display: flex;
93
+ flex-direction: column;
94
+ flex: 1 1 auto;
136
95
  width: 100%;
96
+ min-width: 0;
97
+ padding: 7px 8px 8px;
98
+ text-align: left;
99
+ color: #392718;
100
+ background: rgba(255, 247, 215, 0.5);
101
+ border: 1px solid ${({ $assigning }) => $assigning ? '#8a5a14' : 'rgba(89, 56, 28, .4)'};
102
+ box-shadow: ${({ $assigning }) => $assigning ? 'inset 0 0 0 1px #8a5a14' : 'none'};
103
+ border-radius: 3px;
104
+ font: inherit;
105
+ cursor: pointer;
106
+ transition: background .12s, border-color .12s;
107
+ &:hover:not(:disabled) { background: rgba(255, 252, 235, .9); border-color: #8a5a14; box-shadow: inset 0 0 0 1px #8a5a14; }
108
+ &:focus-visible { outline: 3px solid #3a2110; outline-offset: 2px; }
109
+ /* RPGUI greys and recolors every :disabled (0,2,0): keep the dashed card and its red/blue stats */
110
+ &&:disabled { cursor: default; background: rgba(113, 81, 49, .1); border-style: dashed; color: #392718; filter: none; }
111
+ &:disabled img, &:disabled canvas { opacity: .55; }
112
+ `;
113
+ const Heading = styled.span`
137
114
  display: flex;
138
-
139
- gap: 1rem;
140
115
  align-items: center;
141
- padding: 0 1rem;
142
- text-align: left;
143
- position: relative;
144
-
145
- animation: ${({ isSettingShortcut }) =>
146
- isSettingShortcut ? 'border-color-change 1s infinite' : 'none'};
147
-
148
- @keyframes border-color-change {
149
- 0% {
150
- border-color: ${uiColors.yellow};
151
- }
152
- 50% {
153
- border-color: transparent;
154
- }
155
- 100% {
156
- border-color: ${uiColors.yellow};
157
- }
158
- }
159
-
160
- &:hover,
161
- &:focus {
162
- background-color: ${uiColors.darkGray};
163
- }
164
-
165
- &:active {
166
- background: none;
167
- }
116
+ gap: 8px;
168
117
  `;
169
-
170
- const SpellImage = styled.div`
171
- width: 4rem;
172
- height: 4rem;
173
- font-size: ${uiFonts.size.xLarge};
174
- font-weight: bold;
175
- background-color: ${uiColors.darkGray};
176
- color: ${uiColors.lightGray};
118
+ const Icon = styled.span`
177
119
  display: flex;
178
- justify-content: center;
120
+ flex: 0 0 40px;
121
+ height: 40px;
179
122
  align-items: center;
180
- text-transform: uppercase;
181
- position: relative;
123
+ justify-content: center;
182
124
  overflow: hidden;
183
-
184
- .cooldown {
185
- position: absolute;
186
- top: 0;
187
- left: 0;
188
- width: 100%;
189
- height: 100%;
190
- background-color: rgba(0 0 0 / 20%);
191
- color: ${uiColors.darkYellow};
192
- font-weight: bold;
193
- display: flex;
194
- justify-content: center;
195
- align-items: center;
196
- }
125
+ background: rgba(72, 43, 21, .18);
126
+ border: 1px solid rgba(72, 43, 21, .35);
127
+ border-radius: 3px;
128
+ image-rendering: pixelated;
197
129
  `;
198
-
199
- const Info = styled.span`
200
- width: 0;
130
+ // The game client's RPGUI `.rpgui-content span` rule (0,1,1) forces white outlined 22px text;
131
+ // the doubled `&&` class (0,2,0) keeps the parchment typography below.
132
+ const Name = styled.span`
201
133
  flex: 1;
202
-
203
- @media (orientation: portrait) {
204
- display: none;
205
- }
134
+ min-width: 0;
135
+ strong { font-weight: normal; overflow-wrap: anywhere; }
136
+ /* the block's own font-size sets its line-box strut: match the name so two-line names stay 29px */
137
+ && { font-size: 11px; line-height: 1.3; color: #392718; text-shadow: none; }
206
138
  `;
207
- const Title = styled.p`
139
+ // One strip under the heading, so every card has the same rows whether or not it carries a rarity: the magic
140
+ // words on the left and, when the character's copy has a known rarity, its badge on the right. The badge
141
+ // drops to its own line only when the pair does not fit the card width.
142
+ const Meta = styled.span`
208
143
  display: flex;
209
144
  flex-wrap: wrap;
210
145
  align-items: center;
211
- margin-bottom: 5px;
212
- margin: 0;
213
-
214
- span {
215
- font-size: ${uiFonts.size.medium} !important;
216
- font-weight: bold !important;
217
- color: ${uiColors.yellow} !important;
218
- margin-right: 0.5rem;
219
- }
220
-
221
- .spell {
222
- font-size: ${uiFonts.size.small} !important;
223
- font-weight: normal !important;
224
- color: ${uiColors.lightGray} !important;
146
+ justify-content: space-between;
147
+ gap: 3px 6px;
148
+ margin-top: 5px;
149
+ /* RPGUI's span rule would give this wrapper a 22px line */
150
+ && { line-height: 1.4; }
151
+ `;
152
+ const Words = styled.span`
153
+ display: block;
154
+ min-width: 0;
155
+ overflow-wrap: anywhere;
156
+ && { font-size: 9px; line-height: inherit; color: #6b4d32; text-shadow: none; }
157
+ `;
158
+ // Item palette hues (Item/Inventory/ItemSlotRarity) with the type inked darker for >= 4.5:1 on parchment.
159
+ // The label always carries the rarity; colour only reinforces it.
160
+ const RARITY_INK: Record<ItemRarities, string> = {
161
+ [ItemRarities.Common]: '#5a3f27',
162
+ [ItemRarities.Uncommon]: '#1e5e1e',
163
+ [ItemRarities.Rare]: '#1d4580',
164
+ [ItemRarities.Epic]: '#6a1b8f',
165
+ [ItemRarities.Legendary]: '#7a4f00',
166
+ };
167
+ const Rarity = styled.span<{ $rarity: ItemRarities }>`
168
+ display: inline-block;
169
+ flex: none;
170
+ padding: 1px 5px;
171
+ border: 1px solid ${({ $rarity }) => RARITY_INK[$rarity]};
172
+ border-radius: 2px;
173
+ background: rgba(255, 250, 229, .7);
174
+ && { font-size: 9px; line-height: 1.3; color: ${({ $rarity }) => RARITY_INK[$rarity]}; text-shadow: none; }
175
+ `;
176
+ const Description = styled.span`
177
+ display: block;
178
+ margin: 6px 0 5px;
179
+ overflow-wrap: anywhere;
180
+ && { font-size: 10px; line-height: 1.5; color: #392718; text-shadow: none; }
181
+ /* Art book (> 960px, see FLAT in Spellbook.tsx): four cards across, all as tall as the tallest, so one long
182
+ description would stretch every card. Clamp to three lines; the full text stays in the DOM (aria-describedby)
183
+ and in the hover / touch-hold tooltip. Flat and phone layouts show it all. */
184
+ @media (min-width: 961px) {
185
+ display: -webkit-box;
186
+ -webkit-line-clamp: 3;
187
+ -webkit-box-orient: vertical;
188
+ overflow: hidden;
225
189
  }
226
190
  `;
227
-
228
- const Description = styled.div`
229
- font-size: ${uiFonts.size.small} !important;
230
- line-height: 1.1 !important;
191
+ // Sits straight under the description (a 5px margin, the rule and 5px of padding): the card is stretched to the
192
+ // tallest on the page, so a shorter card keeps its facts here and leaves the spare height below them.
193
+ const Facts = styled.span`
194
+ display: flex;
195
+ flex-direction: column;
196
+ gap: 4px;
197
+ padding-top: 5px;
198
+ border-top: 1px solid rgba(89, 56, 28, .25);
231
199
  `;
232
-
233
- const Divider = styled.div`
234
- width: 1px;
235
- height: 100%;
236
- margin: 0 1rem;
237
- background-color: ${uiColors.lightGray};
200
+ // An unmet requirement gets a red bar and tint as well as red type, so it never relies on hue alone.
201
+ const Requirement = styled.span<{ $unmet: boolean }>`
202
+ display: block;
203
+ && { font-size: 9px; line-height: 1.5; color: #392718; text-shadow: none; }
204
+ ${({ $unmet }) => $unmet && css`
205
+ padding: 3px 6px;
206
+ background: rgba(138, 42, 34, .12);
207
+ border-left: 3px solid #8a2a22;
208
+ border-radius: 2px;
209
+ && { color: #8a2a22; }
210
+ `}
238
211
  `;
239
-
240
- const Cost = styled.p`
212
+ const Stats = styled.span`
213
+ display: grid;
214
+ grid-template-columns: repeat(2, minmax(0, 1fr));
215
+ gap: 4px 8px;
216
+ `;
217
+ const Stat = styled.span`
241
218
  display: flex;
242
- align-items: center;
243
219
  flex-direction: column;
244
- gap: 0.5rem;
245
-
246
- div {
247
- z-index: 1;
248
- }
249
-
250
- .mana {
251
- position: relative;
252
- font-size: ${uiFonts.size.medium};
253
- font-weight: bold;
254
- z-index: 1;
255
-
256
- &::after {
257
- position: absolute;
258
- content: '';
259
- top: 0;
260
- left: 0;
261
- background-color: ${uiColors.blue};
262
- width: 100%;
263
- height: 100%;
264
- border-radius: 50%;
265
- transform: scale(1.8);
266
- filter: blur(10px);
267
- z-index: -1;
268
- }
269
- }
220
+ gap: 2px;
221
+ min-width: 0;
270
222
  `;
271
-
272
- const Overlay = styled.p`
273
- margin: 0 !important;
274
- position: absolute;
275
- top: 0;
276
- left: 0;
277
- width: 100%;
278
- height: 100%;
279
- border-radius: 1rem;
223
+ const Label = styled.span`
224
+ && { font-size: 9px; line-height: 1.4; color: #5a3f27; text-shadow: none; }
225
+ `;
226
+ const STAT_TONES = { mana: '#1d4580', bad: '#8a2a22' };
227
+ const Value = styled.span<{ $tone?: 'mana' | 'bad' }>`
228
+ && { font-size: 10px; line-height: 1.4; color: ${({ $tone }) => $tone ? STAT_TONES[$tone] : '#2a1708'}; text-shadow: none; }
229
+ `;
230
+ const Status = styled.span<{ $unavailable: boolean }>`
280
231
  display: flex;
281
- justify-content: center;
282
232
  align-items: center;
283
- color: ${uiColors.yellow};
284
- font-size: ${uiFonts.size.large} !important;
285
- font-weight: bold;
286
- z-index: 10;
287
- background-color: rgba(0 0 0 / 0.2);
233
+ gap: 6px;
234
+ margin-top: 4px;
235
+ && {
236
+ font-size: 9px;
237
+ line-height: 1.5;
238
+ color: ${({ $unavailable }) => $unavailable ? '#8a2a22' : '#4b4420'};
239
+ text-shadow: none;
240
+ }
241
+ &::before {
242
+ content: '';
243
+ flex: none;
244
+ width: 6px;
245
+ height: 6px;
246
+ background: currentColor;
247
+ clip-path: ${({ $unavailable }) => $unavailable ? 'none' : 'polygon(0 0, 100% 50%, 0 100%)'};
248
+ }
288
249
  `;