496 lines
20 KiB
JavaScript
496 lines
20 KiB
JavaScript
import Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
|
|
import { CAMPAIGNS } from '../data/levels/index.js';
|
|
import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js';
|
|
import { isLevelComplete, getBestScore } from '../util/progress.js';
|
|
import { playMenuTrack } from '../util/music.js';
|
|
import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js';
|
|
import { createPill, pillInteractive } from '../util/pill.js';
|
|
|
|
const NODE_HOVER = 1.12;
|
|
const NODE_IDLE_PULSE = 1.05;
|
|
|
|
// Render order, lowest to highest. The per-campaign group (bg -> tags) is
|
|
// rebuilt on every campaign switch; the persistent chrome must sit ABOVE it,
|
|
// because chrome is created *before* _buildCampaign and Phaser's stable
|
|
// same-depth sort would otherwise let the opaque campaign background cover it.
|
|
const DEPTH = { bg: 0, path: 1, flag: 2, node: 3, tag: 4, plate: 5, chrome: 6, tooltip: 10 };
|
|
|
|
const TITLE_STYLE = {
|
|
fontFamily: 'monospace',
|
|
fontSize: `${30 * WORLD_SCALE}px`,
|
|
color: '#1a1f29',
|
|
fontStyle: 'bold',
|
|
};
|
|
|
|
const PILL_STYLE = {
|
|
fontFamily: 'monospace',
|
|
// 13 (not 14) so the level-name tag under the current node stays compact
|
|
// even for campaigns with many levels; the best-score pills use their own
|
|
// SCORE_PILL_STYLE below.
|
|
fontSize: `${13 * WORLD_SCALE}px`,
|
|
color: '#1a1f29',
|
|
};
|
|
|
|
// Labels that share PILL_STYLE are pills (see util/pill.js) - createPill
|
|
// supplies the rounded white backing + black stroke and the guaranteed
|
|
// text/edge padding; only the font side lives here.
|
|
|
|
// The "best score" pill that rolls up under cleared nodes - gold, to match
|
|
// the gold score numerals in the score tally scene.
|
|
const SCORE_PILL_STYLE = {
|
|
fontFamily: 'monospace',
|
|
fontSize: `${14 * WORLD_SCALE}px`,
|
|
fontStyle: 'bold',
|
|
color: '#1a1f29',
|
|
backgroundColor: '#f2c14e',
|
|
stroke: '#ffffff',
|
|
strokeThickness: 2 * WORLD_SCALE,
|
|
padding: { x: 10 * WORLD_SCALE, y: 5 * WORLD_SCALE },
|
|
};
|
|
|
|
export default class LevelSelectScene extends Phaser.Scene {
|
|
constructor() {
|
|
super('LevelSelect');
|
|
}
|
|
|
|
create() {
|
|
this.cameras.main.setBackgroundColor('#8fc7e8');
|
|
// Menu music (main title or the campaign's own theme) is started by
|
|
// _buildCampaign -> playMenuTrack, which knows which campaign is being
|
|
// shown; it handles first-time start, campaign-to-campaign swaps, and
|
|
// re-entry from the main menu alike.
|
|
|
|
this.campaignIndex = 0;
|
|
this._groups = { bg: [], path: [], nodes: [], tags: [] };
|
|
this._transitioning = false;
|
|
this._pulseTween = null;
|
|
|
|
// Chrome (persistent across campaigns) - see DEPTH for why these need
|
|
// explicit depths above the per-campaign group.
|
|
this._titlePlate = this.add
|
|
.image(GAME_WIDTH / 2, 82 * WORLD_SCALE, 'map_campaign_tag')
|
|
.setOrigin(0.5)
|
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
|
|
.setDepth(DEPTH.plate);
|
|
this._titleText = this.add
|
|
.text(GAME_WIDTH / 2, 82 * WORLD_SCALE, '', TITLE_STYLE)
|
|
.setOrigin(0.5)
|
|
.setDepth(DEPTH.chrome);
|
|
this._pager = this.add.graphics().setDepth(DEPTH.chrome);
|
|
|
|
const back = createPill(this, 44 * WORLD_SCALE, GAME_HEIGHT - 44 * WORLD_SCALE, '< Menu', { ...PILL_STYLE, fontSize: `${16 * WORLD_SCALE}px` }, 0, 0);
|
|
pillInteractive(back, { useHandCursor: true });
|
|
back.setDepth(DEPTH.chrome);
|
|
back.on('pointerdown', () => {
|
|
this.scene.stop('LevelSelect');
|
|
this.scene.start('MainMenu');
|
|
});
|
|
|
|
createPill(this, GAME_WIDTH / 2, GAME_HEIGHT - 36 * WORLD_SCALE, '← → switch campaign · click a node to drive', PILL_STYLE)
|
|
.setDepth(DEPTH.chrome);
|
|
|
|
this._buildCampaign(true);
|
|
|
|
const kb = this.input.keyboard;
|
|
kb.on('keydown-LEFT', () => this._shiftCampaign(-1));
|
|
kb.on('keydown-RIGHT', () => this._shiftCampaign(1));
|
|
kb.on('keydown-P', () => this._playFirst());
|
|
kb.on('keydown-ESC', () => {
|
|
this.scene.stop('LevelSelect');
|
|
this.scene.start('MainMenu');
|
|
});
|
|
}
|
|
|
|
shutdown() {
|
|
if (this._pulseTween) this._pulseTween.stop();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ build
|
|
|
|
_buildCampaign(instant = false) {
|
|
for (const key of Object.keys(this._groups)) {
|
|
for (const obj of this._groups[key]) obj.destroy();
|
|
this._groups[key] = [];
|
|
}
|
|
|
|
const campaign = CAMPAIGNS[this.campaignIndex];
|
|
const levels = campaign.levels;
|
|
|
|
// Menu music follows the campaign: this campaign's theme (musicKey) or
|
|
// the main title for campaigns without one. Handles first entry, campaign
|
|
// switches (from _shiftCampaign), and re-entry after a level (activeKey
|
|
// was cleared by stopMenuMusic, so the track restarts from the top).
|
|
playMenuTrack(this, campaign.musicKey);
|
|
const points = (campaign.positions || autoLayoutPoints(levels.length)).map(scalePos);
|
|
const completed = levels.map((l) => isLevelComplete(l.id));
|
|
// A campaign's first level is playable as soon as the PREVIOUS campaign
|
|
// is fully cleared (within the first campaign, it's open from the start);
|
|
// after that, levels unlock one at a time within the campaign.
|
|
const campaignUnlocked = this.campaignIndex === 0 || CAMPAIGNS[this.campaignIndex - 1].levels.every((l) => isLevelComplete(l.id));
|
|
let current = completed.indexOf(false);
|
|
if (current === 0) current = campaignUnlocked ? 0 : -1;
|
|
|
|
const bg = this.add.image(0, 0, campaign.bgKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
|
this._groups.bg.push(bg);
|
|
|
|
if (points.length >= 2) {
|
|
const spline = sampleSpline(points, 18);
|
|
const path = this.add.graphics().setDepth(DEPTH.path);
|
|
this._traceRibbon(path, spline, 84 * WORLD_SCALE, 0x1a1f29, 1);
|
|
this._traceRibbon(path, spline, 62 * WORLD_SCALE, 0xf4f0e6, 1);
|
|
this._traceDashes(path, spline);
|
|
this._groups.path.push(path);
|
|
}
|
|
|
|
// Finish flag just past the last node, nudged along the path direction.
|
|
// (The auto-layout circuit ends at the bottom-right, so this nudge lands
|
|
// in the corner; if an explicit `positions` layout ends at an edge and the
|
|
// nudge would push the flag off-screen, flip it so it stays on.)
|
|
if (points.length) {
|
|
const last = points[points.length - 1];
|
|
const prev = points[Math.max(0, points.length - 2)] || { x: last.x - 100, y: last.y };
|
|
const ang = Math.atan2(last.y - prev.y, last.x - prev.x);
|
|
const nudge = 95 * WORLD_SCALE;
|
|
let fx = last.x + Math.cos(ang) * nudge;
|
|
let fy = last.y + Math.sin(ang) * nudge;
|
|
const inBounds = (x, y) => x > 60 * WORLD_SCALE && x < GAME_WIDTH - 60 * WORLD_SCALE && y > 160 * WORLD_SCALE && y < GAME_HEIGHT - 60 * WORLD_SCALE;
|
|
if (!inBounds(fx, fy)) {
|
|
fx = last.x - Math.cos(ang) * nudge;
|
|
fy = last.y - Math.sin(ang) * nudge;
|
|
}
|
|
const flag = this.add
|
|
.image(fx, fy, 'map_finish')
|
|
.setOrigin(0.5)
|
|
.setDisplaySize(GAME_WIDTH * 0.18, GAME_HEIGHT * 0.18)
|
|
.setDepth(DEPTH.flag);
|
|
this._groups.path.push(flag);
|
|
}
|
|
|
|
// Nodes + labels. All node textures are 512x512 badges; per-key display
|
|
// sizes come from mapArt.js (map_node_current displays larger than the
|
|
// others).
|
|
levels.forEach((level, i) => {
|
|
const p = points[i];
|
|
const state = campaignUnlocked && (completed[i] || i === current) ? (completed[i] ? 'done' : 'current') : 'locked';
|
|
const key = state === 'current' ? 'map_node_current' : state === 'locked' ? 'map_node_locked' : 'map_node';
|
|
const { width: nw, height: nh } = nodeDisplaySize(key);
|
|
const node = this.add.image(p.x, p.y, key).setOrigin(0.5).setDisplaySize(nw, nh).setDepth(DEPTH.node);
|
|
|
|
// The badge fills its texture file, so the interactive area is a
|
|
// circle at the badge's full diameter (BADGE_HIT_FRAC = 1.0, + the
|
|
// BADGE_HIT_RADIUS_MULT allowance), not the whole texture frame.
|
|
//
|
|
// Phaser hit-tests in LOCAL space: the pointer is inverse-transformed by
|
|
// the object's position/scale (InputPlugin.checkGameObjects), then
|
|
// displayOrigin is added (InputManager.pointWithinInteractiveObject).
|
|
// Both steps are expressed in UNSCALED source-frame pixels, so the badge
|
|
// center (the node's world position) lands at (displayOriginX,
|
|
// displayOriginY) - the center of these origin-0.5 textures - and radii
|
|
// must be given in frame pixels too (world radius = radius * scale).
|
|
// The hover/entrance tweens only change the scale, so the hit circle
|
|
// stays glued to the visible badge at all times.
|
|
const hitRadius = ((node.width * BADGE_HIT_FRAC[key]) / 2) * BADGE_HIT_RADIUS_MULT;
|
|
const hitArea = new Phaser.Geom.Circle(node.displayOriginX, node.displayOriginY, hitRadius);
|
|
const hitTest = (shape, x, y) => (x - shape.x) * (x - shape.x) + (y - shape.y) * (y - shape.y) <= shape.radius * shape.radius;
|
|
|
|
if (state === 'locked') {
|
|
node
|
|
.setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest })
|
|
.on(
|
|
'pointerover',
|
|
() =>
|
|
this._tooltip(
|
|
p.x,
|
|
p.y - nh * 0.75,
|
|
i === 0 ? 'Clear the previous campaign to unlock' : 'Finish the previous level to unlock'
|
|
)
|
|
)
|
|
.on('pointerout', () => this._hideTooltip());
|
|
} else {
|
|
// Hover name tag for CLEARED nodes: a small level-name pill that fades
|
|
// in above the badge while the pointer is over it (the 'current'
|
|
// node already wears its name pill permanently, so no tag there).
|
|
// Created before setInteractive so it sits ahead of the node in the
|
|
// display list; depth DEPTH.tooltip (10) puts it over node and tag.
|
|
const nameTag = state === 'done' ? createPill(this, p.x, p.y, level.name, PILL_STYLE).setDepth(DEPTH.tooltip).setAlpha(0) : null;
|
|
if (nameTag) this._groups.tags.push(nameTag); // rebuilt/destroyed with the campaign group
|
|
node
|
|
.setInteractive({ useHandCursor: true, hitArea, hitAreaCallback: hitTest })
|
|
.on('pointerover', () => {
|
|
this._tweenNodeSize(node, nw * NODE_HOVER, nh * NODE_HOVER);
|
|
if (nameTag) this._showNameTag(nameTag, p.x, p.y - nh * 0.78);
|
|
})
|
|
.on('pointerout', () => {
|
|
this._tweenNodeSize(node, nw, nh);
|
|
if (nameTag) this._hideNameTag(nameTag);
|
|
})
|
|
.on('pointerdown', () => {
|
|
this._tweenNodeSize(node, nw * 0.92, nh * 0.92, 70);
|
|
this.time.delayedCall(80, () => {
|
|
this.scene.stop('LevelSelect');
|
|
this.scene.start('Play', { levelId: level.id });
|
|
});
|
|
});
|
|
}
|
|
this._groups.nodes.push(node);
|
|
|
|
if (state === 'done') {
|
|
const best = getBestScore(level.id);
|
|
if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70);
|
|
} else if (state === 'current') {
|
|
this._groups.tags.push(createPill(this, p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setDepth(DEPTH.tag));
|
|
}
|
|
});
|
|
|
|
const done = completed.filter(Boolean).length;
|
|
this._titleText.setText(`${campaign.name} ${done}/${levels.length} cleared`);
|
|
this._drawPager();
|
|
|
|
if (!instant) {
|
|
// Entrance: background/road fade in, nodes pop in one after another.
|
|
[...this._groups.bg, ...this._groups.path].forEach((o) => o.setAlpha(0));
|
|
this.tweens.add({ targets: [...this._groups.bg, ...this._groups.path], alpha: 1, duration: 200, ease: 'Quad.easeOut' });
|
|
this._groups.nodes.forEach((node, i) => {
|
|
// Pop in to the resting DISPLAY size - never scale 1 (see the per-key
|
|
// sizes in mapArt.js); animating display sizes keeps this correct for
|
|
// any texture frame size, like every other node tween in this scene.
|
|
const { width: dw, height: dh } = nodeDisplaySize(node.texture.key);
|
|
node.setDisplaySize(dw * 0.5, dh * 0.5).setAlpha(0);
|
|
this.tweens.add({
|
|
targets: node,
|
|
alpha: 1,
|
|
displayWidth: dw,
|
|
displayHeight: dh,
|
|
delay: 80 + i * 70,
|
|
duration: 260,
|
|
ease: 'Back.easeOut',
|
|
});
|
|
});
|
|
}
|
|
if (current !== -1) {
|
|
// Start the idle pulse only once that node's entrance pop has landed so
|
|
// it takes over exactly at resting size (immediately for instant builds).
|
|
this._startPulse(current, instant ? 0 : 80 + current * 70 + 260);
|
|
}
|
|
}
|
|
|
|
// Cleared nodes wear a small gold "best score" pill under the badge (gold
|
|
// matches the gold score numerals in the tally scene). On entrance the
|
|
// number rolls up from 0 to the player's best - an odometer that reads as
|
|
// the map remembering the run. Duration scales with the score's magnitude
|
|
// (capped) so big scores get the longer, more satisfying count.
|
|
_scoreRollup(p, nh, best, delay) {
|
|
const pill = this.add
|
|
.text(p.x, p.y + nh * 0.68, '★ 0', SCORE_PILL_STYLE)
|
|
.setOrigin(0.5)
|
|
.setDepth(DEPTH.tag)
|
|
.setAlpha(0)
|
|
.setScale(0.4);
|
|
this._groups.tags.push(pill);
|
|
this.tweens.add({
|
|
targets: pill,
|
|
alpha: 1,
|
|
scale: 1,
|
|
delay,
|
|
duration: 220,
|
|
ease: 'Back.easeOut',
|
|
onComplete: () => {
|
|
const counter = { v: 0 };
|
|
this.tweens.add({
|
|
targets: counter,
|
|
v: best,
|
|
duration: Math.min(1500, 500 + Math.sqrt(best) * 40),
|
|
ease: 'Cubic.easeOut',
|
|
onUpdate: () => {
|
|
// The pill is destroyed by _buildCampaign when the campaign
|
|
// switches, but this tween targets a plain object (the counter)
|
|
// and outlives the pill - guard the setText (crashes on the
|
|
// destroyed Text's nulled texture frame).
|
|
if (pill.isDestroyed) return;
|
|
pill.setText(`★ ${Math.round(counter.v).toLocaleString()}`);
|
|
},
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
_startPulse(index, delay = 0) {
|
|
if (this._pulseTween) this._pulseTween.stop();
|
|
const node = this._groups.nodes[index];
|
|
if (!node) return;
|
|
const { width: dw, height: dh } = nodeDisplaySize(node.texture.key);
|
|
// Explicit from/to pins the oscillation to the resting size, independent
|
|
// of whatever mid-animation scale the node happens to have when this is
|
|
// added.
|
|
this._pulseTween = this.tweens.add({
|
|
targets: node,
|
|
displayWidth: { from: dw, to: dw * NODE_IDLE_PULSE },
|
|
displayHeight: { from: dh, to: dh * NODE_IDLE_PULSE },
|
|
duration: 900,
|
|
yoyo: true,
|
|
repeat: -1,
|
|
ease: 'Sine.easeInOut',
|
|
delay,
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------------- actions
|
|
|
|
_shiftCampaign(dir) {
|
|
if (this._transitioning) return;
|
|
const next = (this.campaignIndex + dir + CAMPAIGNS.length) % CAMPAIGNS.length;
|
|
if (next === this.campaignIndex) return;
|
|
this._transitioning = true;
|
|
this.campaignIndex = next;
|
|
|
|
const all = [...this._groups.bg, ...this._groups.path, ...this._groups.nodes, ...this._groups.tags];
|
|
// `all` is never empty (the background is always built), so onComplete is
|
|
// guaranteed to fire - empty campaigns transition fine through this path.
|
|
this.tweens.add({
|
|
targets: all,
|
|
alpha: 0,
|
|
y: (o) => o.y + 24 * WORLD_SCALE * dir,
|
|
duration: 140,
|
|
ease: 'Quad.easeIn',
|
|
onComplete: () => {
|
|
this._buildCampaign(false);
|
|
this._transitioning = false;
|
|
},
|
|
});
|
|
}
|
|
|
|
_playFirst() {
|
|
const campaign = CAMPAIGNS[this.campaignIndex];
|
|
if (!campaign.levels.length) return; // campaign with no levels yet - nothing to play
|
|
// Mirror the map's unlock rule: the campaign must be open (previous
|
|
// campaign cleared) before any of its levels can be played.
|
|
const campaignUnlocked = this.campaignIndex === 0 || CAMPAIGNS[this.campaignIndex - 1].levels.every((l) => isLevelComplete(l.id));
|
|
if (!campaignUnlocked) return;
|
|
const firstOpen = campaign.levels.find((l) => !isLevelComplete(l.id)) || campaign.levels[0];
|
|
this.scene.stop('LevelSelect');
|
|
this.scene.start('Play', { levelId: firstOpen.id });
|
|
}
|
|
|
|
// ----------------------------------------------------------------- helpers
|
|
|
|
_tweenNodeSize(node, w, h, duration = 130) {
|
|
if (this._pulseTween && this._pulseTween.targets === node) this._pulseTween.stop();
|
|
this.tweens.killTweensOf(node);
|
|
this.tweens.add({ targets: node, displayWidth: w, displayHeight: h, duration, ease: 'Quad.easeOut' });
|
|
}
|
|
|
|
_tooltip(x, y, msg) {
|
|
this._hideTooltip();
|
|
// Tooltip is dark, not one of the white pills (util/pill.js is
|
|
// white-fill-only), so it keeps its own plain backgroundColor.
|
|
this._tip = this.add
|
|
.text(x, y, msg, {
|
|
fontFamily: 'monospace',
|
|
fontSize: `${14 * WORLD_SCALE}px`,
|
|
color: '#f4f0e6',
|
|
backgroundColor: '#1a1f29',
|
|
padding: { x: 12 * WORLD_SCALE, y: 6 * WORLD_SCALE },
|
|
})
|
|
.setOrigin(0.5)
|
|
.setDepth(DEPTH.tooltip)
|
|
.setAlpha(0);
|
|
this.tweens.add({ targets: this._tip, alpha: 1, duration: 100 });
|
|
}
|
|
|
|
_hideTooltip() {
|
|
if (this._tip) {
|
|
this._tip.destroy();
|
|
this._tip = null;
|
|
}
|
|
}
|
|
|
|
// Hover name tag for cleared nodes: fade in (a little pop) while hovered,
|
|
// fade out when the pointer leaves. Non-blocking - the click's
|
|
// pointerdown/scene transition below are never gated on this tween, so an
|
|
// overlapping node's pointerover can't interrupt a still-running fade-in.
|
|
_showNameTag(tag, x, y) {
|
|
tag.setPosition(x, y);
|
|
tag.setAlpha(0);
|
|
this.tweens.killTweensOf(tag);
|
|
this.tweens.add({ targets: tag, alpha: 1, duration: 100, ease: 'Quad.easeOut' });
|
|
}
|
|
|
|
_hideNameTag(tag) {
|
|
if (!tag.active) return;
|
|
this.tweens.killTweensOf(tag);
|
|
this.tweens.add({ targets: tag, alpha: 0, duration: 100, ease: 'Quad.easeIn' });
|
|
}
|
|
|
|
_drawPager() {
|
|
const g = this._pager;
|
|
g.clear();
|
|
const n = CAMPAIGNS.length;
|
|
const r = 8 * WORLD_SCALE;
|
|
const gap = 32 * WORLD_SCALE;
|
|
const x0 = GAME_WIDTH / 2 - ((n - 1) * gap) / 2;
|
|
const y = 34 * WORLD_SCALE;
|
|
for (let i = 0; i < n; i++) {
|
|
const x = x0 + i * gap;
|
|
if (i === this.campaignIndex) {
|
|
g.lineStyle(4 * WORLD_SCALE, 0x1a1f29, 1);
|
|
g.strokeCircle(x, y, r + 5 * WORLD_SCALE);
|
|
g.fillStyle(0xf2c14e, 1);
|
|
} else {
|
|
g.fillStyle(0x1a1f29, 0.4);
|
|
}
|
|
g.fillCircle(x, y, r);
|
|
}
|
|
}
|
|
|
|
// Path ribbon: strokes along the spline, with circles at each point for
|
|
// rounded caps/joins (Phaser's Graphics has no lineCap/lineJoin).
|
|
_traceRibbon(g, spline, width, color, alpha) {
|
|
g.lineStyle(width, color, alpha);
|
|
g.beginPath();
|
|
g.moveTo(spline[0].x, spline[0].y);
|
|
for (let i = 1; i < spline.length; i++) g.lineTo(spline[i].x, spline[i].y);
|
|
g.strokePath();
|
|
// Circles at each sample point give the ribbon rounded ends and smooth joins.
|
|
const r = width / 2;
|
|
for (const p of spline) {
|
|
g.fillStyle(color, alpha);
|
|
g.fillCircle(p.x, p.y, r);
|
|
}
|
|
}
|
|
|
|
_traceDashes(g, spline) {
|
|
const dash = 40 * WORLD_SCALE;
|
|
const gap = 30 * WORLD_SCALE;
|
|
g.lineStyle(8 * WORLD_SCALE, 0x2255aa, 0.8);
|
|
let inDash = true;
|
|
let remaining = dash;
|
|
for (let i = 1; i < spline.length; i++) {
|
|
const a = spline[i - 1];
|
|
const b = spline[i];
|
|
const segLen = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
|
const ux = (b.x - a.x) / segLen;
|
|
const uy = (b.y - a.y) / segLen;
|
|
let t = 0;
|
|
while (t < segLen) {
|
|
const step = Math.min(remaining, segLen - t);
|
|
if (inDash) {
|
|
g.beginPath();
|
|
g.moveTo(a.x + ux * t, a.y + uy * t);
|
|
g.lineTo(a.x + ux * (t + step), a.y + uy * (t + step));
|
|
g.strokePath();
|
|
g.fillStyle(0x2255aa, 0.8);
|
|
g.fillCircle(a.x + ux * t, a.y + uy * t, 4 * WORLD_SCALE);
|
|
g.fillCircle(a.x + ux * (t + step), a.y + uy * (t + step), 4 * WORLD_SCALE);
|
|
}
|
|
remaining -= step;
|
|
if (remaining <= 0) {
|
|
inDash = !inDash;
|
|
remaining = inDash ? dash : gap;
|
|
}
|
|
t += step;
|
|
}
|
|
}
|
|
}
|
|
}
|