I've bashed this together very lazily, mostly ripping the code out of checkLightLevel... but I was too lazy to try to integrate them, so this still requires checkLightLevel from the one-click, even though most of the code is ripped from it. Usage is simple, select a Weeping Angel (or any token) and type !blink The script will grab player tokens on the same page as the selected token (that is, tokens with a 'controlled by' set on either the token or the controlling character sheet). Then it will check: 1. Are any players facing the Angel with direct line of sight? If not, the script will stop checking, the Angel can move 2. Is the Angel illuminated? If it is brightly lit, all players from the first step can see it, and it cannot move, so the script will stop 3. Is the Angel dimly lit? If so, check any player tokens in sight for night vision, and if they are in range. If so, the Angel cannot move. 4. If we get here, it means no player tokens with line of sight have darkvision, or they are out of range of their darkvision. The Angel can move. Things the script won't account for - devil's sight, the ability to see in total darkness to a certain range - blindness, or other status conditions that might hamper a player's sight - legacy lighting (assuming this is still available??), script only works with new DL Things you can change: A few lines into the script there's a couple of variables in settings = { ... } - you will need to change these manually in the API script settings where you paste the script in, I'm far too lazy to write any CLI settings for the script, sorry :) - playerSightAngle: 90 - degrees to the side that players can see, 90 gives them 180 degree vision in the direction they're facing (speaking of which, I hope your players know they need to rotate their tokens....) - dimLightLimit: 0.1 - the level of light which is considered 'darkness' where darkvision doesn't work. Due to how the light falloff works on the VTT, I'd recommend not using '0' as it doesn't look right. You can 'see' things which aren't visible to your eye. I think checkLight level might have used 0.15, but tweak it if it doesn't feel right. Also... not tested very much, so let me know if something breaks, or acts weirdly (again, mostly ripped out of another script so ... didn't really check it too heavily). And again.... this requires the checkLightLevel script (and PathMath, which should be installed with checkLightLevel from the one-click) /* globals sendChat on log getObj playerIsGM findObjs PathMath checkLightLevel */ const blink = (() => { //eslint-disable-line const settings = { playerSightAngle: 90, dimLightLimit: 0.1, } const scriptName = 'blink', scriptVersion = '0.1.0'; const postChat = (chatText, whisper = 'gm') => { const whisperText = whisper ? `/w "${whisper}" ` : ''; sendChat(scriptName, `${whisperText}${chatText}`); } const getSelectedToken = (selected) => { const selectedId = selected && selected[0] ? selected[0]._id : null return selectedId ? getObj('graphic', selectedId) : null; } const getPlayerTokens = (page, targetToken) => { const allTokens = findObjs({ type: 'graphic', pageid: page.id, layer: 'objects' }); const playerTokens = allTokens.filter(token => { if (targetToken.id === token.id) return false; if (token.get('represents')) { const character = getObj('character', token.get('represents')); if (character?.get('controlledby') !== '') return true; } else return token.get('controlledby') !== ''; }); return playerTokens; } const getPageOfToken = (token) => token && token.id ? getObj('page', token.get('_pageid')) : null; const toDegrees = (rads) => rads*180/Math.PI; const getAngleFromX = (x, y) => toDegrees(Math.atan2(y, x)); const getPlayersFacingToken = (targetToken, pageOfToken) => { const playerTokens = getPlayerTokens(pageOfToken, targetToken); const playersFacingToken = playerTokens.filter(token => { const playerFacing = ((token.get('rotation') + 90) % 360) - 180; const tokenDelta = { x: parseInt(targetToken.get('left')) - parseInt(token.get('left')), y: parseInt(targetToken.get('top')) - parseInt(token.get('top')), }; const angleFromPlayer = getAngleFromX(tokenDelta.x, tokenDelta.y); return Math.abs(playerFacing - angleFromPlayer) <= settings.playerSightAngle; }); return playersFacingToken; } const isOneWayAndTransparent = (segment, lightFlowAngle, oneWayReversed) => { if (!segment || segment.length < 2) return; const delta = { x: segment[1][0] - segment[0][0], y: segment[0][1] - segment[1][1] } const segmentAngle = getAngleFromX(delta.x, delta.y); const transparencyAngle = oneWayReversed ? segmentAngle - 90 : segmentAngle + 90; const angleDifference = Math.abs(transparencyAngle - lightFlowAngle); return angleDifference < 90 ? true : false; } const checkLineOfSight = (token1, token2, page) => { const pos1 = { x: parseInt(token1.get('left')), y: parseInt(token1.get('top')) }, pos2 = { x: parseInt(token2.get('left')), y: parseInt(token2.get('top')) }, blockingPaths = findObjs({ type: 'path', pageid: page.id, layer: 'walls' }).filter(path => path.get('barrierType') !== 'transparent'); const losPath = new PathMath.Path([[pos1.x, pos1.y, 0], [pos2.x, pos2.y, 0]]); let losBlocked = null; for (let i=0; i<blockingPaths.length; i++) { let pathData; const isOneWayWall = blockingPaths[i].get('barrierType') === 'oneWay', oneWayReversed = isOneWayWall ? blockingPaths[i].get('oneWayReversed') : null, lightFlowAngle = isOneWayWall ? getAngleFromX(pos1.x - pos2.x, pos2.y - pos1.y) : null; try { pathData = JSON.parse(blockingPaths[i].get('path')); } catch(e) { console.error(e) } if (!pathData) continue; const pathTop = blockingPaths[i].get('top') - (blockingPaths[i].get('height')/2), pathLeft = blockingPaths[i].get('left') - (blockingPaths[i].get('width')/2); const pathVertices = pathData.map(vertex => [ vertex[1] + pathLeft, vertex[2] + pathTop, 0 ]); const wallPath = new PathMath.Path(pathVertices); const wallSegments = wallPath.toSegments(), losSegments = losPath.toSegments(); for (let w=0; w<wallSegments.length; w++) { if (losBlocked) break; const skipOneWaySegment = isOneWayWall ? isOneWayAndTransparent(wallSegments[w], lightFlowAngle, oneWayReversed) : false; if (skipOneWaySegment) { continue; } for (let l=0; l<losSegments.length; l++) { const intersect = PathMath.segmentIntersection(wallSegments[w], losSegments[l]);//wallPath.intersects(losPath); if (intersect) { losBlocked = blockingPaths[i]; break; } } } if (losBlocked) break; } return losBlocked; } const getUnobstructedPlayers = (playerTokens, targetToken, page) => playerTokens.filter(token => !checkLineOfSight(token, targetToken, page)); const feetToPixels = (feetValue, page) => { if (!page) return null; const gridPixelMultiplier = page.get('snapping_increment'), gridUnitScale = page.get('scale_number'); const pixelValue = feetValue/gridUnitScale*(gridPixelMultiplier*70); return pixelValue; } const getSeparation = (point1, point2) => { const delta = { x: point1.x - point2.x, y: point1.y - point2.y }, distance = Math.sqrt(delta.x**2 + delta.y**2); return distance; } const getTokenSeparation = (token1, token2) => { if (!token1 || !token2) return; const pos1 = { x: parseInt(token1.get('left')), y: parseInt(token1.get('top')) }, pos2 = { x: parseInt(token2.get('left')), y: parseInt(token2.get('top')) }; if (![pos1.x, pos1.y, pos2.x, pos2.y].reduce((valid, val) => (valid === true && Number.isSafeInteger(val)) ? true : false, true)) return null; return getSeparation(pos1, pos2); } const checkPlayersForDarkvision = (playerTokens, targetToken, page) => { return playerTokens.filter(player => { if (player.get('has_night_vision')) { const darkvisionRange = feetToPixels(player.get('night_vision_distance'), page); const tokenSeparation = getTokenSeparation(player, targetToken); if (darkvisionRange >= tokenSeparation) return true; } return false; }); } const getPlayerNames = (playerTokens) => { return playerTokens.map(token => token.get('name')).join(', '); } const canAngelMove = (angelToken) => { const pageOfToken = getPageOfToken(angelToken); const playersFacingAngel = getPlayersFacingToken(angelToken, pageOfToken); const playersWithLineOfSight = getUnobstructedPlayers(playersFacingAngel, angelToken, pageOfToken) if (playersWithLineOfSight?.length < 1) { return { angelCanMove: true, message: `No players are facing the ${angelToken.get('name')} with direct line-of-sight.`, } } const lightLevel = checkLightLevel.isLitBy(angelToken); if (lightLevel.bright) { return { angelCanMove: false, message: `Players can see the ${angelToken.get('name')}: ${getPlayerNames(playersWithLineOfSight)}`, } } const playersWithDarkvisionAndSight = checkPlayersForDarkvision(playersWithLineOfSight, angelToken, pageOfToken); if (playersWithDarkvisionAndSight?.length > 0) { return { angelCanMove: false, message: `Players can see the ${angelToken.get('name')} in dim light due to darkvision: ${getPlayerNames(playersWithDarkvisionAndSight)}`, } } else { return { angelCanMove: true, message: `The ${angelToken.get('name')} is obscured by darkness.` } } } const handleInput = (msg) => { if (msg.type === 'api' && /!blink/i.test(msg.content) && playerIsGM(msg.playerid)) { const token = getSelectedToken(msg.selected || []); if (!token) return postChat(`Nothing selected.`); const { angelCanMove, message } = canAngelMove(token); const moveHtml = angelCanMove ? `<div style="color:green;">${token.get('name')} can move</div>` : `<div style="color:red;">${token.get('name')} cannot move</div>`; postChat(createChatTemplate(token, [message], moveHtml)); } } const createChatTemplate = (token, messages, moveHtml) => { return ` <div class="light-outer" style="background: black; border-radius: 1rem; border: 2px solid #4c4c4c; white-space: nowrap;"> <div class="light-avatar" style=" display: inline-block!important; width: 20%; padding: 0.5rem;"> <img src="${token.get('imgsrc')}"/> </div> <div class="light-text" style="display: inline-block; color: whitesmoke; vertical-align: middle; width: 73%; white-space: pre-line;"> ${moveHtml} ${messages.reduce((out, msg) => out += `<p>${msg}</p>`, '')} </div> </div> `.replace(/\n/g, ''); } on('ready', () => { if (typeof(checkLightLevel) !== 'object') { postChat(`${scriptName}: Error - script requires checkLightLevel, aborting load`); return; } on('chat:message', handleInput); log(`=> ${scriptName} v${scriptVersion} started`) }); })();