Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions config/pool.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ OPX.Config.MODULES.pool = {
},
SENSITIVITY = 0.0018, -- radians per physical mouse count
CHARGE_SECONDS = 1.8,
OVERVIEW_HEIGHT = 1.65, -- metres above ball centres
OVERVIEW_HEIGHT = 1.65, -- metres above ball centres for the side camera
SOLO_SHOTS = 10,
DUEL_TARGET = 5,
AUDIO_VOLUME = 0.65, -- 0 mutes the recorded table contacts
Expand All @@ -30,7 +30,9 @@ OPX.Config.MODULES.pool = {
-- Do not use them as tents, sleeping bags or gym equipment on this world.
MODELS = {
cue = 'recreation.tent',
object = 'recreation.sleeping_bag',
object = 'recreation.sleeping_bag', -- retained legacy profile field
-- Object balls use the fifteen vanilla ball_c..ball_q meshes by default.
-- Optional objects = { ... } must name exactly fifteen distinct models.
stick = 'recreation.gym_equipment',
-- Host-specific heading/pivot calibration, in degrees/metres.
stickYaw = 0, stickOffset = 0.7,
Expand Down
37 changes: 23 additions & 14 deletions docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -948,12 +948,18 @@ Contract `bar` v1 (server): `Venues()`, `Items()`, `DrunkLevel(player)`. Bus:
### Pool

`modules/pool` + `config/pool.lua` offers three original modes using one white
cue ball and two yellow object balls. Cooked models, recorded audio and cleanup
cue ball and fifteen distinct vanilla object balls, arranged in a five-row triangle.
The object balls use the game’s `ball_c_billiard.mesh` through `ball_q_billiard.mesh`
models via the documented [raw mesh prop backend](https://open2077.net/docs/props).
An optional `MODELS.objects` array supplies exactly fifteen model paths. Existing
configs without that field receive the full vanilla set automatically. Cue/white
hosts, recorded audio and cleanup
patches live in the separate `opx_pool_assets` resource. Table actions use menu-wide
`closeOnSelect`, so selecting a mode dispatches its command before releasing
focus. Confirmation submenus dispatch only their confirmed selection. The modes
have no wagers,
XP or money rewards, spin, numbered-ball families or persisted match records.
XP or money rewards, spin, full eight-ball group/black-ball rules or persisted
match records. All fifteen object balls score equally in the existing points duel.

`TABLE.enabled` ships false. Survey a real, unobstructed table before enabling it:
`x/y/z` are the **ball centres above the cloth**, not the floor; `halfLength` and
Expand All @@ -975,21 +981,24 @@ in place; inspect the client ArchiveXL log. The gameplay module never deletes
another resource's props or world nodes.

The eye opens **Table and game modes**. Choose **Free practice** (unlimited
shots, automatic rerack after both yellows are pocketed), **Solo challenge**
(clear both within `SOLO_SHOTS`, default 10), or create a **Two-player duel**.
shots, automatic rerack after all fifteen object balls are pocketed), **Solo challenge**
(clear all fifteen within `SOLO_SHOTS`, default 10), or create a **Two-player duel**.
A second nearby player chooses **Join the duel**; this starts the game. A solo
challenge ends on a clear or the shot limit; scratching while pocketing the last
yellow loses the challenge. Scores count pockets, with a one-point scratch penalty
object ball loses the challenge. Scores count pockets, with a one-point scratch penalty
floored at zero. Duel target is `DUEL_TARGET` (default 5): a pocket retains the
turn, a miss or scratch passes it, and the two yellows rerack when cleared.
turn, a miss or scratch passes it, and the fifteen object balls rerack when cleared.
Only the server's physical outcomes change these scores and turns.

The current shooter automatically gets a camera behind the white ball. Mouse
movement aims; **Shift** gives fine control. Hold/release left-click to charge/fire.
**Middle mouse** toggles the overhead view; V remains available to inventory.
The wheel adjusts cue-view height. The overhead default is now **1.65 m** above
ball centres (`OVERVIEW_HEIGHT`, range 1.2–2.4), with a wider lens to keep the
cloth framed. A small lateral cue-camera offset keeps the shaft visible. The
**Middle mouse** toggles the side view; V remains available to inventory.
The wheel adjusts cue-view height. The cue camera lies directly on the shot axis
and targets the white ball itself, centering it without a shoulder offset. The
shot-follow camera shows the cloth from its long side and targets the table centre.
Its distance grows with the table dimensions to fit all corners, including on 4:3
screens. `OVERVIEW_HEIGHT` retains its name for config compatibility and now sets
the side-view elevation (default **1.65 m**, range 1.2–2.4). The
queue sits behind the white ball, retracts with charge and follows through for
220 ms after release. All rendered balls/queue explicitly request `collision =
false`, `physics = "none"`, `visible = true`; ball contacts remain server simulation.
Expand All @@ -1003,7 +1012,7 @@ until the balls stop. **Backspace** leaves at any time. Leaving, dying, changing
character/session, changing bucket or moving more than 6 m away forfeits an active
duel; the shooter must stay within 3.5 m. Unfilled lobbies expire after two minutes,
finished results free the table after 30 seconds. Spectators cannot change a game.
Participants can use middle mouse to watch the overhead view during the other turn.
Participants can use middle mouse to watch the side view during the other turn.

The overlay shows the mode, scores, active player, remaining solo shots and a
smooth power gauge, and takes no focus. Right-click cancels/resumes aim; while
Expand Down Expand Up @@ -1038,9 +1047,9 @@ presentation features, not new permissions to decide shots or outcomes.

The server owns all ball coordinates, collisions, pockets and scratch recovery.
Its fixed 120 Hz solver advances to swept ball/rail/pocket contacts, with a bounded
contact count and dissipative collisions/friction. Six timestamped samples are
sent every 50 ms to valid nearby watchers. Clients keep at most 40 samples and
interpolate 100 ms behind the stream; they never extrapolate or decide outcomes.
contact ceiling of 128 per fixed tick and dissipative collisions/friction. Two
timestamped endpoint samples are sent every 50 ms to valid nearby watchers.
Clients keep at most 40 samples and interpolate 100 ms behind the stream; they never extrapolate or decide outcomes.
Creation, joining, shots and resets bind to the viewed epoch/revision. Reset votes
and scores remain tied to the two original character/session tickets. Shot and
rack requests carry the current epoch and ready revision. Old, duplicate,
Expand Down
14 changes: 7 additions & 7 deletions modules/pool/client/camera.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ function C.Pose(spec, points, angle, overview, height)
local c, s = math.cos(angle), math.sin(angle)
local ball = points[1]
if overview then
local at = Model.World(spec, -c*0.45, -s*0.45)
-- A stable broadside view: fit the full cloth instead of hovering over it.
local distance = math.max(spec.halfWidth+1.8, spec.halfLength*1.45+spec.halfWidth)
local at = Model.World(spec, 0, -distance)
at.z = at.z+(Model.Number((M.Settings or {}).OVERVIEW_HEIGHT, 1.2, 2.4) or 1.65)
local aim = Model.World(spec, 0, 0)
return { position = at, target = aim, fov = 72 }
return { position = at, target = Model.World(spec, 0, 0), fov = 60 }
end
local at = Model.World(spec, ball[1]-c*1.25-s*0.14, ball[2]-s*1.25+c*0.14)
-- The ball is the exact look-at point, on the shot axis with no shoulder offset.
local at = Model.World(spec, ball[1]-c*1.25, ball[2]-s*1.25)
at.z = at.z+height
local aim = Model.World(spec, ball[1]+c*0.45, ball[2]+s*0.45)
aim.z = aim.z+0.015
return { position = at, target = aim, fov = 52 }
return { position = at, target = Model.World(spec, ball[1], ball[2]), fov = 52 }
end
function C.Update(spec, points, angle, overview, height, now)
local wanted = C.Pose(spec, points, angle, overview, height)
Expand Down
9 changes: 5 additions & 4 deletions modules/pool/client/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ local Model = M.Model
M.Render = {}
local R = M.Render
local ids, stick, created, failure, stroke = {}, nil, {}, nil, nil
local models
function R.Clear()
for index, id in pairs(ids) do Open77.props.remove(id); ids[index] = nil end
if stick then Open77.props.remove(stick); stick = nil end
created, stroke = {}, nil
end
function R.Reset() R.Clear(); failure = nil end
function R.Reset() R.Clear(); failure = nil; models = Model.Models() end
local function fail(why)
if not failure then
failure = tostring(why)
Expand Down Expand Up @@ -39,21 +40,21 @@ function R.Strike(spec, positions, angle, power)
stroke = { at = OPX.Now(), point = { positions[1][1], positions[1][2], true }, angle = angle, power = power }
end
function R.StickTransform(spec, ball, angle, power, forward)
local models = Model.Models()
models = models or Model.Models()
local distance = math.max(0.2, models.stickOffset + power*0.35-(forward or 0))
local at = Model.World(spec, ball[1]-math.cos(angle)*distance, ball[2]-math.sin(angle)*distance)
at.z = at.z+0.006
return { position = at, yaw = math.deg(angle)+spec.yaw-90+models.stickYaw }
end
function R.Draw(spec, positions, aiming, angle, power)
if failure then return false end
local models = Model.Models()
models = models or Model.Models()
if not models then return fail('invalid model configuration') end
for index, ball in ipairs(positions) do
if ball[3] then
local position = Model.World(spec, ball[1], ball[2])
if not ids[index] then
local id, why = make(index == 1 and models.cue or models.object, position)
local id, why = make(index == 1 and models.cue or models.objects[index-1], position)
if not id then return fail(why) end
ids[index] = id
elseif not move(ids[index], { position = position }) then return false end
Expand Down
16 changes: 8 additions & 8 deletions modules/pool/locales.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ local EN = {
['pool.power'] = 'Power {power}%',
['pool.powerLabel'] = 'Shot power',
['pool.precise'] = 'Fine aim',
['pool.overview'] = 'Switch table / cue view',
['pool.overview'] = 'Switch side / cue view',
['pool.release'] = 'Release to shoot · RMB to cancel',
['pool.tableView'] = 'Table view',
['pool.tableView'] = 'Side view',
['pool.cueView'] = 'Cue view',
['pool.camera'] = 'The aiming view is unavailable. Try again; if this continues, contact an administrator.',
['pool.busy'] = 'This table already has a session. Join an open duel or wait for it to finish.',
['pool.models'] = 'This table is unavailable. If this seems wrong, please contact an administrator.',
['pool.simulation'] = 'This shot could not be completed. Reset the balls; contact an administrator if it happens again.',
['pool.practice'] = "Free practice",
['pool.practiceInfo'] = "Unlimited practice with two object balls. Reset whenever the balls stop.",
['pool.practiceInfo'] = "Unlimited practice with fifteen object balls. Reset whenever the balls stop.",
['pool.challenge'] = "Solo challenge",
['pool.challengeInfo'] = "Pocket both object balls within {shots} shots. Scratching on the final ball loses the challenge.",
['pool.challengeInfo'] = "Pocket all fifteen object balls within {shots} shots. Scratching on the final ball loses the challenge.",
['pool.duel'] = "Two-player duel",
['pool.duelInfo'] = "First to {target} points. Pocket a ball to keep the turn; miss or scratch to pass. A scratch costs one point.",
['pool.join'] = "Join the duel",
Expand Down Expand Up @@ -79,18 +79,18 @@ local FR = {
['pool.power'] = 'Puissance {power} %',
['pool.powerLabel'] = 'Puissance du tir',
['pool.precise'] = 'Visée précise',
['pool.overview'] = 'Vue de table / vue de tir',
['pool.overview'] = 'Vue de côté / vue de tir',
['pool.release'] = 'Relâchez pour tirer · Clic droit pour annuler',
['pool.tableView'] = 'Vue de table',
['pool.tableView'] = 'Vue de côté',
['pool.cueView'] = 'Vue de tir',
['pool.camera'] = 'La vue de tir est indisponible. Réessayez ; si cela persiste, contactez un administrateur.',
['pool.busy'] = 'Une session occupe déjà cette table. Rejoignez un duel ouvert ou attendez la fin.',
['pool.models'] = 'Cette table est indisponible. Si cela vous semble anormal, contactez un administrateur.',
['pool.simulation'] = 'Ce tir n’a pas pu être terminé. Replacez les billes ; contactez un administrateur si cela se reproduit.',
['pool.practice'] = "Entraînement libre",
['pool.practiceInfo'] = "Deux billes jaunes, autant de tirs que souhaité. Remise à zéro à l’arrêt des billes.",
['pool.practiceInfo'] = "Quinze billes de couleur, autant de tirs que souhaité. Remise à zéro à l’arrêt des billes.",
['pool.challenge'] = "Défi solo",
['pool.challengeInfo'] = "Empochez les deux jaunes en {shots} tirs. Empocher aussi la blanche au dernier tir fait perdre le défi.",
['pool.challengeInfo'] = "Empochez les quinze billes de couleur en {shots} tirs. Empocher aussi la blanche au dernier tir fait perdre le défi.",
['pool.duel'] = "Duel à deux",
['pool.duelInfo'] = "Premier à {target} points. Empochez pour rejouer ; un raté ou une blanche passe la main. La blanche coûte un point.",
['pool.join'] = "Rejoindre le duel",
Expand Down
2 changes: 1 addition & 1 deletion modules/pool/module.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
--- Three-ball solo and two-player modes. No wagers, XP or imported nc_pool gameplay code. Visuals live in opx_pool_assets.
--- Full-rack solo and two-player modes. No wagers, XP or imported nc_pool gameplay code. Visuals live in opx_pool_assets.
-- @author dop42
local M = OPX.Modules.Declare{
id = 'pool', side = 'both', fatal = false,
Expand Down
4 changes: 3 additions & 1 deletion modules/pool/server/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ local function step()
else phase, readyAt, revision, aim.visible = 'ready', ticks*1000/120, revision+1, false end
end
end
frames[#frames+1] = P.Frame(balls, ticks*1000/120)
-- Keep fixed-step physics at 120 Hz; two endpoint samples per 50 ms
-- update bound client validation/transport cost for all sixteen balls.
if #frames == 0 or _ == 6 then frames[#frames+1] = P.Frame(balls, ticks*1000/120) end
end
while sounds[1] and ticks*1000/120-sounds[1].time > 250 do table.remove(sounds, 1) end
publish(frames)
Expand Down
17 changes: 11 additions & 6 deletions modules/pool/server/physics.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ M.Physics = {}
local P = M.Physics
local DRAG, RESTITUTION, RAIL, EPS = 0.7, 0.97, 0.82, 1e-8
function P.Rack(spec)
return {
{ x = -spec.halfLength * 0.5, y = 0, vx = 0, vy = 0, alive = true },
{ x = spec.halfLength * 0.35, y = -R * 1.05, vx = 0, vy = 0, alive = true },
{ x = spec.halfLength * 0.35, y = R * 1.05, vx = 0, vy = 0, alive = true },
}
local balls = { { x = -spec.halfLength*0.5, y = 0, vx = 0, vy = 0, alive = true } }
-- Five rows, a tiny gap to avoid overlapping contact chains at the break.
local spacing = R*2.02
for row = 0, 4 do
for column = 0, row do
balls[#balls+1] = { x = spec.halfLength*0.35+row*spacing*math.sqrt(3)/2,
y = (column-row/2)*spacing, vx = 0, vy = 0, alive = true }
end
end
return balls
end
local function hitTime(x, y, vx, vy, radius, limit)
local c = x*x + y*y - radius*radius
Expand Down Expand Up @@ -75,7 +80,7 @@ function P.Step(balls, spec, dt, contact)
remaining = remaining - soon
if not kind then break end
contacts = contacts + 1
if contacts > 32 then
if contacts > 128 then
for _, ball in ipairs(balls) do ball.vx, ball.vy = 0, 0 end
return false
end
Expand Down
2 changes: 1 addition & 1 deletion modules/pool/shared/match.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
--- Closed three-ball modes and pure scoring; the server alone applies them.
--- Closed full-rack modes and pure scoring; the server alone applies them.
-- @author dop42
local M = OPX.Modules.Get('pool')
local Number = M.Model.Number
Expand Down
12 changes: 11 additions & 1 deletion modules/pool/shared/model.lua
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
--- Bounded geometry and wire vocabulary; pure in both sandboxes.
-- @author dop42
local M = OPX.Modules.Get('pool')
M.Model = { RADIUS = 0.028575, BALLS = 3, STEP = 1 / 120, DELAY = 100 }
M.Model = { RADIUS = 0.028575, BALLS = 16, STEP = 1 / 120, DELAY = 100 }
local Model = M.Model
function Model.Number(value, low, high)
return type(value) == 'number' and value == value and value >= low and value <= high and value or nil
Expand Down Expand Up @@ -43,6 +43,16 @@ function Model.Models()
if type(path) ~= 'string' or #path == 0 or #path > 256 or path:find('%c') then return nil end
result[name] = path
end
result.objects = {}
local used = {}
if raw.objects ~= nil and (type(raw.objects) ~= 'table' or #raw.objects ~= Model.BALLS-1) then return nil end
for number = 1, Model.BALLS-1 do
local path = raw.objects and raw.objects[number] or
('base\\environment\\decoration\\recreation\\sports\\ball\\ball_' .. string.char(98+number) .. '_billiard.mesh')
if type(path) ~= 'string' or #path == 0 or #path > 256 or path:find('%c') or used[path] then return nil end
used[path] = true
result.objects[number] = path
end
result.stickYaw = Model.Number(raw.stickYaw, -360, 360) or 0
result.stickOffset = Model.Number(raw.stickOffset, 0.2, 2) or 0.7
return result
Expand Down
24 changes: 21 additions & 3 deletions tests/audit-pool-camera.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,28 @@ for _,file in ipairs({'shared/model','client/camera','client/guide','client/inpu
local C,G,I=M.Camera,M.Guide,M.Input
local spec={x=100,y=50,z=10,yaw=90,halfLength=1.4,halfWidth=0.68}
local balls={{0,0,true},{0.5,0,true},{0.8,0.3,true}}
for index=4,M.Model.BALLS do balls[index]={0,0,false} end
local p=C.Pose(spec,balls,0,false,0.5)
assert(math.abs(p.position.x-99.86)<1e-9 and math.abs(p.position.y-48.75)<1e-9 and p.position.z==10.5,'cue camera not behind white ball in world frame')
assert(p.target.y>50 and p.position.z>p.target.z,'cue view does not look down the shot')
assert(C.Pose(spec,balls,0,true,0.5).position.z==11.65,'overhead camera was not lowered')
assert(math.abs(p.position.x-100)<1e-9 and math.abs(p.position.y-48.75)<1e-9 and p.position.z==10.5,'cue camera not behind white ball in world frame')
assert(p.target.x==100 and p.target.y==50 and p.target.z==10,'white ball is not exactly centered')
assert(C.Pose(spec,balls,0,true,0.5).position.z==11.65,'side camera height changed')
-- Project the cloth's corners into a conservative 4:3 camera frustum.
for _,size in ipairs({{0.9,0.4},{1.4,0.68},{2,1}}) do
for _,yaw in ipairs({0,90,179}) do
local tableSpec={x=100,y=50,z=10,yaw=yaw,halfLength=size[1],halfWidth=size[2]}
local pose=C.Pose(tableSpec,balls,2,true,0.5)
local dx,dy,dz=pose.target.x-pose.position.x,pose.target.y-pose.position.y,pose.target.z-pose.position.z
local n=math.sqrt(dx*dx+dy*dy+dz*dz);dx,dy,dz=dx/n,dy/n,dz/n
local h=math.sqrt(dx*dx+dy*dy);local rx,ry=dy/h,-dx/h
local ux,uy,uz=-dz*ry,dz*rx,dx*ry-dy*rx
local tangent=math.tan(math.rad(pose.fov/2))
for _,x in ipairs({-size[1],size[1]})do for _,y in ipairs({-size[2],size[2]})do
local point=M.Model.World(tableSpec,x,y);local a,b,c=point.x-pose.position.x,point.y-pose.position.y,point.z-pose.position.z
local depth=a*dx+b*dy+c*dz
assert(depth>0 and math.abs(a*rx+b*ry)<depth*tangent*4/3 and math.abs(a*ux+b*uy+c*uz)<depth*tangent,'side view clips a cloth corner')
end end
end
end
assert(C.Update(spec,balls,0,false,0.5,0) and current==1)
assert(calls[1][2].thirdPersonBody==true,'scripted view omitted the full body request')
assert(C.Check())
Expand Down
Loading
Loading