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
12 changes: 11 additions & 1 deletion config/pool.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,17 @@ OPX.Config.MODULES.pool = {
SOLO_SHOTS = 10,
DUEL_TARGET = 5,
AUDIO_VOLUME = 0.65, -- 0 mutes the recorded table contacts
AIM_GUIDE = true, -- straight line to the first ball/cushion; no bounce prediction
AIM_GUIDE = true, -- master switch for the optical trajectory overlay
TRAJECTORY = {
ENABLED = true,
BASE_LEVEL = 0, -- 0: optics required; 1..3: minimum assistance for everyone
MIN_CONDITION = 1, -- installed implants at 0 condition do not grant a preview
MAX_BOUNCES = 2, MAX_DISTANCE = 4.5,
ANGLE_STEP = 0.005, POWER_STEP = 0.05, DEFAULT_POWER = 0.5,
IMPLANTS = {
kiroshi_optics = { SLOT = 'eyes', TIERS = { [1] = 1, [2] = 2, [3] = 3 } },
},
},
-- 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
38 changes: 34 additions & 4 deletions docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1037,9 +1037,39 @@ refusal, it retries once without that optional field and logs the legacy fallbac
Destroying only the owned camera restores the native presentation. Actual local
head/hair rendering and framing still require a live client check.

The straight aiming guide stops at the first ball or cushion and shows a contact
ring, without predicting rebounds. `AIM_GUIDE = false` hides it. `SENSITIVITY`
defaults to `0.0018`, `CHARGE_SECONDS` to `1.8`. Real CC0 cue, ball and pocket
The optical trajectory preview is configured under `TRAJECTORY` in
`config/pool.lua`. `AIM_GUIDE = false` or `TRAJECTORY.ENABLED = false` disables it.
By default `BASE_LEVEL = 0`: no implant means no overlay. Installed
`kiroshi_optics` in the `eyes` slot grants level 1/2/3 for implant tier 1/2/3:

- Level 1: the incoming line and first-impact ring, up to 1.5 m.
- Level 2: incoming guidance plus separate estimated paths for the white ball
(cyan) and the first object ball (amber), up to 3 m per traced path.
- Level 3: outgoing paths and cushion rebounds, subject to `MAX_BOUNCES` (0–2)
and `MAX_DISTANCE` (0.5–6 m; default 4.5 m).

`IMPLANTS` maps a ripperdoc chrome ID to `SLOT` and a `TIERS` map from installed
implant tier (1–5) to assistance level (0–3). Change it to support other optics.
`MIN_CONDITION` defaults to 1: broken or unknown-condition implants grant no bonus.
`BASE_LEVEL` can provide a minimum level without chrome if the operator wants one.
The server reads the same installed `cyberware` metadata as ripperdoc, not inventory
items or a level sent by the client. Only the current shooter receives their own
numeric grant; no implant records are added to pool packets. The server refreshes
it at least on each watch and once per second during a turn, so removal/tier changes
do not depend on a client's cooperation. Character/session changes still clear the
entire pool view. Missing or malformed grants disable preview without dropping
ball playback, and revocation immediately clears existing lines when received.

The HUD identifies the preview as an estimate. Its path responds to direction and
charge power; before charging it uses `DEFAULT_POWER` (default 0.5). `ANGLE_STEP`
(default 0.005 radians, coarser at lower levels) and `POWER_STEP` (default 0.05)
quantize the estimate for a stable, deliberately approximate readout. It models
stationary balls, drag, the first transfer of momentum and cushion losses. It
stops at pockets or the next object collision, and does not promise multi-ball
chains, spin or an exact final resting position. No preview modifies the real
balls, scores, turns or server shot validation.

`SENSITIVITY` defaults to `0.0018`, `CHARGE_SECONDS` to `1.8`. Real CC0 cue, ball and pocket
recordings are delivered as PCM WAV by the asset resource; rails reuse the ball
clip at lower gain. `AUDIO_VOLUME` (0–1, default 0.65) sets loudness, zero mutes.
Server contacts carry bounded IDs/times; client audio follows the same 100 ms
Expand All @@ -1049,7 +1079,7 @@ client exports; that resource owns at most eight spatial voices. See its NOTICE
and audio/sources.json for original recordings, CC0 links, exact hashes and edits.

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
Its camera/prop work runs per frame; preview 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.
Expand Down
61 changes: 21 additions & 40 deletions modules/pool/client/guide.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
--- A bounded straight aiming aid, ending at the first ball or cushion.
--- Resource-owned optical preview; finite geometry with a short native expiry.
-- @author dop42
local M = OPX.Modules.Get('pool')
local Model = M.Model
Expand All @@ -9,49 +9,30 @@ 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
function G.End(spec, points, angle) return M.Prediction.End(spec,points,angle) end
function G.Draw(spec, points, angle, now, profile, power)
profile = profile or M.Assistance.Profile(M.Assistance.Read(), 1)
if profile.level == 0 then G.Clear(); return end
if refused or now-last < 100 then return end
last = now
local x, y, length = G.End(spec, points, angle)
local ball = points[1]
local paths, contact = M.Prediction.Trace(spec, points, angle, power, profile)
local lines = {}
local function point(px, py)
local p = Model.World(spec, px, py); p.z = p.z-Model.RADIUS+0.003; return p
local c, s = math.cos(math.rad(spec.yaw)), math.sin(math.rad(spec.yaw))
local function point(x, y)
return { x=spec.x+x*c-y*s, y=spec.y+x*s+y*c, z=spec.z-Model.RADIUS+0.003 }
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' }
for _, path in ipairs(paths) do
lines[#lines+1] = { a=point(path[1],path[2]), b=point(path[3],path[4]), color=path[5] }
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
if contact then
for index = 0, 7 do
local a, b = index*math.pi/4, (index+1)*math.pi/4
lines[#lines+1] = { a=point(contact[1]+math.cos(a)*Model.RADIUS,contact[2]+math.sin(a)*Model.RADIUS),
b=point(contact[1]+math.cos(b)*Model.RADIUS,contact[2]+math.sin(b)*Model.RADIUS), color='#ADEFFFCC' }
end
end
if #lines == 0 then G.Clear(); return 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
11 changes: 8 additions & 3 deletions modules/pool/client/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ local M = OPX.Modules.Get('pool')
local Model, B, I, R, C, G, Match, TableMenu = M.Model, M.Playback, M.Input, M.Render, M.Camera, M.Guide, M.Match, M.TableMenu
local spec, buffer, input, positions, target, prompts, jobs, lastPacket, subscribed, lastMouse, lastWheel, lastAim, waiting, owned, held, reported, lastControls
local autoAim, following, shotSeen, hudVisible, member, memberSlot, watching
local openTable, reopenTable
local openTable, reopenTable, assistanceSettings
local function request(action, extra, snapshot)
extra = extra or {}
extra.epoch = snapshot and snapshot.epoch or buffer.epoch
Expand Down Expand Up @@ -56,8 +56,10 @@ local function receive(packet)
if not spec or not subscribed then return end
if type(packet) == 'table' and packet.closed == true then clear(true); return end
local oldEpoch, wasOwned = buffer.epoch, owned
local oldAssistance = buffer.state and buffer.state.assistance or 0
if B.Push(buffer, packet, OPX.Now()) then
lastPacket = OPX.Now()
if oldAssistance ~= buffer.state.assistance then G.Clear() end
M.Audio.Queue(packet, buffer)
local nextOwned = buffer.state.owner == Open77.players.localId()
if oldEpoch ~= buffer.epoch or (wasOwned and not nextOwned) then
Expand Down Expand Up @@ -189,7 +191,8 @@ end
local function guide()
if owned and input.aiming and positions and positions[1][3] and buffer.state and buffer.state.phase == 'ready'
and Open77.input.isCaptured() == false and M.Settings.AIM_GUIDE ~= false then
G.Draw(spec, positions, input.angle, OPX.Now())
G.Draw(spec, positions, input.angle, OPX.Now(),
M.Assistance.Profile(assistanceSettings, buffer.state.assistance), input.chargedAt and input.power or nil)
else G.Clear() end
end
openTable = function()
Expand Down Expand Up @@ -245,7 +248,8 @@ local function strip()
turn = game and game.turn or 0, raceLabel = locale('pool.raceTo', { target = game and game.target or 5 }),
soloStats = locale('pool.points', { score = game and game.scores[1] or 0, shots = game and game.shots or 0 }),
duel = game and game.mode == 'duel' or false, target = game and game.target or 5,
hint = locale(following and 'pool.rolling' or not playing and 'pool.manageHint' or not owned and 'pool.waitTurn' or input.chargedAt and 'pool.release' or 'pool.charge'),
hint = locale(following and 'pool.rolling' or not playing and 'pool.manageHint' or not owned and 'pool.waitTurn' or input.chargedAt and 'pool.release' or 'pool.charge')
.. (owned and input.aiming and (buffer.state.assistance or 0) > 0 and (' · '..locale('pool.preview'..buffer.state.assistance)) or ''),
view = locale(input.overview and 'pool.tableView' or 'pool.cueView') })
end
local function frame() press(); draw(); if subscribed and spec then M.Audio.Frame(spec, OPX.Now()) end end
Expand All @@ -258,6 +262,7 @@ function M.Init()
end
function M.Start()
R.Reset(); G.Reset()
assistanceSettings = M.Assistance.Read(M.Settings.TRAJECTORY)
spec = Model.Table()
if not spec or not R.Register() then return end
target, prompts = OPX.Api.Get('target'), OPX.Api.Get('prompts')
Expand Down
80 changes: 80 additions & 0 deletions modules/pool/client/prediction.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
--- Bounded optical estimates: static balls, one transferred impact, no game mutations.
-- @author dop42
local M = OPX.Modules.Get('pool')
local R, Number = M.Model.RADIUS, M.Model.Number
local DRAG, RAIL, TRANSFER = 0.7, 0.82, 0.985
M.Prediction = {}
local P = M.Prediction
local function circle(x, y, dx, dy, cx, cy, radius)
local rx, ry = cx-x, cy-y
local ahead = rx*dx+ry*dy
if ahead <= 0 then return nil end
local side = rx*dy-ry*dx
local square = radius*radius-side*side
if square < 0 then return nil end
return math.max(0, ahead-math.sqrt(square))
end
local function first(spec, points, x, y, dx, dy, limit, self, skip)
local distance, kind, index = limit, 'stop', nil
local function offer(t, what, which)
if t and t >= 0 and t < distance then distance, kind, index = t, what, which end
end
if math.abs(dx) > 1e-8 then offer(((dx > 0 and spec.halfLength-R or -spec.halfLength+R)-x)/dx, 'x') end
if math.abs(dy) > 1e-8 then offer(((dy > 0 and spec.halfWidth-R or -spec.halfWidth+R)-y)/dy, 'y') end
for slot = 1, M.Model.BALLS do
local ball = points[slot]
if slot ~= self and slot ~= skip and ball and ball[3] then
local t = circle(x,y,dx,dy,ball[1],ball[2],R*2)
if t and t < distance then distance,kind,index = t,'ball',slot end
end
end
for side = -1, 1, 2 do
for column = -1, 1 do offer(circle(x,y,dx,dy,column*spec.halfLength,side*spec.halfWidth,0.065), 'pocket') end
end
return distance, kind, index
end
function P.End(spec, points, angle)
local x,y,dx,dy = points[1][1],points[1][2],math.cos(angle),math.sin(angle)
local distance = first(spec,points,x,y,dx,dy,6,1)
return x+dx*distance,y+dy*distance,distance
end
function P.Trace(spec, points, angle, power, profile)
local paths, contact = {}, nil
if not profile or profile.level == 0 or not points[1] or not points[1][3] then return paths end
angle = Number(angle, -math.pi*2, math.pi*2) or 0
angle = math.floor(angle/profile.angleStep+0.5)*profile.angleStep
power = Number(power, 0.02, 1) or profile.power
power = math.max(0.02, math.min(1, math.floor(power/profile.powerStep+0.5)*profile.powerStep))
local function walk(x, y, dx, dy, speed, bounces, self, skip, color, range, capture)
for bounce = 0, bounces do
local limit = math.min(range, speed*speed/(2*DRAG))
if limit < 0.001 then return nil end
local distance, kind, index = first(spec,points,x,y,dx,dy,limit,self,skip)
local ex, ey = x+dx*distance, y+dy*distance
if capture and not contact and (kind == 'ball' or kind == 'x' or kind == 'y') then contact={ex,ey} end
if distance > 0.00001 then paths[#paths+1] = { x, y, ex, ey, color } end
speed = math.sqrt(math.max(0, speed*speed-2*DRAG*distance))
if kind == 'ball' then return { x=ex, y=ey, dx=dx, dy=dy, speed=speed, index=index, bounces=bounce, remaining=range-distance } end
if kind ~= 'x' and kind ~= 'y' then return nil end
if kind == 'x' then dx = -dx else dy = -dy end
speed, range = speed*RAIL, range-distance
x, y = ex+dx*0.00001, ey+dy*0.00001
end
end
local ball = points[1]
local hit = walk(ball[1],ball[2],math.cos(angle),math.sin(angle),0.2+4.3*power,
profile.bounces,1,nil,'#ADEFFFC0',profile.distance,true)
if hit and profile.outgoing then
local object = points[hit.index]
local nx, ny = object[1]-hit.x, object[2]-hit.y
local length = math.sqrt(nx*nx+ny*ny)
nx, ny = nx/math.max(length,1e-8), ny/math.max(length,1e-8)
local transfer = math.max(0, hit.dx*nx+hit.dy*ny)*TRANSFER
walk(object[1],object[2],nx,ny,hit.speed*transfer,profile.bounces,hit.index,1,'#FFD166CC',profile.distance)
local vx, vy = hit.dx-nx*transfer, hit.dy-ny*transfer
local speed = math.sqrt(vx*vx+vy*vy)
if speed > 0.02 then walk(hit.x,hit.y,vx/speed,vy/speed,hit.speed*speed,
math.max(0,profile.bounces-hit.bounces),1,hit.index,'#78DBF0B0',hit.remaining) end
end
return paths, contact
end
6 changes: 6 additions & 0 deletions modules/pool/locales.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
-- @author dop42
local M = OPX.Modules.Get('pool')
local EN = {
['pool.preview1'] = 'Optical impact estimate',
['pool.preview2'] = 'Optical trajectory estimate',
['pool.preview3'] = 'Optical rebound estimate',
['pool.enter'] = 'Play pool',
['pool.aim'] = 'Aim / cancel',
['pool.charge'] = 'Hold to charge, release to shoot',
Expand Down Expand Up @@ -69,6 +72,9 @@ local EN = {
['pool.sessionEnded'] = "The table is available for a new session.",
}
local FR = {
['pool.preview1'] = 'Impact estimé par les optiques',
['pool.preview2'] = 'Trajectoires estimées par les optiques',
['pool.preview3'] = 'Rebonds estimés par les optiques',
['pool.enter'] = 'Jouer au billard',
['pool.aim'] = 'Viser / annuler',
['pool.charge'] = 'Maintenir pour doser, relâcher pour tirer',
Expand Down
Loading
Loading