From 87b074d04d3246c6c667851d108bee113454c6ca Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 10:13:09 +0000 Subject: [PATCH] fix(client): render on the 1.13c 32x16 sub-tile lattice and pick the LevelView by server position (#539) Root cause 1: subTileToWorldOrtho/subTileToScreen (and their inverses) projected unit sub-tiles with ORTHO_SUB_TILE_*/2, i.e. a 16x8 px lattice, while DT1 floor tiles use 160x80 px = 5x5 sub-tiles of 32x16 px. Player, camera, units, minimap marker and click picking were drawn at half their distance from the level iso origin: e.g. seed 0x31595789 server (5737,4523) = local (137,123) camp floor was drawn over local (68.5,61.5), a palisade wall cell (coll 0x1). Root cause 2: the scene rendered MapService.getActiveLevelView() ("last view built"); AutomapView building Blood Moor for 0x07 reveals near the spawn swapped the rendered level. The scene now resolves the level whose DRLG canvas contains the player's world sub-tile among the LoadAct area + revealed areas. Anti-silent-failure: remove the playerpLevel`, levels of an act occupy disjoint DRLG coordinate rectangles). + * It deliberately does NOT use {@link getActiveLevelView}: that is "the last view built", which + * `AutomapView` changes as a side effect when it builds neighbouring levels for `0x07` reveals + * (e.g. Blood Moor rooms next to the Rogue Encampment spawn) — issue #539. + * + * Returns `null` when no candidate level has real floor/wall data at that sub-tile (DRLG mismatch + * or not yet loaded); callers must treat that as not-ready rather than guessing a level. + */ + findLevelViewAtWorldSubTile( + worldSubX: number, + worldSubY: number, + areaIds: Iterable, + ): LevelView | null { + if (!this.engine) { + throw new Error('MapService.findLevelViewAtWorldSubTile: synchronous call requires in-process engine') + } + if (this.activeSeed === null) return null + const seen = new Set() + for (const areaId of areaIds) { + if (areaId <= 0 || seen.has(areaId)) continue + seen.add(areaId) + const view = this.engine.getLevelView(areaId, 'all') + const lx = Math.floor(worldSubX) - view.subOriginX + const ly = Math.floor(worldSubY) - view.subOriginY + if (lx < 0 || ly < 0 || lx >= view.subWidth || ly >= view.subHeight) continue + const mask = view.collision[ly * view.subWidth + lx] ?? COLLIDE_MASK_INVALID + if ((mask & COLLIDE_MASK_INVALID) === COLLIDE_MASK_INVALID) continue + return view + } + return null + } + private sendWorkerRequest( req: MapWorkerRequest, ): Promise> { diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 6e5df97..19d0bed 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -38,7 +38,6 @@ import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' import { applyLightingInputs, - type LightingInputs, type LightingPassTargets, } from './lighting-presets.ts' @@ -501,56 +500,34 @@ function drawSceneOverlayDrawable( } } -function normalizeSceneLightingInputs( - lightingInputs: LightingInputs, - subShiftX: number, - subShiftY: number, -): LightingInputs { - if ( - (subShiftX === 0 && subShiftY === 0) || - !(lightingInputs.playerSubTile.x >= subShiftX && subShiftX > 0) - ) { - return lightingInputs - } - const normalizedPlayerSubTile = { - x: lightingInputs.playerSubTile.x - subShiftX, - y: lightingInputs.playerSubTile.y - subShiftY, - } - const normalizedPointLights = lightingInputs.pointLights.map((light) => ({ - ...light, - subTileX: - light.subTileX >= subShiftX && subShiftX > 0 ? light.subTileX - subShiftX : light.subTileX, - subTileY: - light.subTileY >= subShiftY && subShiftY > 0 ? light.subTileY - subShiftY : light.subTileY, - })) - return { - ...lightingInputs, - playerSubTile: normalizedPlayerSubTile, - pointLights: normalizedPointLights, - } -} - /** * Render one frame driven by an engine-decoupled `SceneSource` (e.g. online `ClientWorld` + `LevelView`). + * + * Coordinate contract (#539): `SceneSource` already converts absolute D2GS world sub-tiles to + * level-local sub-tiles (`world - levelView.subOrigin`) for drawables, lighting and the player; + * this function must not re-normalise them with value-range heuristics. Until the source reports + * `isWorldReady()` (LevelView loaded + server-assigned player position) only a cleared frame and + * the HUD are drawn — the camera is never anchored on a guessed position. */ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFrameSnapshot { - const rawSnapshot = ctx.source.buildFrameSnapshot() + const snapshot = ctx.source.buildFrameSnapshot() const levelView = ctx.source.getLevelView() - const subShiftX = levelView ? (levelView.subOriginX ?? levelView.originX * 5) : 0 - const subShiftY = levelView ? (levelView.subOriginY ?? levelView.originY * 5) : 0 - const snapshot: SceneFrameSnapshot = - rawSnapshot.playerSubTile.x >= subShiftX && subShiftX > 0 - ? { - ...rawSnapshot, - playerSubTile: { - x: rawSnapshot.playerSubTile.x - subShiftX, - y: rawSnapshot.playerSubTile.y - subShiftY, - }, - } - : rawSnapshot const nowMs = ctx.nowMs ?? performance.now() const renderer = ctx.renderer ?? null + if (!snapshot.worldReady) { + if (renderer !== null) { + renderer.begin({ x: 0, y: 0, zoom: 1 }, [0, 0, 0]) + renderer.setLighting(null) + renderer.flush() + } + if (ctx.hudManager) { + ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + } + ctx.onFrameSnapshot?.(snapshot) + return snapshot + } + if (renderer !== null) { ctx.tileGpuCache?.ensureActPalette(snapshot.actId) const camX = snapshot.playerWorld.x @@ -558,13 +535,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03]) if (ctx.lightingTargets) { - const lightingInputs = ctx.source.buildLightingInputs(ctx.lightingPreset) - const normalizedLightingInputs = normalizeSceneLightingInputs( - lightingInputs, - subShiftX, - subShiftY, - ) - applyLightingInputs(normalizedLightingInputs, ctx.lightingTargets) + applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets) } const drawTileQuad = ( @@ -654,13 +625,9 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram originY: levelView?.isoOriginY ?? 0, preRevealTown: isTownLevel(snapshot.levelId), } - const localSub = ctx.source.getPlayerLocalSubTile() - const normSubX = - localSub.x >= subShiftX && subShiftX > 0 ? localSub.x - subShiftX : localSub.x - const normSubY = - localSub.y >= subShiftY && subShiftY > 0 ? localSub.y - subShiftY : localSub.y - const playerCellX = Math.floor(normSubX / 5) - const playerCellY = Math.floor(normSubY / 5) + const localSub = snapshot.playerLocalSubTile + const playerCellX = Math.floor(localSub.x / 5) + const playerCellY = Math.floor(localSub.y / 5) ctx.minimap.reveal( effectiveMinimapLevel, playerCellX, @@ -707,13 +674,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.flush() } else if (ctx.lightingTargets) { - const lightingInputs = ctx.source.buildLightingInputs(ctx.lightingPreset) - const normalizedLightingInputs = normalizeSceneLightingInputs( - lightingInputs, - subShiftX, - subShiftY, - ) - applyLightingInputs(normalizedLightingInputs, ctx.lightingTargets) + applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets) } if (ctx.hudManager) { diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 4af58e7..caecda4 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -269,19 +269,32 @@ export class OnlineSession { }) this.sceneSource = new ClientWorldSceneSource( - () => ({ - tick: this.tickCounter, - actId: getActForArea(this.world.areaId || 1), - levelId: this.world.areaId || 1, - playerSubX: this.predictor.getSnapshot().x, - playerSubY: this.predictor.getSnapshot().y, - units: this.buildRenderableUnits(), - levelView: - this.mapService?.getActiveLevelView() ?? - this.automapView.getLevelView(this.world.areaId || 1) ?? - null, - tileAtlas: this.mapService?.getTileAtlas() ?? null, - }), + () => { + // #539: the scene is anchored only on server-provided state (0x03 LoadAct area + 0x07 + // revealed rooms + server player position). No `|| 1` Rogue Encampment default and no + // "last LevelView MapService happened to build" (AutomapView builds neighbouring levels + // such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG + // canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a + // level exists the scene reports not-ready. + const predSnap = this.predictor.getSnapshot() + const levelView = + this.mapService && this.world.areaId > 0 && (predSnap.x !== 0 || predSnap.y !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(predSnap.x, predSnap.y, [ + this.world.areaId, + ...this.world.getRevealedAreaIds(), + ]) + : null + const levelId = levelView?.areaId ?? 0 + return { + tick: this.tickCounter, + ...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}), + playerSubX: predSnap.x, + playerSubY: predSnap.y, + units: this.buildRenderableUnits(), + levelView, + tileAtlas: this.mapService?.getTileAtlas() ?? null, + } + }, initialViewport, ) @@ -728,32 +741,41 @@ export class OnlineSession { switch (ev.type) { case 'LoadAct': { - const defaultTownArea = ([1, 40, 75, 103, 109] as const)[ev.act] ?? 1 - const effectiveAreaId = ev.areaId > 0 ? ev.areaId : defaultTownArea + // 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being + // loaded into. Without it (or without a buildable LevelView) the client cannot place the + // player on the server's map, so fail fast instead of guessing a town (#539). + if (ev.areaId <= 0) { + this.handleUnexpectedDisconnect( + `0x03 LoadAct carried no areaId (act=${String(ev.act)}): cannot build the level the server placed the player in`, + ) + break + } if (this.mapService) { try { this.mapService.onLoadActPacket( { act: ev.act, mapSeed: ev.mapSeed, - areaId: effectiveAreaId, + areaId: ev.areaId, }, { difficulty: this.world.difficulty }, ) - } catch { - // MapService may be running without packed bundle in lightweight tests + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + this.handleUnexpectedDisconnect( + `MapService failed to build area ${String(ev.areaId)} (act=${String(ev.act)}, seed=0x${(ev.mapSeed >>> 0).toString(16)}): ${msg}`, + ) + break } } const activeView = this.mapService?.getActiveLevelView() ?? undefined this.automapView.setContext( ev.mapSeed, this.world.difficulty, - effectiveAreaId, + ev.areaId, activeView, ) - if (effectiveAreaId > 0) { - void this.soundService.onEnterArea(effectiveAreaId) - } + void this.soundService.onEnterArea(ev.areaId) break } diff --git a/src/client/view/scene-source.ts b/src/client/view/scene-source.ts index 9312f0d..b99476c 100644 --- a/src/client/view/scene-source.ts +++ b/src/client/view/scene-source.ts @@ -44,7 +44,12 @@ export interface SceneFrameSnapshot { readonly actId: 1 | 2 | 3 | 4 | 5 readonly levelId: number readonly viewportProfile: ViewportProfile + /** `true` once a LevelView and a server-assigned player position exist (see `isWorldReady`). */ + readonly worldReady: boolean + /** Absolute world sub-tile of the local player (D2GS coordinates). */ readonly playerSubTile: { readonly x: number; readonly y: number } + /** Level-local sub-tile of the local player (`world - levelView.subOrigin`). */ + readonly playerLocalSubTile: { readonly x: number; readonly y: number } readonly playerWorld: { readonly x: number; readonly y: number } readonly cameraTopLeft: { readonly x: number; readonly y: number } readonly floorQuads: readonly SceneTileQuad[] @@ -66,6 +71,8 @@ export interface SceneSource { getLevelId(): number getTick(): number getPlayerSubTile(): { readonly x: number; readonly y: number } + /** LevelView loaded and local player position assigned by the server. */ + isWorldReady(): boolean getPlayerLocalSubTile(): { readonly x: number; readonly y: number } getPlayerWorldOrtho(): { readonly x: number; readonly y: number } getCameraTopLeft(profile?: ViewportProfile): { readonly x: number; readonly y: number } @@ -149,23 +156,23 @@ function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTi } } +/** + * Level-local sub-tile shift for the active `LevelView`. + * + * Contract: every unit / player coordinate fed into a `ClientWorldSceneSource` is an absolute + * world sub-tile coordinate as sent by D2GS (`0x15`, `0x59`, `0x95`, `0xAC`, ...). The level-local + * sub-tile is always `world - levelView.subOrigin` (`subOrigin = DRLG coord * 5`). There is no + * heuristic "already local?" guess any more (the old `player < origin * 0.5` probe silently accepted + * two coordinate contracts). Without a `LevelView` there is no level to anchor to: shift is zero and + * `isWorldReady()` reports not-ready so the renderer does not draw units at arbitrary positions. + */ function resolveLevelSubTileShift( - playerSubX: number, - playerSubY: number, levelView: LevelView | null | undefined, ): { readonly shiftX: number; readonly shiftY: number } { if (!levelView) { return { shiftX: 0, shiftY: 0 } } - const ox = levelView.subOriginX ?? levelView.originX ?? 0 - const oy = levelView.subOriginY ?? levelView.originY ?? 0 - if (ox === 0 && oy === 0) { - return { shiftX: 0, shiftY: 0 } - } - if (playerSubX < ox * 0.5 && playerSubY < oy * 0.5) { - return { shiftX: 0, shiftY: 0 } - } - return { shiftX: ox, shiftY: oy } + return { shiftX: levelView.subOriginX, shiftY: levelView.subOriginY } } function normalizeUnitsToLocalSubTiles( @@ -240,9 +247,21 @@ export class ClientWorldSceneSource implements SceneSource { return { x: st.playerSubX, y: st.playerSubY } } + /** + * The world can only be drawn once D2GS has (a) loaded the act/level (`0x03 LoadAct` → + * `LevelView`) and (b) assigned the local player a world position (`0x15` / `0x95` / ...). + * D2 world sub-tile coordinates are always far from `(0, 0)` (DRLG act origins), so `(0, 0)` + * means "not yet assigned". Until then nothing world-relative may be drawn (#539: never guess a + * spawn / camera anchor). + */ + isWorldReady(): boolean { + const st = this.stateProvider() + return Boolean(st.levelView) && (st.playerSubX !== 0 || st.playerSubY !== 0) + } + getPlayerLocalSubTile(): { readonly x: number; readonly y: number } { const st = this.stateProvider() - const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) + const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView) return { x: st.playerSubX - shiftX, y: st.playerSubY - shiftY, @@ -251,7 +270,7 @@ export class ClientWorldSceneSource implements SceneSource { getPlayerWorldOrtho(): { readonly x: number; readonly y: number } { const st = this.stateProvider() - const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) + const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView) const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0 return subTileToWorldOrtho( @@ -275,19 +294,19 @@ export class ClientWorldSceneSource implements SceneSource { isSubTileWalkable(subX: number, subY: number): boolean { const st = this.stateProvider() - if (st.levelView && st.levelView.rooms.length > 0) { - return isWalkableWorldSubTile(st.levelView, Math.floor(subX), Math.floor(subY)) - } - return true + // No LevelView → no collision data → nothing is walkable (never a permissive `return true`). + if (!st.levelView) return false + return isWalkableWorldSubTile(st.levelView, Math.floor(subX), Math.floor(subY)) } buildFrameSnapshot(cullPaddingPx = 240): SceneFrameSnapshot { const st = this.stateProvider() const profile = this.viewportProfile - const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) + const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView) const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0 const playerSubTile = { x: st.playerSubX, y: st.playerSubY } + const playerLocalSubTile = { x: st.playerSubX - shiftX, y: st.playerSubY - shiftY } const playerWorld = subTileToWorldOrtho( st.playerSubX - shiftX, st.playerSubY - shiftY, @@ -351,7 +370,9 @@ export class ClientWorldSceneSource implements SceneSource { actId: this.getActId(), levelId: this.getLevelId(), viewportProfile: profile, + worldReady: this.isWorldReady(), playerSubTile, + playerLocalSubTile, playerWorld, cameraTopLeft, floorQuads, @@ -365,7 +386,7 @@ export class ClientWorldSceneSource implements SceneSource { buildLightingInputs(preset: LightingPreset): LightingInputs { const st = this.stateProvider() - const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) + const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView) const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0 const localPlayerSub = { @@ -401,7 +422,7 @@ export class ClientWorldSceneSource implements SceneSource { profile: ViewportProfile = this.viewportProfile, ): SceneUnitDrawable | null { const st = this.stateProvider() - const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) + const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView) const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0 const playerWorld = subTileToWorldOrtho( diff --git a/src/client/view/viewport-profile.ts b/src/client/view/viewport-profile.ts index 19c1cf6..454039a 100644 --- a/src/client/view/viewport-profile.ts +++ b/src/client/view/viewport-profile.ts @@ -148,9 +148,19 @@ export function screenToWorld( /** * Convert 1.13c isometric sub-tile coordinates `(subX, subY)` to world orthogonal pixels. - * Each sub-tile is 32×16 ortho pixels: - * `worldX = originX + (subX - subY) * 16` - * `worldY = originY + (subX + subY) * 8` + * + * Diablo II 1.13c floor tiles are 160×80 px diamonds (DT1 floor blocks) holding 5×5 sub-tiles, + * so one sub-tile is a 32×16 px diamond and the tile grid places cell `(cx, cy)` at + * `((cx - cy) * 80, (cx + cy) * 40)` (`ORTHO_CELL_WIDTH` / `ORTHO_CELL_HEIGHT`, see + * `buildIsoMapScene` / `localSubTileToOrtho`). Units (server sub-tile coordinates) MUST use the + * same lattice as the tiles they stand on: + * `worldX = originX + (subX - subY) * 16` (`ORTHO_SUB_TILE_WIDTH = 80 / 5 = 16`) + * `worldY = originY + (subX + subY) * 8` (`ORTHO_SUB_TILE_HEIGHT = 40 / 5 = 8`) + * + * Issue #539: this used to multiply by `ORTHO_SUB_TILE_WIDTH / 2` and `ORTHO_SUB_TILE_HEIGHT / 2`, + * i.e. a 16×8 px lattice at half the tile scale. Every unit (and the camera that follows the + * player) was drawn at half its distance from the level iso origin, so a player standing on + * Rogue Encampment floor appeared outside the palisade among the trees. */ export function subTileToWorldOrtho( subX: number, @@ -159,13 +169,14 @@ export function subTileToWorldOrtho( originY = 0, ): { x: number; y: number } { return { - x: originX + (subX - subY) * (ORTHO_SUB_TILE_WIDTH / 2), - y: originY + (subX + subY) * (ORTHO_SUB_TILE_HEIGHT / 2), + x: originX + (subX - subY) * ORTHO_SUB_TILE_WIDTH, + y: originY + (subX + subY) * ORTHO_SUB_TILE_HEIGHT, } } /** - * Convert world orthogonal pixels `(worldX, worldY)` to continuous sub-tile coordinates. + * Convert world orthogonal pixels `(worldX, worldY)` to continuous sub-tile coordinates + * (exact inverse of {@link subTileToWorldOrtho}). */ export function worldOrthoToSubTile( worldX: number, @@ -176,13 +187,14 @@ export function worldOrthoToSubTile( const dx = (worldX - originX) / ORTHO_SUB_TILE_WIDTH const dy = (worldY - originY) / ORTHO_SUB_TILE_HEIGHT return { - x: dy + dx, - y: dy - dx, + x: (dy + dx) / 2, + y: (dy - dx) / 2, } } /** - * Convert sub-tile `(subX, subY)` directly to screen coordinates relative to camera sub-tile `(camSubX, camSubY)`. + * Convert sub-tile `(subX, subY)` directly to screen coordinates relative to camera sub-tile `(camSubX, camSubY)` + * (same 32×16 px sub-tile lattice as {@link subTileToWorldOrtho}). */ export function subTileToScreen( subX: number, @@ -194,13 +206,14 @@ export function subTileToScreen( const dSubX = subX - camSubX const dSubY = subY - camSubY return { - x: profile.width / 2 + (dSubX - dSubY) * (ORTHO_SUB_TILE_WIDTH / 2), - y: profile.height / 2 + (dSubX + dSubY) * (ORTHO_SUB_TILE_HEIGHT / 2), + x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH, + y: profile.height / 2 + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT, } } /** - * Convert screen click `(screenX, screenY)` to fractional sub-tile coordinates relative to camera sub-tile `(camSubX, camSubY)`. + * Convert screen click `(screenX, screenY)` to fractional sub-tile coordinates relative to camera sub-tile `(camSubX, camSubY)` + * (exact inverse of {@link subTileToScreen}). */ export function screenToSubTile( screenX: number, @@ -214,8 +227,8 @@ export function screenToSubTile( const dx = relX / ORTHO_SUB_TILE_WIDTH const dy = relY / ORTHO_SUB_TILE_HEIGHT return { - x: camSubX + (dy + dx), - y: camSubY + (dy - dx), + x: camSubX + (dy + dx) / 2, + y: camSubY + (dy - dx) / 2, } } diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 5927688..44d4549 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -144,6 +144,19 @@ export class ClientWorld { return this._revealedRooms } + /** + * Distinct area ids of the rooms currently revealed by the server (`0x07` minus `0x08`), in + * first-seen order. Used to resolve which level contains the player's server position (#539). + */ + getRevealedAreaIds(): number[] { + const out: number[] = [] + for (const key of this._revealedRooms) { + const areaId = Number(key.slice(0, key.indexOf(':'))) + if (areaId > 0 && !out.includes(areaId)) out.push(areaId) + } + return out + } + get waypoints(): ReadonlySet { return this._waypoints }