Rebalance ship and shared world scale

This commit is contained in:
Admin 2026-09-21 06:20:26 +00:00
parent e00c57ca25
commit ce8c8333cc
5 changed files with 83 additions and 23 deletions

View File

@ -22,9 +22,9 @@ import (
) )
const ( const (
worldSize = 3600.0 worldSize = 5400.0
spawnX = 1200.0 spawnX = 1800.0
spawnY = 1550.0 spawnY = 2325.0
playerMaxHull = 100 playerMaxHull = 100
playerMaxSpeed = 180.0 playerMaxSpeed = 180.0
playerReverseSpeed = -55.0 playerReverseSpeed = -55.0
@ -36,7 +36,7 @@ const (
shotSpeed = 520.0 shotSpeed = 520.0
shotLifetime = 900 * time.Millisecond shotLifetime = 900 * time.Millisecond
shotDamage = 16 shotDamage = 16
shipHitRadius = 44.0 shipHitRadius = 34.0
respawnDelay = 2200 * time.Millisecond respawnDelay = 2200 * time.Millisecond
simTick = time.Second / 30 simTick = time.Second / 30
snapshotTick = 50 * time.Millisecond snapshotTick = 50 * time.Millisecond
@ -229,8 +229,8 @@ func (h *hub) addFakeCaptains(count int) {
id := "bot:" + strings.ToLower(strings.ReplaceAll(name, " ", "-")) id := "bot:" + strings.ToLower(strings.ReplaceAll(name, " ", "-"))
state := newPlayerState(id, name, 0, 0) state := newPlayerState(id, name, 0, 0)
phase := math.Pi * 2 * float64(index) / float64(count) phase := math.Pi * 2 * float64(index) / float64(count)
state.X = clampFloat(spawnX+math.Cos(phase)*300, 90, worldSize-90) state.X = clampFloat(spawnX+math.Cos(phase)*650, 90, worldSize-90)
state.Y = clampFloat(spawnY+math.Sin(phase)*300, 90, worldSize-90) state.Y = clampFloat(spawnY+math.Sin(phase)*650, 90, worldSize-90)
state.Angle = normalizeAngle(phase*180/math.Pi + 90) state.Angle = normalizeAngle(phase*180/math.Pi + 90)
state.Bot = true state.Bot = true
h.clients[id] = &client{ h.clients[id] = &client{
@ -329,8 +329,8 @@ func (h *hub) fireLocked(c *client, side string, now time.Time) bool {
projectileID := c.state.ID + ":" + strconv.FormatUint(h.nextProjectile, 10) projectileID := c.state.ID + ":" + strconv.FormatUint(h.nextProjectile, 10)
h.projectiles[projectileID] = projectileState{ h.projectiles[projectileID] = projectileState{
ID: projectileID, OwnerID: c.state.ID, ID: projectileID, OwnerID: c.state.ID,
X: c.state.X + math.Cos(base)*34, X: c.state.X + math.Cos(base)*28,
Y: c.state.Y + math.Sin(base)*34, Y: c.state.Y + math.Sin(base)*28,
VX: math.Cos(angle)*shotSpeed + shipVX*0.25, VX: math.Cos(angle)*shotSpeed + shipVX*0.25,
VY: math.Sin(angle)*shotSpeed + shipVY*0.25, VY: math.Sin(angle)*shotSpeed + shipVY*0.25,
Damage: shotDamage, Damage: shotDamage,
@ -428,7 +428,7 @@ func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, s
dx := candidate.state.X - c.state.X dx := candidate.state.X - c.state.X
dy := candidate.state.Y - c.state.Y dy := candidate.state.Y - c.state.Y
distance := math.Hypot(dx, dy) distance := math.Hypot(dx, dy)
if distance < bestRange { if distance <= 1100 && distance < bestRange {
target = candidate target = candidate
bestRange = distance bestRange = distance
} }
@ -436,7 +436,7 @@ func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, s
if target == nil { if target == nil {
phase := float64(c.botIndex)*2.1 + float64(now.UnixMilli()%60000)/60000*math.Pi*2 phase := float64(c.botIndex)*2.1 + float64(now.UnixMilli()%60000)/60000*math.Pi*2
radius := 420.0 + float64(c.botIndex)*75 radius := 720.0 + float64(c.botIndex)*110
targetX := spawnX + math.Cos(phase)*radius targetX := spawnX + math.Cos(phase)*radius
targetY := spawnY + math.Sin(phase)*radius targetY := spawnY + math.Sin(phase)*radius
bearing := bearingDegrees(c.state.X, c.state.Y, targetX, targetY) bearing := bearingDegrees(c.state.X, c.state.Y, targetX, targetY)

View File

@ -190,3 +190,39 @@ func TestFakeCaptainEngagesNearbyHumanWithAuthoritativeBroadside(t *testing.T) {
t.Fatal("fake captain input sequence did not advance") t.Fatal("fake captain input sequence did not advance")
} }
} }
func TestExpandedWorldAndSmallerShipCombatScale(t *testing.T) {
if worldSize != 5400 {
t.Fatalf("worldSize=%v", worldSize)
}
if shipHitRadius != 34 {
t.Fatalf("shipHitRadius=%v", shipHitRadius)
}
}
func TestFakeCaptainDoesNotAggroHumanAcrossWholeWorld(t *testing.T) {
h := newHub("TEST", 1)
var bot *client
for _, candidate := range h.clients {
if candidate.state.Bot {
bot = candidate
break
}
}
human := &client{
state: newPlayerState("human-far", "Human", 0, 0),
lastFire: map[string]time.Time{},
}
bot.state.X, bot.state.Y = 500, 500
human.state.X, human.state.Y = 5000, 5000
h.clients[human.state.ID] = human
input, fireSide := h.fakeCaptainDecisionLocked(bot, time.Unix(300, 0))
if fireSide != "" {
t.Fatalf("far human should not trigger broadside: %s", fireSide)
}
if input.Throttle <= 0 {
t.Fatalf("bot should patrol when human is far: %+v", input)
}
}

View File

@ -71,7 +71,7 @@ import {
merchantRouteEndpoint, merchantRouteEndpoint,
} from './world/trade-traffic.js'; } from './world/trade-traffic.js';
const WORLD_SIZE = 3600; const WORLD_SIZE = 5400;
const PLAYER_MAX_SPEED = 180; const PLAYER_MAX_SPEED = 180;
const PLAYER_REVERSE_SPEED = -55; const PLAYER_REVERSE_SPEED = -55;
const PLAYER_ACCELERATION = 92; const PLAYER_ACCELERATION = 92;
@ -124,6 +124,8 @@ class GameScene extends Phaser.Scene {
seed: querySeed || 'SKYWARD-001', seed: querySeed || 'SKYWARD-001',
width: WORLD_SIZE, width: WORLD_SIZE,
height: WORLD_SIZE, height: WORLD_SIZE,
islandCount: 24,
portCount: 9,
}); });
this.progression = createStarterProgression(); this.progression = createStarterProgression();
this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions); this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions);
@ -217,9 +219,9 @@ class GameScene extends Phaser.Scene {
this.player = this.physics.add.sprite(1200, 1550, 'player-airship', 0); this.player = this.physics.add.sprite(1200, 1550, 'player-airship', 0);
this.player.setPosition(this.world.spawn.x, this.world.spawn.y); this.player.setPosition(this.world.spawn.x, this.world.spawn.y);
this.player.setDepth(6).setCollideWorldBounds(true).setDisplaySize(66, 126); this.player.setDepth(6).setCollideWorldBounds(true).setDisplaySize(52, 100);
this.player.body.setAllowGravity(false); this.player.body.setAllowGravity(false);
this.player.body.setCircle(44, 20, 84); this.player.body.setCircle(34, 30, 94);
this.player.setFrame(0); this.player.setFrame(0);
this.createPlayerSmoke(); this.createPlayerSmoke();
@ -325,7 +327,7 @@ class GameScene extends Phaser.Scene {
this.cameras.main.setBounds(0, 0, this.world.width, this.world.height); this.cameras.main.setBounds(0, 0, this.world.width, this.world.height);
this.cameras.main.startFollow(this.player, true, 0.08, 0.08); this.cameras.main.startFollow(this.player, true, 0.08, 0.08);
this.cameras.main.setZoom(1.12); this.cameras.main.setZoom(1.0);
this.createHud(); this.createHud();
@ -1606,13 +1608,13 @@ class GameScene extends Phaser.Scene {
); );
sprite sprite
.setDepth(5.5) .setDepth(5.5)
.setDisplaySize(60, 114) .setDisplaySize(48, 92)
.setTint(0x8cd3ff) .setTint(0x8cd3ff)
.setAlpha(0.86); .setAlpha(0.86);
sprite.play('player-forward'); sprite.play('player-forward');
sprite.anims.timeScale = 0.72; sprite.anims.timeScale = 0.72;
const label = this.add.text(sprite.x, sprite.y - 70, state.name || 'CAPTAIN', { const label = this.add.text(sprite.x, sprite.y - 56, state.name || 'CAPTAIN', {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '10px', fontSize: '10px',
color: '#bfe6ff', color: '#bfe6ff',
@ -1745,7 +1747,7 @@ class GameScene extends Phaser.Scene {
sprite.x = Phaser.Math.Linear(sprite.x, remote.targetX, 0.32); sprite.x = Phaser.Math.Linear(sprite.x, remote.targetX, 0.32);
sprite.y = Phaser.Math.Linear(sprite.y, remote.targetY, 0.32); sprite.y = Phaser.Math.Linear(sprite.y, remote.targetY, 0.32);
sprite.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32; sprite.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32;
remote.label.setPosition(sprite.x, sprite.y - 70); remote.label.setPosition(sprite.x, sprite.y - 56);
remote.label.setVisible(window.airshipSettings?.get?.().showPlayerLabels !== false); remote.label.setVisible(window.airshipSettings?.get?.().showPlayerLabels !== false);
} }
} }

View File

@ -187,10 +187,10 @@ const candidatesLength = (islands, ports) => Math.max(
export const generateWorld = ({ export const generateWorld = ({
seed = 'SKYWARD-001', seed = 'SKYWARD-001',
width = 3600, width = 5400,
height = 3600, height = 5400,
islandCount = 16, islandCount = 24,
portCount = 7, portCount = 9,
} = {}) => { } = {}) => {
const random = createSeededRandom(seed); const random = createSeededRandom(seed);
const regions = createRegions(random, width, height); const regions = createRegions(random, width, height);

View File

@ -277,8 +277,8 @@ test('player uses the four-frame north-facing sheet as a forward propulsion anim
assert.match(source, /import playerAirshipUrl from '.\/assets\/player-airship-4frame\.png\?url'/); assert.match(source, /import playerAirshipUrl from '.\/assets\/player-airship-4frame\.png\?url'/);
assert.match(source, /this\.load\.spritesheet\('player-airship', playerAirshipUrl, \{[\s\S]*?frameWidth: 128,[\s\S]*?frameHeight: 256/); assert.match(source, /this\.load\.spritesheet\('player-airship', playerAirshipUrl, \{[\s\S]*?frameWidth: 128,[\s\S]*?frameHeight: 256/);
assert.match(source, /this\.physics\.add\.sprite\(1200, 1550, 'player-airship', 0\)/); assert.match(source, /this\.physics\.add\.sprite\(1200, 1550, 'player-airship', 0\)/);
assert.match(source, /setDisplaySize\(66, 126\)/); assert.match(source, /setDisplaySize\(52, 100\)/);
assert.match(source, /this\.player\.body\.setCircle\(44, 20, 84\)/); assert.match(source, /this\.player\.body\.setCircle\(34, 30, 94\)/);
assert.match(source, /key: 'player-forward'/); assert.match(source, /key: 'player-forward'/);
assert.match(source, /generateFrameNumbers\('player-airship', \{ start: 0, end: 3 \}\)/); assert.match(source, /generateFrameNumbers\('player-airship', \{ start: 0, end: 3 \}\)/);
assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/); assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/);
@ -329,6 +329,28 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
}); });
test('larger shared world uses smaller player ships and wider fake-captain spacing', async () => {
const [game, world, server] = await Promise.all([
read('src/game-v4.js'),
read('src/world/world-generator.js'),
read('server/main.go'),
]);
assert.match(game, /const WORLD_SIZE = 5400/);
assert.match(game, /islandCount: 24/);
assert.match(game, /portCount: 9/);
assert.match(game, /setDisplaySize\(52, 100\)/);
assert.match(game, /setCircle\(34, 30, 94\)/);
assert.match(game, /setDisplaySize\(48, 92\)/);
assert.match(game, /this\.cameras\.main\.setZoom\(1\.0\)/);
assert.match(world, /width = 5400/);
assert.match(world, /islandCount = 24/);
assert.match(server, /worldSize\s+= 5400\.0/);
assert.match(server, /shipHitRadius\s+= 34\.0/);
assert.match(server, /math\.Cos\(phase\)\*650/);
assert.match(server, /distance <= 1100/);
});
test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => { test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => {
const server = await read('server/main.go'); const server = await read('server/main.go');