Skip to content

Commit 5256ad6

Browse files
feat: say each tip once and let pictures do the rest
Several screens repeated what the player had already seen. On touch, wave 1 showed MOVE and AIM + SHOOT in the center while the ghost sticks below said MOVE and AIM + FIRE, and the right stick's label sat on the shop stall on a landscape phone. The stall said BUMP THE STALL TO SHOP, CLOSED DURING WAVES, or OPENS AFTER WAVE 1 every time the player walked by. Every endless break said THEY GOT BEEFIER with a health line, and the character picker opened with a line of text every time. - On touch the ghost sticks alone teach moving and shooting; the center hints for them show only with a keyboard and mouse. - Firing is SHOOT everywhere in the hints: the stick label is now AIM + SHOOT. - The ghost stick labels sit above their circles, out on the floor, so they never cover the stall or the gun slots. - The stall's words show until the player has shopped once (the saved shop lesson). After that its SHOP or CLOSED sign and the bobbing coin do the talking. - Only the first endless wave of a run says they got beefier. Later breaks show the wave number beside a green heart with a plus. - The character picker's blurb is now for screen readers only.
1 parent 503983f commit 5256ad6

10 files changed

Lines changed: 156 additions & 58 deletions

‎src/characterView.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,7 @@ const rowsView = (
315315
// DIALOG
316316

317317
const CHARACTER_TITLE = 'Character'
318-
const CHARACTER_BLURB =
319-
'Pick your look. It sticks around until you close the game.'
318+
const CHARACTER_BLURB = 'Pick your look.'
320319

321320
const panelView = (
322321
picker: CharacterPicker,
@@ -328,7 +327,7 @@ const panelView = (
328327
[h.Class('look-header')],
329328
[
330329
h.h2([...title, h.Class('look-title')], [CHARACTER_TITLE]),
331-
h.p([...description, h.Class('look-blurb')], [CHARACTER_BLURB]),
330+
h.p([...description, h.Class('sr-only')], [CHARACTER_BLURB]),
332331
],
333332
),
334333
h.div(

‎src/endless.story.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
Trap,
2424
initialModel,
2525
} from './model'
26+
import { endlessBannerOf } from './screens'
2627
import { scoreOf } from './stats'
2728
import { bulletInto, press, scoring, tick, zombieAt } from './testing'
2829
import {
@@ -392,6 +393,23 @@ describe('keep going from Victory', () => {
392393
)
393394
})
394395

396+
test('only the first endless wave of a run says they got beefier, later ones show a heart pip', () => {
397+
story(
398+
update,
399+
given(victorious),
400+
...waitOutGrace(),
401+
message(Message.ClickedKeepGoingButton()),
402+
model(({ phase }) => {
403+
expect(Option.map(preparationWave(phase), endlessBannerOf)).toEqual(
404+
Option.some('Explained'),
405+
)
406+
}),
407+
)
408+
expect(endlessBannerOf(FINAL_WAVE)).toBe('None')
409+
expect(endlessBannerOf(FINAL_WAVE + 2)).toBe('Pip')
410+
expect(endlessBannerOf(FINAL_WAVE + 7)).toBe('Pip')
411+
})
412+
395413
test('Enter on Victory still starts a fresh campaign', () => {
396414
story(
397415
update,

‎src/onboarding.story.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,9 @@ describe('tutorial hints', () => {
449449
releaseFire,
450450
...ticks(SHOWN_TICKS, TICK_MS),
451451
model(current => {
452-
expect(hintIds(current)).toEqual(['Move', 'Fire'])
452+
expect(isLearned(current.lessons.move)).toBe(false)
453+
expect(isLearned(current.lessons.fire)).toBe(false)
454+
expect(shownGhostSticks(current)).toEqual(['Move', 'Aim'])
453455
}),
454456
)
455457
})

‎src/onboarding.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,20 +431,25 @@ const isTrapHintFor = (model: Model, kind: 'Barricade' | 'Turret'): boolean =>
431431
TrapHint.isAnyOf(['Showing'])(model.trapHint) &&
432432
Option.contains(maybeSelectedKind(model), kind)
433433

434+
// NOTE: on touch the ghost sticks, drawn where the thumbs go, teach moving
435+
// and shooting, so the center hints for them show only with a keyboard.
436+
const isFirstWaveOnKeyboard = (model: Model): boolean =>
437+
isFirstWave(model.phase) && model.lastDevice === 'KeyboardMouse'
438+
434439
const HINT_RULES: ReadonlyArray<HintRule> = [
435440
{
436441
id: 'Move',
437442
maybeAgeMs: model =>
438443
ageWhen(
439-
isFirstWave(model.phase) && isPending(model.lessons.move),
444+
isFirstWaveOnKeyboard(model) && isPending(model.lessons.move),
440445
model.elapsedMs,
441446
),
442447
},
443448
{
444449
id: 'Fire',
445450
maybeAgeMs: model =>
446451
ageWhen(
447-
isFirstWave(model.phase) && isPending(model.lessons.fire),
452+
isFirstWaveOnKeyboard(model) && isPending(model.lessons.fire),
448453
model.elapsedMs,
449454
),
450455
},

‎src/ready.story.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
LESSON_MIN_SHOWN_MS,
2525
hintIcons,
2626
hintWords,
27+
shownGhostSticks,
2728
shownHints,
2829
} from './onboarding'
2930
import { accuracyPercent, totalKills } from './stats'
@@ -172,7 +173,7 @@ describe('onboarding hints', () => {
172173
)
173174
})
174175

175-
test('touch hints picture both thumbs and a tap on START WAVE', () => {
176+
test('on touch the ghost sticks teach moving and shooting, then a tap on START WAVE', () => {
176177
story(
177178
update,
178179
given(readyModel),
@@ -181,10 +182,8 @@ describe('onboarding hints', () => {
181182
message(Message.ClickedCampaignButton()),
182183
model(current => {
183184
expect(current.lastDevice).toBe('Touch')
184-
expect(hintTexts(current)).toEqual([
185-
'MoveStick MOVE',
186-
'AimStick AIM + SHOOT',
187-
])
185+
expect(shownGhostSticks(current)).toEqual(['Move', 'Aim'])
186+
expect(hintTexts(current)).toEqual([])
188187
}),
189188
touchDown(2, 100, 200),
190189
message(
@@ -194,7 +193,8 @@ describe('onboarding hints', () => {
194193
message(Message.ReleasedPointer({ pointerId: 2 })),
195194
...ticks(SHOWN_TICKS, TICK_MS),
196195
model(current => {
197-
expect(hintTexts(current)).toEqual(['AimStick AIM + SHOOT'])
196+
expect(shownGhostSticks(current)).toEqual(['Aim'])
197+
expect(hintTexts(current)).toEqual([])
198198
}),
199199
touchDown(3, 700, 200),
200200
message(

‎src/screens.ts‎

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,7 @@ const BEEFIER = bakeText('THEY GOT BEEFIER', 'Fresh')
315315
const healthBoostText = (wave: number): string =>
316316
`ZOMBIES HAVE +${Math.round((hpScaleFor(wave) - 1) * 100)}% HEALTH`
317317

318-
const HEALTH_BOOSTS: ReadonlyArray<BakedText> = Array.makeBy(
319-
BAKED_WAVES + 1,
320-
wave => bakeText(healthBoostText(wave), 'Prompt'),
321-
)
318+
const FIRST_ENDLESS_HEALTH = bakeText(healthBoostText(FINAL_WAVE + 1), 'Prompt')
322319

323320
type KindBanner = Readonly<{ title: BakedText; tagline: BakedText }>
324321

@@ -351,12 +348,19 @@ const bannerAt = (
351348
}),
352349
]
353350

354-
const newKindBanner = (
351+
const bakedFor = (
352+
list: ReadonlyArray<BakedText>,
353+
index: number,
354+
fallback: () => BakedText,
355+
): BakedText => Option.getOrElse(Array.get(list, index), fallback)
356+
357+
const iconBanner = (
355358
band: HintBand,
356-
kind: ZombieKind,
359+
icon: Sprite,
360+
title: BakedText,
361+
maybeTagline: Option.Option<BakedText>,
357362
shownMs: number,
358363
): ReadonlyArray<Canvas.Shape> => {
359-
const { title, tagline } = KIND_BANNERS[kind]
360364
const hop =
361365
shownMs < NEW_KIND_HOP_MS
362366
? 1 + 0.5 * Math.sin((Math.PI * shownMs) / NEW_KIND_HOP_MS)
@@ -371,34 +375,80 @@ const newKindBanner = (
371375
y: BAND_TOP[band] + 5,
372376
},
373377
scale: { x: scale, y: scale },
374-
shapes: ZOMBIE_SPRITES[kind].icon,
378+
shapes: icon,
375379
}),
376380
placeBaked(title, {
377381
x: titleX,
378382
y: BAND_TOP[band],
379383
align: 'Center',
380384
scale: TITLE_SCALE,
381385
}),
382-
centered(tagline, BAND_TOP[band] + SUBTITLE_OFFSET),
386+
...Option.match(maybeTagline, {
387+
onNone: () => [],
388+
onSome: tagline => [centered(tagline, BAND_TOP[band] + SUBTITLE_OFFSET)],
389+
}),
383390
]
384391
}
385392

393+
const newKindBanner = (
394+
band: HintBand,
395+
kind: ZombieKind,
396+
shownMs: number,
397+
): ReadonlyArray<Canvas.Shape> => {
398+
const { title, tagline } = KIND_BANNERS[kind]
399+
return iconBanner(
400+
band,
401+
ZOMBIE_SPRITES[kind].icon,
402+
title,
403+
Option.some(tagline),
404+
shownMs,
405+
)
406+
}
407+
408+
// NOTE: the first endless wave of a run says the zombies got tougher. Every
409+
// later one shows only its number beside the beefy heart.
410+
export type EndlessBanner = 'None' | 'Explained' | 'Pip'
411+
412+
export const endlessBannerOf = (wave: number): EndlessBanner => {
413+
const depth = endlessDepth(wave)
414+
if (depth === 0) {
415+
return 'None'
416+
} else if (depth === 1) {
417+
return 'Explained'
418+
} else {
419+
return 'Pip'
420+
}
421+
}
422+
423+
const BEEFY_HEART: Sprite = spriteShapes(
424+
['.GG.GG.', 'GWGGGGG', 'GGGYGGG', 'GGYYYGG', '.GGYGG.', '..GGG..', '...G...'],
425+
{ G: '#6fae3f', W: '#c4ef9a', Y: '#ffe066' },
426+
)
427+
386428
const beefierBanner = (
387429
band: HintBand,
388430
wave: number,
389431
shownMs: number,
390-
): ReadonlyArray<Canvas.Shape> =>
391-
endlessDepth(wave) > 0 && shownMs < NEW_KIND_BANNER_MS
392-
? bannerAt(
393-
band,
394-
BEEFIER,
395-
Option.some(
396-
Option.getOrElse(Array.get(HEALTH_BOOSTS, wave), () =>
397-
bakeText(healthBoostText(wave), 'Prompt'),
398-
),
399-
),
400-
)
401-
: []
432+
): ReadonlyArray<Canvas.Shape> => {
433+
if (shownMs >= NEW_KIND_BANNER_MS) {
434+
return []
435+
}
436+
437+
const banner = endlessBannerOf(wave)
438+
if (banner === 'Explained') {
439+
return bannerAt(band, BEEFIER, Option.some(FIRST_ENDLESS_HEALTH))
440+
} else if (banner === 'Pip') {
441+
return iconBanner(
442+
band,
443+
BEEFY_HEART,
444+
bakedFor(WAVE_TITLES, wave, () => bakeText(`WAVE ${wave}`, 'Defeat')),
445+
Option.none(),
446+
shownMs,
447+
)
448+
} else {
449+
return []
450+
}
451+
}
402452

403453
const debutBanner = (
404454
band: HintBand,
@@ -411,12 +461,6 @@ const debutBanner = (
411461
shownMs < NEW_KIND_BANNER_MS ? newKindBanner(band, kind, shownMs) : [],
412462
})
413463

414-
const bakedFor = (
415-
list: ReadonlyArray<BakedText>,
416-
index: number,
417-
fallback: () => BakedText,
418-
): BakedText => Option.getOrElse(Array.get(list, index), fallback)
419-
420464
const preparationBanner = (
421465
model: Model,
422466
wave: number,

‎src/shop.story.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type Bullet,
2626
type CarriedGun,
2727
GameEvent,
28+
Lesson,
2829
type Model,
2930
Phase,
3031
Pierce,
@@ -912,6 +913,40 @@ describe('the stall entrance', () => {
912913
}),
913914
)
914915
})
916+
917+
test('once the player has shopped, the stall shows no words, only its sign and beacon', () => {
918+
const shopped = modifyFields(readyModel, {
919+
lessons: lessons =>
920+
modifyFields(lessons, { shop: () => Lesson.Learned({ atMs: 0 }) }),
921+
})
922+
const near = (phase: Phase): Model =>
923+
at(modifyFields(shopped, { phase: () => phase }), 225, 160)
924+
story(
925+
update,
926+
given(near(preparing(2))),
927+
tick(FRAME_MS),
928+
model(current => {
929+
expect(ShopState.guards.Away(current.shop)).toBe(true)
930+
expect(stallPrompt(current)).toBe('None')
931+
}),
932+
)
933+
story(
934+
update,
935+
given(near(fighting(3))),
936+
tick(FRAME_MS),
937+
model(current => {
938+
expect(stallPrompt(current)).toBe('None')
939+
}),
940+
)
941+
story(
942+
update,
943+
given(near(preparing(1))),
944+
tick(FRAME_MS),
945+
model(current => {
946+
expect(stallPrompt(current)).toBe('None')
947+
}),
948+
)
949+
})
915950
})
916951

917952
describe('shop categories', () => {

‎src/shop.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import {
1919
Arsenal,
2020
type CarriedGun,
21+
Lesson,
2122
type Model,
2223
Phase,
2324
type Player,
@@ -980,9 +981,15 @@ export type StallPrompt =
980981
const closedPrompt = (phase: Phase): StallPrompt =>
981982
Phase.isAnyOf(['Wave'])(phase) ? 'ClosedDuringWave' : 'ClosedUntilFirstClear'
982983

984+
// NOTE: the words teach the stall once. After the player has shopped, its
985+
// sign and the bobbing coin over it say the same without words.
983986
export const stallPrompt = (model: Model): StallPrompt => {
984-
const { phase, player, shop } = model
985-
if (!Phase.isAnyOf(['Preparation', 'Wave'])(phase) || !isNearStall(player)) {
987+
const { phase, player, shop, lessons } = model
988+
if (
989+
!Phase.isAnyOf(['Preparation', 'Wave'])(phase) ||
990+
!isNearStall(player) ||
991+
Lesson.isAnyOf(['Learned'])(lessons.shop)
992+
) {
986993
return 'None'
987994
} else if (!isShopOpenFor(phase)) {
988995
return closedPrompt(phase)

‎src/styles.css‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -511,12 +511,6 @@ body {
511511
font: bold 18px monospace;
512512
}
513513

514-
.look-blurb {
515-
margin: 4px 0 0;
516-
color: #b8c2b0;
517-
font-size: 12px;
518-
}
519-
520514
.look-body {
521515
display: grid;
522516
grid-template-columns: auto 1fr;
@@ -664,10 +658,6 @@ body {
664658
font-size: 16px;
665659
}
666660

667-
.look-blurb {
668-
display: none;
669-
}
670-
671661
.look-body {
672662
gap: 12px;
673663
}
@@ -784,10 +774,12 @@ body {
784774
background: rgba(255, 224, 102, 0.5);
785775
}
786776

777+
/* NOTE: the label sits above its ghost, out on the floor, so on a landscape
778+
phone it never covers the stall or the gun slots in the bottom corners. */
787779
.ghost-stick-label {
788780
position: absolute;
789-
top: 100%;
790-
margin-top: 6px;
781+
bottom: 100%;
782+
margin-bottom: 6px;
791783
padding: 2px 6px;
792784
border-radius: 4px;
793785
background: rgba(8, 10, 12, 0.7);
@@ -1229,10 +1221,6 @@ body {
12291221
padding: 12px;
12301222
}
12311223

1232-
.look-blurb {
1233-
display: none;
1234-
}
1235-
12361224
.look-body {
12371225
grid-template-columns: 1fr;
12381226
gap: 8px;

0 commit comments

Comments
 (0)