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
2 changes: 2 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
run: node tests/ui-notify-lifecycle.mjs
- name: Check modal keyboard ownership
run: node tests/ui-spawn-focus.mjs
- name: Pool gauge lifecycle
run: node tests/ui-pool-lifecycle.mjs
- name: Garment generator encoding regressions
run: node tests/tool-garments.mjs
- name: Image CDN catalogue regressions
Expand Down
5 changes: 3 additions & 2 deletions config/pool.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ OPX.Config.MODULES.pool = {
north_oak_casino_main = { label = 'North Oak Casino', x = 1003.637, y = 1494.385, z = 246.863, yaw = -178.49, bucket = 0, halfLength = 1.4, halfWidth = 0.68 },
westbrook_luxury_apartment = { label = 'Luxury Apartment', x = 16.227, y = -0.07, z = 135.687, yaw = -31.88, bucket = 0, halfLength = 1.4, halfWidth = 0.68 },
},
SENSITIVITY = 0.0025, -- radians per physical mouse count
CHARGE_SECONDS = 2.4,
SENSITIVITY = 0.0018, -- radians per physical mouse count
CHARGE_SECONDS = 1.8,
AIM_GUIDE = true, -- straight line to the first ball/cushion; no bounce prediction
-- opx_pool_assets reserves these three native recreation aliases globally.
-- Do not use them as tents, sleeping bags or gym equipment on this world.
MODELS = {
Expand Down
30 changes: 25 additions & 5 deletions docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -970,11 +970,31 @@ another resource's props or world nodes.

The eye offers **Practise pool** at the configured table. One loaded character
holds the practice seat; nearby loaded characters in the same bucket receive
spectator frames. Right-click arms/cancels aim, mouse movement adjusts direction,
holding/releasing left-click charges/fires, **R** resets the balls while unarmed,
and **Backspace** leaves. Walking remains free. Aiming temporarily holds camera
rotation; opening a keyboard-owning UI cancels charge without shooting. The
existing prompts strip shows controls and power without taking WebUI focus.
spectator frames. Entering automatically frames the white ball from behind the
cue. Move the mouse to aim; hold **Shift** for fine adjustments. Holding left-click
charges the shot and releasing fires; the dedicated, unfocused overlay shows a
smooth power gauge. The direction becomes finer while charging. **V** toggles an
overhead view for planning; the mouse wheel adjusts the cue-view height. A dashed
straight guide stops at the first ball or cushion, with a cue-ball contact ring;
it does not predict rebounds or results. Set `AIM_GUIDE = false` to hide it.
`SENSITIVITY` defaults to `0.0018`, `CHARGE_SECONDS` to `1.8`.

After a shot the camera moves to the table view until authoritative playback has
settled, then returns behind the white ball. Right-click cancels/resumes aiming;
while cancelled the player can walk around and **R** resets the practice balls.
**Backspace** leaves, including while balls move. Position, rotation, jump, dodge
and ordinary interaction are held only during the camera view, through the same
module-owned control leases used by target/police/hauling. UI capture, loss of the
seat, character unload, leaving and stop remove the camera, guide and gauge and
release those claims. Capturing input cancels a charge without firing and does
not automatically resume it when the UI closes. A camera refusal leaves practice
with a friendly notice and records the native reason in operator diagnostics.

The camera needs `camera.script`; the bounded aiming guide uses `world.debug`.
Its camera/prop work runs per frame; guide geometry is capped at 20 lines and
refreshed every 100 ms with a 200 ms native expiry. The power overlay consumes no
pointer or keyboard and expires stale readings after 500 ms. These are client
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
Expand Down
5 changes: 3 additions & 2 deletions lib/client/controls.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
OPX.Controls = {}
local claims = {}
local known = { allowAim = true, allowShoot = true, allowInteraction = true,
allowWeapons = true, allowDodge = true, allowJump = true, freezeRotation = true }
allowWeapons = true, allowDodge = true, allowJump = true, freezeRotation = true, freezePosition = true }
local function held(name, except)
for owner, names in pairs(claims) do if owner ~= except and names[name] then return true end end
return false
Expand All @@ -19,7 +19,8 @@ function OPX.Controls.Set(owner, names, take)
end
local previous, changed = claims[owner] or {}, {}
local function apply(name, on)
local value = name == 'freezeRotation' and on or name ~= 'freezeRotation' and not on
local freeze = name == 'freezeRotation' or name == 'freezePosition'
local value = freeze and on or not freeze and not on
local ran, ok = pcall(players[name], value)
return ran and ok == true
end
Expand Down
64 changes: 64 additions & 0 deletions modules/pool/client/camera.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
--- A cue-ball view and a table view, owned only while this player is shooting.
-- @author dop42
local M = OPX.Modules.Get('pool')
local Model = M.Model
M.Camera = {}
local C = M.Camera
local id, pose, last, active
local function mix(a, b, amount) return a + (b-a)*amount end
function C.Clear()
-- Destroying our definition restores the view only if THIS camera held it.
-- Never deactivate another camera owned by a different module in Infinity.
if id then
local ok, why = Open77.camera.destroy(id)
if not ok and why ~= 'camera_not_found' then OPX.Note('pool', 'camera cleanup: ' .. tostring(why)) end
end
id, pose, last, active = nil, nil, nil, false
end
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.65, -s*0.65)
at.z = at.z+2.35
local aim = Model.World(spec, 0, 0)
return { position = at, target = aim, fov = 62 }
end
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 }
end
function C.Update(spec, points, angle, overview, height, now)
local wanted = C.Pose(spec, points, angle, overview, height)
if not id then
local why
id, why = Open77.camera.create({ position = wanted.position, lookAt = wanted.target, fov = wanted.fov })
if not id then return false, why end
local ok, refusal = Open77.camera.activate(id, { blendMs = 250 })
if not ok then C.Clear(); return false, refusal end
pose, last, active = wanted, now, true
return true
end
-- Frame-rate-independent smoothing, including the transition to the table.
local amount = 1-math.exp(-math.max(0, math.min(100, now-last))/80)
last = now
for _, key in ipairs({ 'x', 'y', 'z' }) do
pose.position[key] = mix(pose.position[key], wanted.position[key], amount)
pose.target[key] = mix(pose.target[key], wanted.target[key], amount)
end
pose.fov = mix(pose.fov, wanted.fov, amount)
local ok, why = Open77.camera.setTransform(id, { position = pose.position, fov = pose.fov })
if not ok then C.Clear(); return false, why end
ok, why = Open77.camera.lookAt(id, pose.target)
if not ok then C.Clear(); return false, why end
return true
end
function C.Check()
if not active then return true end
local snapshot, why = Open77.camera.cameras()
if type(snapshot) ~= 'table' then return false, why end
if snapshot.activeCamera ~= id or snapshot.held ~= true then return false, 'camera_released' end
return true
end
57 changes: 57 additions & 0 deletions modules/pool/client/guide.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
--- A bounded straight aiming aid, ending at the first ball or cushion.
-- @author dop42
local M = OPX.Modules.Get('pool')
local Model = M.Model
M.Guide = {}
local G = M.Guide
local shown, refused, last = false, false, -math.huge
function G.Clear()
if shown then Open77.debugDraw.clear('pool.aim'); shown = false end
end
function G.Reset() G.Clear(); refused, last = false, -math.huge end
function G.End(spec, points, angle)
local x, y, c, s = points[1][1], points[1][2], math.cos(angle), math.sin(angle)
local length = 6
local r = Model.RADIUS
if math.abs(c) > 1e-6 then length = math.min(length, ((c > 0 and spec.halfLength-r or -spec.halfLength+r)-x)/c) end
if math.abs(s) > 1e-6 then length = math.min(length, ((s > 0 and spec.halfWidth-r or -spec.halfWidth+r)-y)/s) end
for index = 2, Model.BALLS do
local ball = points[index]
if ball[3] then
local dx, dy = ball[1]-x, ball[2]-y
local ahead, side = dx*c+dy*s, dx*s-dy*c
if ahead > 0 and math.abs(side) < r*2 then
local touch = ahead-math.sqrt(math.max(0, r*r*4-side*side))
if touch >= 0 then length = math.min(length, touch) end
end
end
end
length = math.max(0, length)
return x+c*length, y+s*length, length
end
function G.Draw(spec, points, angle, now)
if refused or now-last < 50 then return end
last = now
local x, y, length = G.End(spec, points, angle)
local ball = points[1]
local lines = {}
local function point(px, py)
local p = Model.World(spec, px, py); p.z = p.z-Model.RADIUS+0.003; return p
end
local count = math.min(12, math.max(1, math.ceil(length/0.08)))
for index = 0, count-1 do
local a, b = index/count, math.min(1, (index+0.55)/count)
lines[#lines+1] = { a = point(ball[1]+(x-ball[1])*a, ball[2]+(y-ball[2])*a),
b = point(ball[1]+(x-ball[1])*b, ball[2]+(y-ball[2])*b), color = '#ADEFFFC0' }
end
for index = 0, 7 do
local a, b = index*math.pi/4, (index+1)*math.pi/4
lines[#lines+1] = { a = point(x+math.cos(a)*Model.RADIUS, y+math.sin(a)*Model.RADIUS),
b = point(x+math.cos(b)*Model.RADIUS, y+math.sin(b)*Model.RADIUS), color = '#ADEFFFCC' }
end
local ok, why = Open77.debugDraw.set('pool.aim', { lines = lines, ttl = 0.2, maxDistance = 8, thickness = 1.5 })
if not ok then
refused = true; G.Clear(); OPX.Note('pool', 'aim guide unavailable: ' .. tostring(why)); return
end
shown = true
end
10 changes: 7 additions & 3 deletions modules/pool/client/input.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
local M = OPX.Modules.Get('pool')
M.Input = {}
local I = M.Input
function I.New() return { aiming = false, power = 0, angle = 0, previous = {} } end
function I.New() return { aiming = false, power = 0, angle = 0, overview = false, height = 0.5, previous = {} } end
function I.Step(state, now, buttons, allowed, captured, delta, sensitivity, seconds)
local old, event = state.previous, nil
local right = buttons.right and not old.right
Expand All @@ -16,12 +16,16 @@ function I.Step(state, now, buttons, allowed, captured, delta, sensitivity, seco
return { kind = 'leave' }
end
if right or (buttons.cancel and not old.cancel) then
state.aiming = right and not state.aiming or false
state.aiming = not buttons.cancel and right and not state.aiming or false
state.chargedAt, state.power = nil, 0
return { kind = 'aim', visible = state.aiming }
end
if state.aiming then
state.angle = (state.angle + delta*sensitivity + math.pi) % (2*math.pi) - math.pi
if buttons.overview and not old.overview then state.overview = not state.overview end
-- Finer direction while charging makes small-power shots repeatable.
local gain = buttons.precise and 0.15 or 1
if state.chargedAt then gain = gain*0.2 end
state.angle = (state.angle + delta*sensitivity*gain + math.pi) % (2*math.pi) - math.pi
if left then state.chargedAt = now end
if state.chargedAt then state.power = math.max(0.02, math.min(1, (now-state.chargedAt)/seconds)) end
if release and state.chargedAt then
Expand Down
Loading
Loading