diff --git a/src/main.js b/src/main.js index 4e8178c..006279b 100644 --- a/src/main.js +++ b/src/main.js @@ -804,10 +804,7 @@ class HouseScene extends Phaser.Scene { } startRoomHold(room, pointer) { - if ( - this.journeyActive || - room.id === this.state.player.roomId - ) return + if (this.journeyActive) return this.cancelRoomHold(false) @@ -1035,7 +1032,7 @@ class HouseScene extends Phaser.Scene { } async beginElevatorJourney(targetRoom) { - if (this.journeyActive || targetRoom.id === this.state.player.roomId) return + if (this.journeyActive) return const fromRoom = this.currentRoom() const fromEntry = elevatorEntry(fromRoom) @@ -1085,7 +1082,6 @@ class HouseScene extends Phaser.Scene { this.playerState = PLAYER_STATE.RIDING_LIFT this.updateStatus(fromRoom, 'Riding to ' + targetRoom.name + '…') - const fromStop = elevatorStopY(fromRoom) const toStop = elevatorStopY(targetRoom) const floorDistance = Math.abs(targetRoom.floor - fromRoom.floor) @@ -1116,55 +1112,31 @@ class HouseScene extends Phaser.Scene { } await this.setElevatorDoors(true) + this.updateStatus(targetRoom, 'Entering ' + targetRoom.name + '…') - this.playerState = PLAYER_STATE.EXITING_LIFT - this.animatePlayer('walk', 999) - - await this.tweenAsync({ - targets: this.player, - x: targetEntry.x, - y: targetEntry.y, - duration: 240, - ease: 'Sine.easeOut' - }) - + // Store where the player returns to in the dollhouse hub. this.state.player.roomId = targetRoom.id - this.state.player.x = Math.round(this.player.x) - this.state.player.y = Math.round(this.player.y) + this.state.player.x = Math.round(targetEntry.x) + this.state.player.y = Math.round(targetEntry.y) - this.updatePlayerDepthAndScale(targetRoom) - await this.setElevatorDoors(false) + // Keep the live sprite on the saved hub position so shutdown persistence + // cannot overwrite the target with the elevator-car coordinates. + this.player.x = targetEntry.x + this.player.y = targetEntry.y + + await saveGameState(this.state) + + this.cameras.main.fadeOut(220, 255, 247, 251) + await this.delayAsync(240) this.journeyActive = false - this.playerState = PLAYER_STATE.IDLE - this.updateStatus(targetRoom) - const pointerRoom = this.latestPointer - ? this.getRoomAt(this.latestPointer.x, this.latestPointer.y) - : null - - if (this.playerDragging && pointerRoom?.id === targetRoom.id) { - const targetX = Phaser.Math.Clamp( - this.latestPointer.x, - targetRoom.x + 26, - targetRoom.x + targetRoom.width - 26 - ) - - const targetY = Phaser.Math.Clamp( - this.latestPointer.y + PLAYER_DRAG_OFFSET_Y, - targetRoom.walkBand.minY, - targetRoom.walkBand.maxY - ) - - this.playerDragTarget = { x: targetX, y: targetY } - this.playerState = PLAYER_STATE.WALKING - this.animatePlayer('walk', 999) - } else { - this.playerDragTarget = null - this.animatePlayer('idle', 999) + if (targetRoom.isRoof) { + this.scene.start('RoofScene', { roomId: targetRoom.id }) + return } - this.scheduleSave() + this.scene.start('RoomScene', { roomId: targetRoom.id }) } settlePlayer() { @@ -1284,6 +1256,425 @@ class HouseScene extends Phaser.Scene { } } + +class PlayableLevelScene extends Phaser.Scene { + constructor(key) { + super(key) + this.playerDragging = false + this.playerDragTarget = null + this.lastDragX = null + this.playerAnimMode = 'idle' + this.playerAnimFrame = 0 + this.playerAnimClock = 0 + this.walkBounds = null + this.exiting = false + } + + createPolygonMask(points) { + const shape = this.make.graphics({ add: false }) + shape.fillStyle(0xffffff, 1) + shape.beginPath() + shape.moveTo(points[0].x, points[0].y) + + for (let i = 1; i < points.length; i += 1) { + shape.lineTo(points[i].x, points[i].y) + } + + shape.closePath() + shape.fillPath() + return shape.createGeometryMask() + } + + addMaskedLevelImage(texture, points, depth, tint = null) { + const image = this.add.image(WIDTH / 2, HEIGHT / 2, texture) + .setDisplaySize(WIDTH, HEIGHT) + .setDepth(depth) + .setMask(this.createPolygonMask(points)) + + if (tint !== null) image.setTint(tint) + return image + } + + createLevelPlayer(x, y, bounds, scale = 0.64) { + this.walkBounds = bounds + + this.player = this.add.image(x, y, 'player-idle-0') + .setOrigin(0.5, 1) + .setScale(scale) + .setDepth(300) + .setData('kind', 'level-player') + .setInteractive({ cursor: 'grab' }) + + this.input.setDraggable(this.player) + + this.input.on('dragstart', (_pointer, object) => { + if (object !== this.player || this.exiting) return + this.playerDragging = true + this.lastDragX = object.x + this.animatePlayer('walk', 999) + }) + + this.input.on('drag', (pointer, object) => { + if (object !== this.player || this.exiting) return + + const targetX = Phaser.Math.Clamp( + pointer.x, + this.walkBounds.left, + this.walkBounds.right + ) + + const targetY = Phaser.Math.Clamp( + pointer.y + 76, + this.walkBounds.top, + this.walkBounds.bottom + ) + + const previousX = this.lastDragX ?? this.player.x + if (Math.abs(targetX - previousX) > 1) { + this.player.setFlipX(targetX < previousX) + } + + this.lastDragX = targetX + this.playerDragTarget = { x: targetX, y: targetY } + }) + + this.input.on('dragend', (_pointer, object) => { + if (object !== this.player) return + this.playerDragging = false + this.lastDragX = null + if (!this.exiting) this.animatePlayer('idle', 999) + }) + + this.cursors = this.input.keyboard.createCursorKeys() + this.keys = this.input.keyboard.addKeys('W,A,S,D') + } + + animatePlayer(mode, delta) { + if (!this.player) return + + const frameCount = mode === 'walk' + ? PLAYER_WALK_CROPS.length + : PLAYER_IDLE_CROPS.length + const frameMs = mode === 'walk' ? 105 : 420 + + if (this.playerAnimMode !== mode) { + this.playerAnimMode = mode + this.playerAnimFrame = 0 + this.playerAnimClock = 0 + this.player.setTexture('player-' + mode + '-0') + return + } + + this.playerAnimClock += delta + if (this.playerAnimClock < frameMs) return + + this.playerAnimClock %= frameMs + this.playerAnimFrame = (this.playerAnimFrame + 1) % frameCount + this.player.setTexture('player-' + mode + '-' + this.playerAnimFrame) + } + + addHouseExit(x, y, width = 126, height = 250) { + const door = this.add.rectangle(x, y, width, height, 0xfff6fa, 1) + .setStrokeStyle(6, 0xd094af) + .setDepth(80) + .setInteractive({ useHandCursor: true }) + + this.add.line( + x, + y - height * 0.18, + -width * 0.18, + 0, + -width * 0.18, + height * 0.36, + 0xc0809e, + 1 + ).setLineWidth(4).setDepth(81) + + this.add.line( + x, + y - height * 0.18, + width * 0.18, + 0, + width * 0.18, + height * 0.36, + 0xc0809e, + 1 + ).setLineWidth(4).setDepth(81) + + this.add.text(x, y, '♥', { + fontFamily: 'Arial', + fontSize: '34px', + color: '#c95b88', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(82) + + const label = this.add.text(x, y + height / 2 + 28, 'House', { + fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', + fontSize: '22px', + color: '#6d4c61', + backgroundColor: '#fff3f8', + padding: { x: 14, y: 8 } + }).setOrigin(0.5).setDepth(82).setInteractive({ useHandCursor: true }) + + const exit = () => this.returnToHouse() + door.on('pointerdown', exit) + label.on('pointerdown', exit) + } + + returnToHouse() { + if (this.exiting) return + this.exiting = true + this.playerDragging = false + this.playerDragTarget = null + this.cameras.main.fadeOut(180, 255, 247, 251) + + this.time.delayedCall(200, () => { + this.scene.start('House') + }) + } + + update(_time, delta) { + if (!this.player || !this.walkBounds || this.exiting) return + + if (this.playerDragging && this.playerDragTarget) { + const follow = 1 - Math.pow(1 - PLAYER_DRAG_FOLLOW, delta / 16.667) + + this.player.x = Phaser.Math.Linear( + this.player.x, + this.playerDragTarget.x, + follow + ) + this.player.y = Phaser.Math.Linear( + this.player.y, + this.playerDragTarget.y, + follow + ) + + this.animatePlayer('walk', delta) + return + } + + const left = this.cursors.left.isDown || this.keys.A.isDown + const right = this.cursors.right.isDown || this.keys.D.isDown + const up = this.cursors.up.isDown || this.keys.W.isDown + const down = this.cursors.down.isDown || this.keys.S.isDown + + let dx = Number(right) - Number(left) + let dy = Number(down) - Number(up) + const moving = dx !== 0 || dy !== 0 + + this.animatePlayer(moving ? 'walk' : 'idle', delta) + if (!moving) return + + const length = Math.hypot(dx, dy) || 1 + dx /= length + dy /= length + + this.player.x = Phaser.Math.Clamp( + this.player.x + dx * 320 * delta / 1000, + this.walkBounds.left, + this.walkBounds.right + ) + this.player.y = Phaser.Math.Clamp( + this.player.y + dy * 150 * delta / 1000, + this.walkBounds.top, + this.walkBounds.bottom + ) + + if (Math.abs(dx) > 0.05) this.player.setFlipX(dx < 0) + } +} + +class RoomScene extends PlayableLevelScene { + constructor() { + super('RoomScene') + this.roomId = 'living' + } + + init(data) { + this.roomId = data?.roomId ?? 'living' + } + + create() { + const room = ROOM_BY_ID.get(this.roomId) ?? ROOM_BY_ID.get('living') + const skin = ROOM_SKINS[room.id] ?? ROOM_SKINS.living + const graphics = this.add.graphics().setDepth(0) + + this.cameras.main.setBackgroundColor('#ccecff') + this.cameras.main.fadeIn(220, 255, 247, 251) + + const left = 64 + const right = WIDTH - 64 + const backLeft = 186 + const backRight = WIDTH - 186 + const top = 156 + const backTop = 210 + const horizonY = 930 + const bottomY = 1470 + + graphics.fillStyle(0xfffbfd, 1) + graphics.fillRoundedRect(42, 126, WIDTH - 84, 1372, 30) + + this.add.image( + WIDTH / 2, + backTop + (horizonY - backTop) / 2, + skin.wallpaper + ) + .setDisplaySize(backRight - backLeft, horizonY - backTop) + .setDepth(1) + + this.addMaskedLevelImage( + skin.wallpaper, + [ + { x: left, y: top }, + { x: backLeft, y: backTop }, + { x: backLeft, y: horizonY }, + { x: left, y: bottomY } + ], + 2, + 0xffedf5 + ) + + this.addMaskedLevelImage( + skin.wallpaper, + [ + { x: backRight, y: backTop }, + { x: right, y: top }, + { x: right, y: bottomY }, + { x: backRight, y: horizonY } + ], + 2, + 0xf2f6ff + ) + + this.addMaskedLevelImage( + skin.floor, + [ + { x: backLeft, y: horizonY }, + { x: backRight, y: horizonY }, + { x: right, y: bottomY }, + { x: left, y: bottomY } + ], + 3 + ) + + graphics.fillStyle(0x6d4c61, 0.12) + graphics.fillRect(backLeft, horizonY - 3, backRight - backLeft, 7) + + this.add.image(WIDTH / 2, backTop, 'architecture-ceilingTrim') + .setDisplaySize(backRight - backLeft + 24, 30) + .setDepth(8) + + this.add.image(WIDTH / 2, horizonY, 'architecture-skirting') + .setDisplaySize(backRight - backLeft + 24, 24) + .setDepth(8) + + this.add.image(WIDTH / 2, bottomY, 'architecture-floorEdge') + .setDisplaySize(right - left, 28) + .setDepth(8) + + graphics.lineStyle(5, 0xd2aabf, 1) + graphics.lineBetween(left, top, backLeft, backTop) + graphics.lineBetween(right, top, backRight, backTop) + graphics.lineBetween(left, bottomY, backLeft, horizonY) + graphics.lineBetween(right, bottomY, backRight, horizonY) + + graphics.lineStyle(6, 0xd5b8c6, 1) + graphics.strokeRoundedRect(42, 126, WIDTH - 84, 1372, 30) + + this.add.text(72, 62, room.name, { + fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', + fontSize: '42px', + color: '#684b5d', + fontStyle: 'bold' + }).setDepth(100) + + this.add.text(72, 112, 'Drag to explore', { + fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', + fontSize: '20px', + color: '#8a6c7d' + }).setDepth(100) + + // Elevator doorway is the return path to the dollhouse hub. + this.addHouseExit(916, 785, 126, 250) + + this.createLevelPlayer( + 780, + 1260, + { left: 150, right: 980, top: 1060, bottom: 1430 }, + 0.66 + ) + } +} + +class RoofScene extends PlayableLevelScene { + constructor() { + super('RoofScene') + } + + create() { + const graphics = this.add.graphics().setDepth(0) + + this.cameras.main.setBackgroundColor('#ccecff') + this.cameras.main.fadeIn(220, 255, 247, 251) + + this.add.text(72, 62, 'Roof', { + fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', + fontSize: '42px', + color: '#684b5d', + fontStyle: 'bold' + }).setDepth(100) + + this.add.text(72, 112, 'Drag to explore', { + fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', + fontSize: '20px', + color: '#6d7184' + }).setDepth(100) + + // Keep the roof level intentionally bare for now: just the approved shell + // roof language expanded into a large playable surface. + this.add.image(WIDTH / 2, 210, 'shell-roof') + .setOrigin(0.5, 0) + .setDisplaySize(1060, 228) + .setDepth(1) + + const roofBackY = 610 + const roofFrontY = 1450 + const roofLeftBack = 240 + const roofRightBack = WIDTH - 240 + const roofLeftFront = 92 + const roofRightFront = WIDTH - 92 + + graphics.fillStyle(0xf5a8c3, 1) + graphics.fillPoints([ + new Phaser.Geom.Point(roofLeftBack, roofBackY), + new Phaser.Geom.Point(roofRightBack, roofBackY), + new Phaser.Geom.Point(roofRightFront, roofFrontY), + new Phaser.Geom.Point(roofLeftFront, roofFrontY) + ], true) + + graphics.lineStyle(7, 0xb96f8e, 1) + graphics.strokePoints([ + new Phaser.Geom.Point(roofLeftBack, roofBackY), + new Phaser.Geom.Point(roofRightBack, roofBackY), + new Phaser.Geom.Point(roofRightFront, roofFrontY), + new Phaser.Geom.Point(roofLeftFront, roofFrontY) + ], true, true) + + graphics.lineStyle(4, 0xffd2df, 0.9) + graphics.lineBetween(WIDTH / 2, roofBackY, WIDTH / 2, roofFrontY) + + this.addHouseExit(WIDTH / 2, 690, 124, 236) + + this.createLevelPlayer( + WIDTH / 2 + 180, + 1200, + { left: 170, right: WIDTH - 170, top: 880, bottom: 1400 }, + 0.62 + ) + } +} + const game = new Phaser.Game({ type: Phaser.AUTO, parent: 'game', @@ -1303,11 +1694,11 @@ const game = new Phaser.Game({ input: { activePointers: 4 }, - scene: [HouseScene] + scene: [HouseScene, RoomScene, RoofScene] }) window.__DOLLHOUSE__ = { - version: '0.10.9', + version: '0.11.0', game, async resetAll() { await clearGameState()